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
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Graph operations for people, contacts, directory users and photos (spec §8.5).
|
|
2
|
+
|
|
3
|
+
Pure: client and parameters in, Graph dicts / PageResult / DownloadResult out.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from mgraphctl import odata
|
|
11
|
+
from mgraphctl.errors import GraphError
|
|
12
|
+
from mgraphctl.http import DownloadResult, GraphClient, PageResult
|
|
13
|
+
|
|
14
|
+
PEOPLE_SELECT = (
|
|
15
|
+
"id,displayName,scoredEmailAddresses,jobTitle,department,companyName,personType,"
|
|
16
|
+
"userPrincipalName"
|
|
17
|
+
)
|
|
18
|
+
CONTACTS_SELECT = "id,displayName,emailAddresses,mobilePhone,businessPhones,jobTitle,companyName"
|
|
19
|
+
|
|
20
|
+
PAGE = 50
|
|
21
|
+
CAP = 250 # spec §5.3: people search, contacts — page_size 50, default limit 20, --all cap 250
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def search_people(client: GraphClient, q: str, *, limit: int, all_: bool) -> PageResult:
|
|
25
|
+
"""`/me/people` relevance search (spec §8.5 `search`); the endpoint is in maintenance mode."""
|
|
26
|
+
params = {"$search": odata.kql(q), "$select": PEOPLE_SELECT}
|
|
27
|
+
return client.paginate(
|
|
28
|
+
"/me/people", params=params, limit=limit, all_=all_, cap=CAP, page_size=PAGE
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _contact_matches(contact: dict, term: str) -> bool:
|
|
33
|
+
if term in (contact.get("displayName") or "").casefold():
|
|
34
|
+
return True
|
|
35
|
+
return any(
|
|
36
|
+
term in (addr.get("address") or "").casefold()
|
|
37
|
+
for addr in contact.get("emailAddresses") or []
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def list_contacts(client: GraphClient, *, search: str | None, limit: int, all_: bool) -> PageResult:
|
|
42
|
+
"""Contacts, optionally `$search`-filtered; a 400 on `$search` falls back to a client-side
|
|
43
|
+
substring match over displayName/email of up to 250 contacts (spec §8.5 `contacts`).
|
|
44
|
+
"""
|
|
45
|
+
if not search:
|
|
46
|
+
return client.paginate(
|
|
47
|
+
"/me/contacts",
|
|
48
|
+
params={"$select": CONTACTS_SELECT},
|
|
49
|
+
limit=limit,
|
|
50
|
+
all_=all_,
|
|
51
|
+
cap=CAP,
|
|
52
|
+
page_size=PAGE,
|
|
53
|
+
)
|
|
54
|
+
params = {"$select": CONTACTS_SELECT, "$search": odata.kql(search)}
|
|
55
|
+
try:
|
|
56
|
+
return client.paginate(
|
|
57
|
+
"/me/contacts", params=params, limit=limit, all_=all_, cap=CAP, page_size=PAGE
|
|
58
|
+
)
|
|
59
|
+
except GraphError as exc:
|
|
60
|
+
if exc.status != 400:
|
|
61
|
+
raise
|
|
62
|
+
page = client.paginate(
|
|
63
|
+
"/me/contacts",
|
|
64
|
+
params={"$select": CONTACTS_SELECT},
|
|
65
|
+
limit=None,
|
|
66
|
+
all_=True,
|
|
67
|
+
cap=CAP,
|
|
68
|
+
page_size=CAP,
|
|
69
|
+
)
|
|
70
|
+
term = search.casefold()
|
|
71
|
+
filtered = [c for c in page.items if _contact_matches(c, term)]
|
|
72
|
+
bound = CAP if all_ else min(limit, CAP)
|
|
73
|
+
return PageResult(items=filtered[:bound], truncated=len(filtered) > bound, pages=page.pages)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def get_contact(client: GraphClient, contact_id: str) -> dict:
|
|
77
|
+
return client.get(odata.p("me", "contacts", contact_id))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def download_photo(client: GraphClient, upn: str | None, size: str, dest: Path) -> DownloadResult:
|
|
81
|
+
"""`/me/photo/$value` for the signed-in user; `/users/{upn}/photos/{size}/$value` otherwise."""
|
|
82
|
+
path = "/me/photo/$value" if upn is None else f"{odata.p('users', upn)}/photos/{size}/$value"
|
|
83
|
+
return client.download(path, dest)
|
|
@@ -0,0 +1,415 @@
|
|
|
1
|
+
"""Graph operations for Planner plans and tasks (spec §8.14).
|
|
2
|
+
|
|
3
|
+
Pure: client and parameters in, Graph dicts / `Plan` out. Planner paths carry no OData query
|
|
4
|
+
parameters (spec §8.14); slicing (`--limit`) and filtering (bucket, completed) happen client-side
|
|
5
|
+
on values already returned by Graph. `list_plans` and `my_tasks` annotate merged items with
|
|
6
|
+
underscore-prefixed keys (`_groupName`, `_planTitle`, `_bucketName`) used only by text-mode
|
|
7
|
+
columns; JSON callers get them too since `render.emit` serialises `ListResult.items` unchanged.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from collections.abc import Callable
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
from mgraphctl import odata
|
|
16
|
+
from mgraphctl.errors import GraphError, MsgraphError, UsageError
|
|
17
|
+
from mgraphctl.graph import users
|
|
18
|
+
from mgraphctl.http import (
|
|
19
|
+
JSON_HEADERS,
|
|
20
|
+
BatchRequest,
|
|
21
|
+
GraphClient,
|
|
22
|
+
PageResult,
|
|
23
|
+
Plan,
|
|
24
|
+
PlannedRequest,
|
|
25
|
+
)
|
|
26
|
+
from mgraphctl.render import to_iso_offset
|
|
27
|
+
from mgraphctl.resolve import looks_like_id, pick_unique, split_id_prefix
|
|
28
|
+
|
|
29
|
+
# `on_skip`, when given, is called with one human-readable line per batch sub-request that
|
|
30
|
+
# failed (e.g. a group without Planner enabled, or a plan deleted since `my_tasks` read the
|
|
31
|
+
# task list). Both `list_plans` and `my_tasks` degrade gracefully on such a failure — the rest
|
|
32
|
+
# of the merged/annotated result is still returned — rather than failing the whole command;
|
|
33
|
+
# callers pass `render.note` so the omission is visible on stderr instead of silent. `graph/*`
|
|
34
|
+
# functions never call `render` directly (spec §7.2), hence the injected callback.
|
|
35
|
+
OnSkip = Callable[[str], None]
|
|
36
|
+
|
|
37
|
+
MY_TASKS_PLAN_TITLE_CAP = 20
|
|
38
|
+
PERCENT_VALUES = (0, 50, 100)
|
|
39
|
+
# Not a user-facing cap: large enough that name resolution never misses a real match because
|
|
40
|
+
# a display `--limit` truncated the merged list first.
|
|
41
|
+
RESOLUTION_LIMIT = 10_000
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _run(client: GraphClient, plan: Plan) -> Any:
|
|
45
|
+
"""Execute every step of `plan` in order, returning the last step's result."""
|
|
46
|
+
result: Any = None
|
|
47
|
+
for step in plan:
|
|
48
|
+
result = client.execute(step)
|
|
49
|
+
return result
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# --------------------------------------------------------------------------- reads
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def list_plans(
|
|
56
|
+
client: GraphClient, *, limit: int = 50, on_skip: OnSkip | None = None
|
|
57
|
+
) -> PageResult:
|
|
58
|
+
"""Plans the signed-in user owns, plus every plan of a group they belong to (§8.14).
|
|
59
|
+
|
|
60
|
+
`GET /me/planner/plans` ∪ (for each unified group from `users.list_unified_groups`) a
|
|
61
|
+
`$batch` of `GET /groups/{id}/planner/plans`, deduped by id. Plans found only via a group
|
|
62
|
+
are annotated with `_groupName` (text column only); the owned list is authoritative and
|
|
63
|
+
keeps no `_groupName`, but a plan seen in more than one place still gets one if a group
|
|
64
|
+
supplied it. A group whose batch sub-request fails (e.g. Planner not enabled there) is
|
|
65
|
+
skipped rather than failing the whole call — same policy as `my_tasks` below for a failed
|
|
66
|
+
plan-title lookup — reported through `on_skip` when given.
|
|
67
|
+
"""
|
|
68
|
+
own = client.get("/me/planner/plans") or {}
|
|
69
|
+
merged: dict[str, dict] = {item["id"]: item for item in own.get("value") or []}
|
|
70
|
+
groups = users.list_unified_groups(client)
|
|
71
|
+
if groups:
|
|
72
|
+
requests = [
|
|
73
|
+
BatchRequest(
|
|
74
|
+
id=str(i), method="GET", url=odata.p("groups", g["id"], "planner", "plans")
|
|
75
|
+
)
|
|
76
|
+
for i, g in enumerate(groups, 1)
|
|
77
|
+
]
|
|
78
|
+
responses = client.batch(requests)
|
|
79
|
+
for group, response in zip(groups, responses, strict=True):
|
|
80
|
+
if response.error is not None:
|
|
81
|
+
if on_skip is not None:
|
|
82
|
+
name = group.get("displayName") or group["id"]
|
|
83
|
+
on_skip(f"skipped plans for group {name}: {response.error.message}")
|
|
84
|
+
continue
|
|
85
|
+
for item in (response.body or {}).get("value") or []:
|
|
86
|
+
existing = merged.get(item["id"])
|
|
87
|
+
if existing is not None:
|
|
88
|
+
existing.setdefault("_groupName", group.get("displayName"))
|
|
89
|
+
else:
|
|
90
|
+
merged[item["id"]] = {**item, "_groupName": group.get("displayName")}
|
|
91
|
+
items = list(merged.values())
|
|
92
|
+
return PageResult(items=items[:limit], truncated=len(items) > limit, pages=1)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def resolve_plan(client: GraphClient, value: str) -> dict:
|
|
96
|
+
"""A plan id, or `title` looked up among `list_plans` (§6.6)."""
|
|
97
|
+
if looks_like_id(value, "planner"):
|
|
98
|
+
bare, _ = split_id_prefix(value)
|
|
99
|
+
return {"id": bare}
|
|
100
|
+
# Resolution needs every visible plan, not just the first page a `--limit` would keep.
|
|
101
|
+
candidates = list_plans(client, limit=RESOLUTION_LIMIT)
|
|
102
|
+
return pick_unique(candidates.items, "title", value, what="plan")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def get_plan(client: GraphClient, plan_id: str) -> dict:
|
|
106
|
+
"""`{...plan, "details": {...}}` (spec §8.14 `plan`)."""
|
|
107
|
+
plan_obj = client.get(odata.p("planner", "plans", plan_id))
|
|
108
|
+
details = client.get(odata.p("planner", "plans", plan_id, "details"))
|
|
109
|
+
return {**plan_obj, "details": details}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def list_buckets(client: GraphClient, plan_id: str) -> list[dict]:
|
|
113
|
+
payload = client.get(odata.p("planner", "plans", plan_id, "buckets")) or {}
|
|
114
|
+
return payload.get("value") or []
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def resolve_bucket(client: GraphClient, plan_id: str, value: str) -> dict:
|
|
118
|
+
"""A bucket id, or `name` looked up among the plan's buckets (§6.6)."""
|
|
119
|
+
if looks_like_id(value, "planner"):
|
|
120
|
+
bare, _ = split_id_prefix(value)
|
|
121
|
+
return {"id": bare}
|
|
122
|
+
return pick_unique(list_buckets(client, plan_id), "name", value, what="bucket")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def resolve_bucket_for_task(client: GraphClient, task_id: str, value: str) -> dict:
|
|
126
|
+
"""Resolve a `--bucket` name on `update`, where the plan is not already known.
|
|
127
|
+
|
|
128
|
+
An id-shaped value never needs the task's plan; a name does, so this fetches the task
|
|
129
|
+
first to learn `planId` before delegating to `resolve_bucket`.
|
|
130
|
+
"""
|
|
131
|
+
if looks_like_id(value, "planner"):
|
|
132
|
+
bare, _ = split_id_prefix(value)
|
|
133
|
+
return {"id": bare}
|
|
134
|
+
current = client.get(odata.p("planner", "tasks", task_id))
|
|
135
|
+
return resolve_bucket(client, current["planId"], value)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def list_tasks(
|
|
139
|
+
client: GraphClient,
|
|
140
|
+
plan_id: str,
|
|
141
|
+
*,
|
|
142
|
+
bucket_id: str | None = None,
|
|
143
|
+
include_completed: bool = False,
|
|
144
|
+
limit: int = 50,
|
|
145
|
+
) -> PageResult:
|
|
146
|
+
"""A plan's tasks, hiding completed ones and naming buckets, sliced to `limit` (§8.14)."""
|
|
147
|
+
payload = client.get(odata.p("planner", "plans", plan_id, "tasks")) or {}
|
|
148
|
+
items = payload.get("value") or []
|
|
149
|
+
if not include_completed:
|
|
150
|
+
items = [t for t in items if t.get("percentComplete") != 100]
|
|
151
|
+
if bucket_id is not None:
|
|
152
|
+
items = [t for t in items if t.get("bucketId") == bucket_id]
|
|
153
|
+
names = {b["id"]: b.get("name") for b in list_buckets(client, plan_id)}
|
|
154
|
+
for item in items:
|
|
155
|
+
item["_bucketName"] = names.get(item.get("bucketId"), "")
|
|
156
|
+
return PageResult(items=items[:limit], truncated=len(items) > limit, pages=1)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def my_tasks(
|
|
160
|
+
client: GraphClient,
|
|
161
|
+
*,
|
|
162
|
+
include_completed: bool = False,
|
|
163
|
+
limit: int = 50,
|
|
164
|
+
on_skip: OnSkip | None = None,
|
|
165
|
+
) -> PageResult:
|
|
166
|
+
"""The signed-in user's tasks across every plan, annotated with plan titles (§8.14 `--my`).
|
|
167
|
+
|
|
168
|
+
A plan whose title lookup fails (e.g. deleted since the task was created) is skipped —
|
|
169
|
+
that task just keeps a blank `_planTitle` — same graceful-degradation policy as
|
|
170
|
+
`list_plans` above for a failed group lookup, reported through `on_skip` when given.
|
|
171
|
+
"""
|
|
172
|
+
payload = client.get("/me/planner/tasks") or {}
|
|
173
|
+
items = payload.get("value") or []
|
|
174
|
+
if not include_completed:
|
|
175
|
+
items = [t for t in items if t.get("percentComplete") != 100]
|
|
176
|
+
plan_ids = list(dict.fromkeys(t["planId"] for t in items if t.get("planId")))
|
|
177
|
+
plan_ids = plan_ids[:MY_TASKS_PLAN_TITLE_CAP]
|
|
178
|
+
titles: dict[str, str] = {}
|
|
179
|
+
if plan_ids:
|
|
180
|
+
requests = [
|
|
181
|
+
BatchRequest(id=str(i), method="GET", url=odata.p("planner", "plans", pid))
|
|
182
|
+
for i, pid in enumerate(plan_ids, 1)
|
|
183
|
+
]
|
|
184
|
+
responses = client.batch(requests)
|
|
185
|
+
for pid, response in zip(plan_ids, responses, strict=True):
|
|
186
|
+
if response.error is None and isinstance(response.body, dict):
|
|
187
|
+
titles[pid] = response.body.get("title", "")
|
|
188
|
+
elif on_skip is not None:
|
|
189
|
+
detail = response.error.message if response.error else "empty response"
|
|
190
|
+
on_skip(f"skipped plan title for {pid}: {detail}")
|
|
191
|
+
for item in items:
|
|
192
|
+
item["_planTitle"] = titles.get(item.get("planId"), "")
|
|
193
|
+
return PageResult(items=items[:limit], truncated=len(items) > limit, pages=1)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def get_task(client: GraphClient, task_id: str) -> dict:
|
|
197
|
+
"""`{...task, "details": {...}}`; a details fetch failure is swallowed (§8.14 `task`)."""
|
|
198
|
+
task_obj = client.get(odata.p("planner", "tasks", task_id))
|
|
199
|
+
try:
|
|
200
|
+
details = client.get(odata.p("planner", "tasks", task_id, "details"))
|
|
201
|
+
except MsgraphError:
|
|
202
|
+
details = {}
|
|
203
|
+
return {**task_obj, "details": details}
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# --------------------------------------------------------------------------- writes
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _create_body(
|
|
210
|
+
*,
|
|
211
|
+
plan_id: str,
|
|
212
|
+
bucket_id: str | None,
|
|
213
|
+
title: str,
|
|
214
|
+
due: Any,
|
|
215
|
+
assignee_oids: list[str],
|
|
216
|
+
priority: int | None,
|
|
217
|
+
) -> dict:
|
|
218
|
+
body: dict[str, Any] = {"planId": plan_id}
|
|
219
|
+
if bucket_id is not None:
|
|
220
|
+
body["bucketId"] = bucket_id
|
|
221
|
+
body["title"] = title
|
|
222
|
+
if due is not None:
|
|
223
|
+
body["dueDateTime"] = to_iso_offset(due)
|
|
224
|
+
if priority is not None:
|
|
225
|
+
body["priority"] = priority
|
|
226
|
+
if assignee_oids:
|
|
227
|
+
body["assignments"] = {
|
|
228
|
+
oid: {"@odata.type": "#microsoft.graph.plannerAssignment", "orderHint": " !"}
|
|
229
|
+
for oid in assignee_oids
|
|
230
|
+
}
|
|
231
|
+
return body
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _description_step(tasks_url: str, task_id: str, description: str) -> PlannedRequest:
|
|
235
|
+
url = f"{tasks_url}/{task_id}/details"
|
|
236
|
+
return PlannedRequest(
|
|
237
|
+
"PATCH", url, {**JSON_HEADERS, "If-Match": "{etag}"}, {"description": description}
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _write_description(client: GraphClient, task_id: str, description: str) -> dict:
|
|
242
|
+
path = odata.p("planner", "tasks", task_id, "details")
|
|
243
|
+
details = client.get(path) or {}
|
|
244
|
+
etag = details.get("@odata.etag")
|
|
245
|
+
headers = {"If-Match": etag} if etag else {}
|
|
246
|
+
return client.patch(path, json={"description": description}, headers=headers)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def plan_create_task(
|
|
250
|
+
client: GraphClient,
|
|
251
|
+
*,
|
|
252
|
+
plan_id: str,
|
|
253
|
+
bucket_id: str | None = None,
|
|
254
|
+
title: str,
|
|
255
|
+
due: Any = None,
|
|
256
|
+
assignee_oids: list[str] | None = None,
|
|
257
|
+
priority: int | None = None,
|
|
258
|
+
description: str | None = None,
|
|
259
|
+
) -> Plan:
|
|
260
|
+
body = _create_body(
|
|
261
|
+
plan_id=plan_id,
|
|
262
|
+
bucket_id=bucket_id,
|
|
263
|
+
title=title,
|
|
264
|
+
due=due,
|
|
265
|
+
assignee_oids=assignee_oids or [],
|
|
266
|
+
priority=priority,
|
|
267
|
+
)
|
|
268
|
+
tasks_url = client.url(odata.p("planner", "tasks"))
|
|
269
|
+
steps: Plan = [PlannedRequest("POST", tasks_url, dict(JSON_HEADERS), body)]
|
|
270
|
+
if description is not None:
|
|
271
|
+
steps.append(_description_step(tasks_url, "{taskId}", description))
|
|
272
|
+
return steps
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def run_create_task(
|
|
276
|
+
client: GraphClient,
|
|
277
|
+
*,
|
|
278
|
+
plan_id: str,
|
|
279
|
+
bucket_id: str | None = None,
|
|
280
|
+
title: str,
|
|
281
|
+
due: Any = None,
|
|
282
|
+
assignee_oids: list[str] | None = None,
|
|
283
|
+
priority: int | None = None,
|
|
284
|
+
description: str | None = None,
|
|
285
|
+
) -> dict:
|
|
286
|
+
body = _create_body(
|
|
287
|
+
plan_id=plan_id,
|
|
288
|
+
bucket_id=bucket_id,
|
|
289
|
+
title=title,
|
|
290
|
+
due=due,
|
|
291
|
+
assignee_oids=assignee_oids or [],
|
|
292
|
+
priority=priority,
|
|
293
|
+
)
|
|
294
|
+
task = client.post(odata.p("planner", "tasks"), json=body)
|
|
295
|
+
if description is not None:
|
|
296
|
+
details = _write_description(client, task["id"], description)
|
|
297
|
+
task = {**task, "details": details}
|
|
298
|
+
return task
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def _update_body(
|
|
302
|
+
*,
|
|
303
|
+
title: str | None = None,
|
|
304
|
+
due: Any = None,
|
|
305
|
+
percent: int | None = None,
|
|
306
|
+
bucket_id: str | None = None,
|
|
307
|
+
priority: int | None = None,
|
|
308
|
+
assign_oids: list[str] | None = None,
|
|
309
|
+
unassign_oids: list[str] | None = None,
|
|
310
|
+
) -> dict:
|
|
311
|
+
body: dict[str, Any] = {}
|
|
312
|
+
if title is not None:
|
|
313
|
+
body["title"] = title
|
|
314
|
+
if due is not None:
|
|
315
|
+
body["dueDateTime"] = to_iso_offset(due)
|
|
316
|
+
if percent is not None:
|
|
317
|
+
if percent not in PERCENT_VALUES:
|
|
318
|
+
raise UsageError("USAGE", "--percent must be 0, 50 or 100")
|
|
319
|
+
body["percentComplete"] = percent
|
|
320
|
+
if bucket_id is not None:
|
|
321
|
+
body["bucketId"] = bucket_id
|
|
322
|
+
if priority is not None:
|
|
323
|
+
body["priority"] = priority
|
|
324
|
+
assignments: dict[str, Any] = {}
|
|
325
|
+
for oid in assign_oids or []:
|
|
326
|
+
assignments[oid] = {"@odata.type": "#microsoft.graph.plannerAssignment", "orderHint": " !"}
|
|
327
|
+
for oid in unassign_oids or []:
|
|
328
|
+
assignments[oid] = None
|
|
329
|
+
if assignments:
|
|
330
|
+
body["assignments"] = assignments
|
|
331
|
+
return body
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
def plan_update_task(
|
|
335
|
+
client: GraphClient,
|
|
336
|
+
task_id: str,
|
|
337
|
+
*,
|
|
338
|
+
title: str | None = None,
|
|
339
|
+
due: Any = None,
|
|
340
|
+
percent: int | None = None,
|
|
341
|
+
bucket_id: str | None = None,
|
|
342
|
+
priority: int | None = None,
|
|
343
|
+
assign_oids: list[str] | None = None,
|
|
344
|
+
unassign_oids: list[str] | None = None,
|
|
345
|
+
description: str | None = None,
|
|
346
|
+
) -> Plan:
|
|
347
|
+
body = _update_body(
|
|
348
|
+
title=title,
|
|
349
|
+
due=due,
|
|
350
|
+
percent=percent,
|
|
351
|
+
bucket_id=bucket_id,
|
|
352
|
+
priority=priority,
|
|
353
|
+
assign_oids=assign_oids,
|
|
354
|
+
unassign_oids=unassign_oids,
|
|
355
|
+
)
|
|
356
|
+
tasks_url = client.url(odata.p("planner", "tasks"))
|
|
357
|
+
steps: Plan = []
|
|
358
|
+
if body:
|
|
359
|
+
headers = {**JSON_HEADERS, "If-Match": "{etag}", "Prefer": "return=representation"}
|
|
360
|
+
steps.append(PlannedRequest("PATCH", f"{tasks_url}/{task_id}", headers, body))
|
|
361
|
+
if description is not None:
|
|
362
|
+
steps.append(_description_step(tasks_url, task_id, description))
|
|
363
|
+
return steps
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def run_update_task(
|
|
367
|
+
client: GraphClient,
|
|
368
|
+
task_id: str,
|
|
369
|
+
*,
|
|
370
|
+
title: str | None = None,
|
|
371
|
+
due: Any = None,
|
|
372
|
+
percent: int | None = None,
|
|
373
|
+
bucket_id: str | None = None,
|
|
374
|
+
priority: int | None = None,
|
|
375
|
+
assign_oids: list[str] | None = None,
|
|
376
|
+
unassign_oids: list[str] | None = None,
|
|
377
|
+
description: str | None = None,
|
|
378
|
+
) -> dict:
|
|
379
|
+
body = _update_body(
|
|
380
|
+
title=title,
|
|
381
|
+
due=due,
|
|
382
|
+
percent=percent,
|
|
383
|
+
bucket_id=bucket_id,
|
|
384
|
+
priority=priority,
|
|
385
|
+
assign_oids=assign_oids,
|
|
386
|
+
unassign_oids=unassign_oids,
|
|
387
|
+
)
|
|
388
|
+
result: dict | None = None
|
|
389
|
+
if body:
|
|
390
|
+
path = odata.p("planner", "tasks", task_id)
|
|
391
|
+
etag = (client.get(path) or {}).get("@odata.etag")
|
|
392
|
+
headers = {"If-Match": etag, "Prefer": "return=representation"}
|
|
393
|
+
try:
|
|
394
|
+
result = client.patch(path, json=body, headers=headers)
|
|
395
|
+
except GraphError as exc:
|
|
396
|
+
if exc.status != 412:
|
|
397
|
+
raise
|
|
398
|
+
etag = (client.get(path) or {}).get("@odata.etag")
|
|
399
|
+
headers = {"If-Match": etag, "Prefer": "return=representation"}
|
|
400
|
+
result = client.patch(path, json=body, headers=headers)
|
|
401
|
+
if description is not None:
|
|
402
|
+
details = _write_description(client, task_id, description)
|
|
403
|
+
result = {**(result or {}), "details": details}
|
|
404
|
+
return result or {}
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def plan_delete_task(client: GraphClient, task_id: str) -> Plan:
|
|
408
|
+
url = client.url(odata.p("planner", "tasks", task_id))
|
|
409
|
+
return [PlannedRequest("DELETE", url, {"If-Match": "{etag}"}, None, expect="none")]
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def run_delete_task(client: GraphClient, task_id: str) -> None:
|
|
413
|
+
path = odata.p("planner", "tasks", task_id)
|
|
414
|
+
etag = (client.get(path) or {}).get("@odata.etag")
|
|
415
|
+
client.delete(path, headers={"If-Match": etag}, expect="none")
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Graph operations for Teams presence (spec §8.9).
|
|
2
|
+
|
|
3
|
+
Pure: client and parameters in, Graph dicts or a `Plan` out.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from collections.abc import Iterable
|
|
9
|
+
from datetime import timedelta
|
|
10
|
+
|
|
11
|
+
from mgraphctl import odata
|
|
12
|
+
from mgraphctl.errors import UsageError
|
|
13
|
+
from mgraphctl.http import JSON_HEADERS, GraphClient, Plan, PlannedRequest
|
|
14
|
+
from mgraphctl.render import iso_duration
|
|
15
|
+
|
|
16
|
+
# The CLI state, and the `availability` / `activity` pair Graph wants for it.
|
|
17
|
+
PRESENCE_PAIRS: dict[str, tuple[str, str]] = {
|
|
18
|
+
"available": ("Available", "Available"),
|
|
19
|
+
"busy": ("Busy", "Busy"),
|
|
20
|
+
"dnd": ("DoNotDisturb", "DoNotDisturb"),
|
|
21
|
+
"brb": ("BeRightBack", "BeRightBack"),
|
|
22
|
+
"away": ("Away", "Away"),
|
|
23
|
+
"offline": ("Offline", "OffWork"),
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def resolve_pair(state: str) -> tuple[str, str]:
|
|
28
|
+
"""The Graph availability/activity pair for a CLI state name."""
|
|
29
|
+
pair = PRESENCE_PAIRS.get(state.casefold())
|
|
30
|
+
if pair is None:
|
|
31
|
+
raise UsageError(
|
|
32
|
+
"USAGE", f"unknown presence {state!r}; use one of {', '.join(PRESENCE_PAIRS)}"
|
|
33
|
+
)
|
|
34
|
+
return pair
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def get_presence(client: GraphClient) -> dict:
|
|
38
|
+
return client.get("/me/presence")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_presences(client: GraphClient, ids: Iterable[str]) -> list[dict]:
|
|
42
|
+
payload = client.post("/communications/getPresencesByUserId", json={"ids": list(ids)}) or {}
|
|
43
|
+
return payload.get("value") or []
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _presence_base(client: GraphClient, my_oid: str) -> str:
|
|
47
|
+
"""`/users/{oid}/presence`; the oid comes from the token, so there is no `/me` call."""
|
|
48
|
+
return client.url(odata.p("users", my_oid, "presence"))
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def plan_set(
|
|
52
|
+
client: GraphClient,
|
|
53
|
+
my_oid: str,
|
|
54
|
+
availability: str,
|
|
55
|
+
*,
|
|
56
|
+
expiration: timedelta,
|
|
57
|
+
message: str | None,
|
|
58
|
+
) -> Plan:
|
|
59
|
+
state, activity = resolve_pair(availability)
|
|
60
|
+
base = _presence_base(client, my_oid)
|
|
61
|
+
steps = [
|
|
62
|
+
PlannedRequest(
|
|
63
|
+
"POST",
|
|
64
|
+
f"{base}/setUserPreferredPresence",
|
|
65
|
+
dict(JSON_HEADERS),
|
|
66
|
+
{
|
|
67
|
+
"availability": state,
|
|
68
|
+
"activity": activity,
|
|
69
|
+
"expirationDuration": iso_duration(expiration),
|
|
70
|
+
},
|
|
71
|
+
expect="none",
|
|
72
|
+
)
|
|
73
|
+
]
|
|
74
|
+
if message is not None:
|
|
75
|
+
steps.append(
|
|
76
|
+
PlannedRequest(
|
|
77
|
+
"POST",
|
|
78
|
+
f"{base}/setStatusMessage",
|
|
79
|
+
dict(JSON_HEADERS),
|
|
80
|
+
{"statusMessage": {"message": {"content": message, "contentType": "text"}}},
|
|
81
|
+
expect="none",
|
|
82
|
+
)
|
|
83
|
+
)
|
|
84
|
+
return steps
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def plan_clear(client: GraphClient, my_oid: str) -> Plan:
|
|
88
|
+
base = _presence_base(client, my_oid)
|
|
89
|
+
return [PlannedRequest("POST", f"{base}/clearUserPreferredPresence", {}, None, expect="none")]
|