teams-phone-cli 0.1.2__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.
- teams_phone/__init__.py +3 -0
- teams_phone/__main__.py +7 -0
- teams_phone/cli/__init__.py +8 -0
- teams_phone/cli/api_check.py +267 -0
- teams_phone/cli/auth.py +201 -0
- teams_phone/cli/context.py +108 -0
- teams_phone/cli/helpers.py +65 -0
- teams_phone/cli/locations.py +308 -0
- teams_phone/cli/main.py +99 -0
- teams_phone/cli/numbers.py +1644 -0
- teams_phone/cli/policies.py +893 -0
- teams_phone/cli/tenants.py +364 -0
- teams_phone/cli/users.py +394 -0
- teams_phone/constants.py +97 -0
- teams_phone/exceptions.py +137 -0
- teams_phone/infrastructure/__init__.py +22 -0
- teams_phone/infrastructure/cache_manager.py +274 -0
- teams_phone/infrastructure/config_manager.py +209 -0
- teams_phone/infrastructure/debug_logger.py +321 -0
- teams_phone/infrastructure/graph_client.py +666 -0
- teams_phone/infrastructure/output_formatter.py +234 -0
- teams_phone/models/__init__.py +76 -0
- teams_phone/models/api_responses.py +69 -0
- teams_phone/models/auth.py +100 -0
- teams_phone/models/cache.py +25 -0
- teams_phone/models/config.py +66 -0
- teams_phone/models/location.py +36 -0
- teams_phone/models/number.py +184 -0
- teams_phone/models/policy.py +26 -0
- teams_phone/models/tenant.py +45 -0
- teams_phone/models/user.py +117 -0
- teams_phone/services/__init__.py +21 -0
- teams_phone/services/auth_service.py +536 -0
- teams_phone/services/bulk_operations.py +562 -0
- teams_phone/services/location_service.py +195 -0
- teams_phone/services/number_service.py +489 -0
- teams_phone/services/policy_service.py +330 -0
- teams_phone/services/tenant_service.py +205 -0
- teams_phone/services/user_service.py +435 -0
- teams_phone/utils.py +172 -0
- teams_phone_cli-0.1.2.dist-info/METADATA +15 -0
- teams_phone_cli-0.1.2.dist-info/RECORD +45 -0
- teams_phone_cli-0.1.2.dist-info/WHEEL +4 -0
- teams_phone_cli-0.1.2.dist-info/entry_points.txt +2 -0
- teams_phone_cli-0.1.2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,435 @@
|
|
|
1
|
+
"""User service for Teams Phone user configuration operations.
|
|
2
|
+
|
|
3
|
+
This module provides the UserService class for listing, searching, and
|
|
4
|
+
retrieving Teams user voice configurations from Microsoft Graph API.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
from teams_phone.constants import GRAPH_API_V1_BASE
|
|
13
|
+
from teams_phone.exceptions import NotFoundError, ValidationError
|
|
14
|
+
from teams_phone.models import AccountType, UserConfiguration
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from teams_phone.infrastructure.graph_client import GraphClient
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _strip_odata_metadata(data: dict[str, Any]) -> dict[str, Any]:
|
|
22
|
+
"""Strip OData metadata fields from a response dict.
|
|
23
|
+
|
|
24
|
+
Graph API responses include @odata.context and other @ prefixed fields
|
|
25
|
+
that are not part of the actual data contract. This helper removes them
|
|
26
|
+
before Pydantic validation with extra="forbid".
|
|
27
|
+
"""
|
|
28
|
+
return {k: v for k, v in data.items() if not k.startswith("@")}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class UserService:
|
|
32
|
+
"""Service for user voice configuration operations.
|
|
33
|
+
|
|
34
|
+
Provides methods for listing, searching, and retrieving Teams user
|
|
35
|
+
configurations from the Microsoft Graph API.
|
|
36
|
+
|
|
37
|
+
Attributes:
|
|
38
|
+
graph_client: GraphClient instance for API communication.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
# Maximum IDs per filter query (URL length limit)
|
|
42
|
+
_MAX_IDS_PER_BATCH = 15
|
|
43
|
+
|
|
44
|
+
def __init__(self, graph_client: GraphClient) -> None:
|
|
45
|
+
"""Initialize the UserService.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
graph_client: GraphClient instance for API communication.
|
|
49
|
+
"""
|
|
50
|
+
self.graph_client = graph_client
|
|
51
|
+
|
|
52
|
+
async def _fetch_display_names(
|
|
53
|
+
self,
|
|
54
|
+
user_ids: list[str],
|
|
55
|
+
) -> dict[str, str]:
|
|
56
|
+
"""Fetch display names for a list of user IDs from the directory.
|
|
57
|
+
|
|
58
|
+
The /admin/teams/userConfigurations endpoint doesn't return displayName,
|
|
59
|
+
so we fetch it separately from the v1.0 /users endpoint.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
user_ids: List of Entra object IDs to fetch display names for.
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
Dict mapping user ID to display name. Users not found are omitted.
|
|
66
|
+
"""
|
|
67
|
+
if not user_ids:
|
|
68
|
+
return {}
|
|
69
|
+
|
|
70
|
+
display_names: dict[str, str] = {}
|
|
71
|
+
|
|
72
|
+
# Batch IDs to avoid URL length limits
|
|
73
|
+
for i in range(0, len(user_ids), self._MAX_IDS_PER_BATCH):
|
|
74
|
+
batch_ids = user_ids[i : i + self._MAX_IDS_PER_BATCH]
|
|
75
|
+
|
|
76
|
+
# Build filter: id in ('id1', 'id2', ...)
|
|
77
|
+
id_list = ", ".join(f"'{uid}'" for uid in batch_ids)
|
|
78
|
+
url = f"{GRAPH_API_V1_BASE}/users"
|
|
79
|
+
params: dict[str, Any] = {
|
|
80
|
+
"$filter": f"id in ({id_list})",
|
|
81
|
+
"$select": "id,displayName",
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async for item in self.graph_client.get_paginated_url(
|
|
85
|
+
url,
|
|
86
|
+
params=params,
|
|
87
|
+
):
|
|
88
|
+
user_id = item.get("id")
|
|
89
|
+
display_name = item.get("displayName")
|
|
90
|
+
if user_id and display_name:
|
|
91
|
+
display_names[user_id] = display_name
|
|
92
|
+
|
|
93
|
+
return display_names
|
|
94
|
+
|
|
95
|
+
async def _enrich_display_names(
|
|
96
|
+
self,
|
|
97
|
+
users: list[UserConfiguration],
|
|
98
|
+
) -> list[UserConfiguration]:
|
|
99
|
+
"""Enrich a list of users with display names from the directory.
|
|
100
|
+
|
|
101
|
+
Args:
|
|
102
|
+
users: List of UserConfiguration objects to enrich.
|
|
103
|
+
|
|
104
|
+
Returns:
|
|
105
|
+
New list with display names populated where available.
|
|
106
|
+
"""
|
|
107
|
+
user_ids = [u.id for u in users if u.display_name is None]
|
|
108
|
+
if not user_ids:
|
|
109
|
+
return users
|
|
110
|
+
|
|
111
|
+
display_names = await self._fetch_display_names(user_ids)
|
|
112
|
+
return [
|
|
113
|
+
u.model_copy(update={"display_name": display_names.get(u.id)})
|
|
114
|
+
if u.display_name is None and u.id in display_names
|
|
115
|
+
else u
|
|
116
|
+
for u in users
|
|
117
|
+
]
|
|
118
|
+
|
|
119
|
+
async def list_users(
|
|
120
|
+
self,
|
|
121
|
+
*,
|
|
122
|
+
licensed: bool | None = None,
|
|
123
|
+
assigned: bool | None = None,
|
|
124
|
+
account_type: AccountType | None = None,
|
|
125
|
+
page_size: int = 100,
|
|
126
|
+
max_items: int | None = None,
|
|
127
|
+
) -> list[UserConfiguration]:
|
|
128
|
+
"""List users with optional filtering and pagination.
|
|
129
|
+
|
|
130
|
+
Retrieves user voice configurations from Microsoft Graph API
|
|
131
|
+
with support for filtering by licensing, phone number assignment,
|
|
132
|
+
and account type.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
licensed: If True, only return users with enterprise voice enabled.
|
|
136
|
+
If False, only return users without enterprise voice.
|
|
137
|
+
If None, return all users regardless of voice license.
|
|
138
|
+
assigned: If True, only return users with phone numbers assigned.
|
|
139
|
+
If False, only return users without phone numbers.
|
|
140
|
+
If None, return all users regardless of assignment status.
|
|
141
|
+
account_type: Filter by account type (user, resourceAccount, etc.).
|
|
142
|
+
If None, return all account types.
|
|
143
|
+
page_size: Number of items per page (default 100, max 999).
|
|
144
|
+
max_items: Maximum total items to return. If None, return all items.
|
|
145
|
+
|
|
146
|
+
Returns:
|
|
147
|
+
List of UserConfiguration objects matching the filter criteria.
|
|
148
|
+
|
|
149
|
+
Raises:
|
|
150
|
+
ContractError: If API response is missing expected fields.
|
|
151
|
+
AuthenticationError: If authentication fails.
|
|
152
|
+
RateLimitError: If rate limit exceeded after retries.
|
|
153
|
+
"""
|
|
154
|
+
users: list[UserConfiguration] = []
|
|
155
|
+
params: dict[str, Any] = {"$top": str(min(page_size, 999))}
|
|
156
|
+
|
|
157
|
+
# Build OData $filter string based on parameters
|
|
158
|
+
# Note: assigned filter is done client-side (Graph API doesn't support $count on telephoneNumbers)
|
|
159
|
+
filters: list[str] = []
|
|
160
|
+
if licensed is not None:
|
|
161
|
+
filters.append(f"isEnterpriseVoiceEnabled eq {str(licensed).lower()}")
|
|
162
|
+
if account_type is not None:
|
|
163
|
+
filters.append(f"accountType eq '{account_type.value}'")
|
|
164
|
+
|
|
165
|
+
if filters:
|
|
166
|
+
params["$filter"] = " and ".join(filters)
|
|
167
|
+
|
|
168
|
+
async for item in self.graph_client.get_paginated(
|
|
169
|
+
"/admin/teams/userConfigurations",
|
|
170
|
+
params=params,
|
|
171
|
+
max_items=max_items
|
|
172
|
+
if assigned is None
|
|
173
|
+
else None, # Fetch all if filtering client-side
|
|
174
|
+
):
|
|
175
|
+
user = UserConfiguration.model_validate(item)
|
|
176
|
+
# Client-side filter for assigned status
|
|
177
|
+
if assigned is not None:
|
|
178
|
+
has_numbers = len(user.telephone_numbers) > 0
|
|
179
|
+
if assigned and not has_numbers:
|
|
180
|
+
continue
|
|
181
|
+
if not assigned and has_numbers:
|
|
182
|
+
continue
|
|
183
|
+
users.append(user)
|
|
184
|
+
# Check max_items after client-side filtering
|
|
185
|
+
if max_items is not None and len(users) >= max_items:
|
|
186
|
+
break
|
|
187
|
+
|
|
188
|
+
# Enrich with display names from directory
|
|
189
|
+
# The Teams admin API doesn't return displayName
|
|
190
|
+
return await self._enrich_display_names(users)
|
|
191
|
+
|
|
192
|
+
async def get_user(self, user_id: str) -> UserConfiguration:
|
|
193
|
+
"""Get a single user's voice configuration by ID.
|
|
194
|
+
|
|
195
|
+
Retrieves detailed voice configuration for a specific user
|
|
196
|
+
identified by their Entra object ID.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
user_id: The user's Entra object ID (GUID).
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
UserConfiguration for the specified user.
|
|
203
|
+
|
|
204
|
+
Raises:
|
|
205
|
+
NotFoundError: If the user is not found.
|
|
206
|
+
ContractError: If API response doesn't match expected schema.
|
|
207
|
+
AuthenticationError: If authentication fails.
|
|
208
|
+
"""
|
|
209
|
+
response = await self.graph_client.get(
|
|
210
|
+
f"/admin/teams/userConfigurations/{user_id}"
|
|
211
|
+
)
|
|
212
|
+
user = UserConfiguration.model_validate(_strip_odata_metadata(response))
|
|
213
|
+
|
|
214
|
+
# Enrich with display name from directory if not present
|
|
215
|
+
enriched = await self._enrich_display_names([user])
|
|
216
|
+
return enriched[0]
|
|
217
|
+
|
|
218
|
+
async def _search_directory_users(
|
|
219
|
+
self,
|
|
220
|
+
query: str,
|
|
221
|
+
*,
|
|
222
|
+
max_results: int = 25,
|
|
223
|
+
) -> list[dict[str, str | None]]:
|
|
224
|
+
"""Search users by display name via v1.0 /users endpoint.
|
|
225
|
+
|
|
226
|
+
The /admin/teams/userConfigurations endpoint doesn't support
|
|
227
|
+
displayName filtering, so we search the directory first.
|
|
228
|
+
We also return displayName since the Teams admin API doesn't
|
|
229
|
+
populate it in the userConfigurations response.
|
|
230
|
+
|
|
231
|
+
Args:
|
|
232
|
+
query: Display name search query.
|
|
233
|
+
max_results: Maximum number of results to return.
|
|
234
|
+
|
|
235
|
+
Returns:
|
|
236
|
+
List of dicts with 'id' and 'displayName' for matching users.
|
|
237
|
+
"""
|
|
238
|
+
# Use v1.0 /users endpoint which supports displayName filtering
|
|
239
|
+
# Build full URL since GraphClient defaults to beta
|
|
240
|
+
url = f"{GRAPH_API_V1_BASE}/users"
|
|
241
|
+
params: dict[str, Any] = {
|
|
242
|
+
"$filter": f"startsWith(displayName, '{query}')",
|
|
243
|
+
"$select": "id,displayName",
|
|
244
|
+
"$top": str(min(max_results, 999)),
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
users: list[dict[str, str | None]] = []
|
|
248
|
+
async for item in self.graph_client.get_paginated_url(
|
|
249
|
+
url,
|
|
250
|
+
params=params,
|
|
251
|
+
max_items=max_results,
|
|
252
|
+
):
|
|
253
|
+
if "id" in item:
|
|
254
|
+
users.append(
|
|
255
|
+
{
|
|
256
|
+
"id": str(item["id"]),
|
|
257
|
+
"displayName": item.get("displayName"),
|
|
258
|
+
}
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
return users
|
|
262
|
+
|
|
263
|
+
async def search_users(
|
|
264
|
+
self,
|
|
265
|
+
query: str,
|
|
266
|
+
*,
|
|
267
|
+
max_results: int = 25,
|
|
268
|
+
) -> list[UserConfiguration]:
|
|
269
|
+
"""Search users by display name, UPN, or phone number.
|
|
270
|
+
|
|
271
|
+
Searches user configurations using OData filters. Automatically
|
|
272
|
+
detects the query type and applies appropriate filtering:
|
|
273
|
+
- Phone numbers (starting with +): Searches telephoneNumbers collection
|
|
274
|
+
- Email addresses (containing @): Searches by UPN
|
|
275
|
+
- Other queries: Searches by display name using v1.0 /users endpoint
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
query: Search query - phone number, UPN, or display name.
|
|
279
|
+
max_results: Maximum number of results to return (default 25).
|
|
280
|
+
|
|
281
|
+
Returns:
|
|
282
|
+
List of UserConfiguration objects matching the search query.
|
|
283
|
+
|
|
284
|
+
Raises:
|
|
285
|
+
ContractError: If API response doesn't match expected schema.
|
|
286
|
+
AuthenticationError: If authentication fails.
|
|
287
|
+
RateLimitError: If rate limit exceeded after retries.
|
|
288
|
+
"""
|
|
289
|
+
# Detect query type and route to appropriate search method
|
|
290
|
+
if query.startswith("+") or re.match(r"^\d+$", query):
|
|
291
|
+
# Phone number search - normalize and search in telephoneNumbers
|
|
292
|
+
# Handle both E.164 format and raw digits
|
|
293
|
+
normalized = query if query.startswith("+") else f"+{query}"
|
|
294
|
+
params: dict[str, Any] = {
|
|
295
|
+
"$top": str(min(max_results, 999)),
|
|
296
|
+
"$filter": (
|
|
297
|
+
f"telephoneNumbers/any(t: t/telephoneNumber eq '{normalized}')"
|
|
298
|
+
),
|
|
299
|
+
}
|
|
300
|
+
users: list[UserConfiguration] = []
|
|
301
|
+
async for item in self.graph_client.get_paginated(
|
|
302
|
+
"/admin/teams/userConfigurations",
|
|
303
|
+
params=params,
|
|
304
|
+
max_items=max_results,
|
|
305
|
+
):
|
|
306
|
+
users.append(UserConfiguration.model_validate(item))
|
|
307
|
+
# Enrich with display names
|
|
308
|
+
return await self._enrich_display_names(users)
|
|
309
|
+
|
|
310
|
+
elif "@" in query:
|
|
311
|
+
# UPN search - exact match on userConfigurations
|
|
312
|
+
params = {
|
|
313
|
+
"$top": str(min(max_results, 999)),
|
|
314
|
+
"$filter": f"userPrincipalName eq '{query}'",
|
|
315
|
+
}
|
|
316
|
+
users = []
|
|
317
|
+
async for item in self.graph_client.get_paginated(
|
|
318
|
+
"/admin/teams/userConfigurations",
|
|
319
|
+
params=params,
|
|
320
|
+
max_items=max_results,
|
|
321
|
+
):
|
|
322
|
+
users.append(UserConfiguration.model_validate(item))
|
|
323
|
+
# Enrich with display names
|
|
324
|
+
return await self._enrich_display_names(users)
|
|
325
|
+
|
|
326
|
+
else:
|
|
327
|
+
# Display name search - use v1.0 /users endpoint first,
|
|
328
|
+
# then fetch Teams configurations for matched users
|
|
329
|
+
directory_users = await self._search_directory_users(
|
|
330
|
+
query, max_results=max_results
|
|
331
|
+
)
|
|
332
|
+
|
|
333
|
+
if not directory_users:
|
|
334
|
+
return []
|
|
335
|
+
|
|
336
|
+
# Fetch Teams configurations for each matched user
|
|
337
|
+
# Note: The Teams admin API doesn't return displayName, so we
|
|
338
|
+
# inject it from the directory search result
|
|
339
|
+
users = []
|
|
340
|
+
for dir_user in directory_users:
|
|
341
|
+
user_id = dir_user["id"]
|
|
342
|
+
if user_id is None:
|
|
343
|
+
continue # Skip if somehow id is None (shouldn't happen)
|
|
344
|
+
try:
|
|
345
|
+
user_config = await self.get_user(user_id)
|
|
346
|
+
# Inject displayName from directory if Teams API didn't provide it
|
|
347
|
+
if user_config.display_name is None and dir_user.get("displayName"):
|
|
348
|
+
# Create a new instance with the displayName set
|
|
349
|
+
# Using model_copy to avoid mutating the original
|
|
350
|
+
user_config = user_config.model_copy(
|
|
351
|
+
update={"display_name": dir_user["displayName"]}
|
|
352
|
+
)
|
|
353
|
+
users.append(user_config)
|
|
354
|
+
except NotFoundError:
|
|
355
|
+
# User exists in directory but has no Teams configuration
|
|
356
|
+
# (e.g., unlicensed user) - skip them
|
|
357
|
+
continue
|
|
358
|
+
|
|
359
|
+
return users
|
|
360
|
+
|
|
361
|
+
async def resolve_user(self, identifier: str) -> UserConfiguration:
|
|
362
|
+
"""Resolve a user by UPN, display name, or object ID.
|
|
363
|
+
|
|
364
|
+
Attempts to find a single user matching the identifier. Uses
|
|
365
|
+
intelligent detection to determine the identifier type:
|
|
366
|
+
- GUID format: Direct lookup by object ID
|
|
367
|
+
- Email format (contains @): Lookup by UPN
|
|
368
|
+
- Other: Search by display name
|
|
369
|
+
|
|
370
|
+
Args:
|
|
371
|
+
identifier: User identifier - UPN, display name, or object ID.
|
|
372
|
+
|
|
373
|
+
Returns:
|
|
374
|
+
UserConfiguration for the resolved user.
|
|
375
|
+
|
|
376
|
+
Raises:
|
|
377
|
+
NotFoundError: If no user matches the identifier.
|
|
378
|
+
ValidationError: If multiple users match (ambiguous identifier).
|
|
379
|
+
AuthenticationError: If authentication fails.
|
|
380
|
+
"""
|
|
381
|
+
# Check if identifier is a GUID (object ID)
|
|
382
|
+
guid_pattern = re.compile(
|
|
383
|
+
r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-"
|
|
384
|
+
r"[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
|
|
385
|
+
)
|
|
386
|
+
|
|
387
|
+
if guid_pattern.match(identifier):
|
|
388
|
+
# Direct lookup by ID
|
|
389
|
+
return await self.get_user(identifier)
|
|
390
|
+
|
|
391
|
+
if "@" in identifier:
|
|
392
|
+
# UPN lookup - search for exact match
|
|
393
|
+
users = await self.search_users(identifier, max_results=1)
|
|
394
|
+
if not users:
|
|
395
|
+
raise NotFoundError(
|
|
396
|
+
f"User '{identifier}' not found",
|
|
397
|
+
remediation=(
|
|
398
|
+
"Verify the user principal name (email) is correct.\n"
|
|
399
|
+
"Use 'teams-phone users search' to find available users."
|
|
400
|
+
),
|
|
401
|
+
)
|
|
402
|
+
return users[0]
|
|
403
|
+
|
|
404
|
+
# Display name search - requires disambiguation
|
|
405
|
+
users = await self.search_users(identifier, max_results=10)
|
|
406
|
+
|
|
407
|
+
if not users:
|
|
408
|
+
raise NotFoundError(
|
|
409
|
+
f"No user found matching '{identifier}'",
|
|
410
|
+
remediation=(
|
|
411
|
+
"No user found with a display name starting with "
|
|
412
|
+
f"'{identifier}'.\n"
|
|
413
|
+
"Use 'teams-phone users list' to see available users, or\n"
|
|
414
|
+
"try searching by UPN (email address) for exact matching."
|
|
415
|
+
),
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
if len(users) == 1:
|
|
419
|
+
return users[0]
|
|
420
|
+
|
|
421
|
+
# Multiple matches - require disambiguation
|
|
422
|
+
user_list = "\n".join(
|
|
423
|
+
f" - {u.display_name or u.user_principal_name} ({u.user_principal_name})"
|
|
424
|
+
for u in users[:5]
|
|
425
|
+
)
|
|
426
|
+
additional = f"\n ... and {len(users) - 5} more" if len(users) > 5 else ""
|
|
427
|
+
|
|
428
|
+
raise ValidationError(
|
|
429
|
+
f"Multiple users match '{identifier}'",
|
|
430
|
+
remediation=(
|
|
431
|
+
f"The following users match your query:\n{user_list}{additional}\n\n"
|
|
432
|
+
"Please specify the user by their full UPN (email address) "
|
|
433
|
+
"or object ID\nto avoid ambiguity."
|
|
434
|
+
),
|
|
435
|
+
)
|
teams_phone/utils.py
ADDED
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
"""Phone number normalization and helper utilities."""
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from teams_phone.exceptions import ValidationError
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def normalize_phone_number(number: str) -> str:
|
|
9
|
+
"""Normalize a phone number by stripping non-digit characters.
|
|
10
|
+
|
|
11
|
+
Accepts various formats and converts to digits-only format.
|
|
12
|
+
Validates that the result has at least 10 digits.
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
number: Phone number in any format (e.g., "+1 (206) 555-1234",
|
|
16
|
+
"1-206-555-1234", "+12065551234").
|
|
17
|
+
|
|
18
|
+
Returns:
|
|
19
|
+
Digits-only string (e.g., "12065551234").
|
|
20
|
+
|
|
21
|
+
Raises:
|
|
22
|
+
ValidationError: If the number has fewer than 10 digits after
|
|
23
|
+
stripping non-digit characters.
|
|
24
|
+
|
|
25
|
+
Examples:
|
|
26
|
+
>>> normalize_phone_number("+1 (206) 555-1234")
|
|
27
|
+
'12065551234'
|
|
28
|
+
>>> normalize_phone_number("1-206-555-1234")
|
|
29
|
+
'12065551234'
|
|
30
|
+
>>> normalize_phone_number("+12065551234")
|
|
31
|
+
'12065551234'
|
|
32
|
+
"""
|
|
33
|
+
digits = re.sub(r"\D", "", number)
|
|
34
|
+
|
|
35
|
+
if len(digits) < 10:
|
|
36
|
+
raise ValidationError(
|
|
37
|
+
f"Invalid phone number: '{number}' has fewer than 10 digits",
|
|
38
|
+
remediation="Provide a valid phone number with at least 10 digits.",
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
return digits
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def format_for_assign(number: str) -> str:
|
|
45
|
+
"""Format a phone number for the assignNumber/unassignNumber API endpoints.
|
|
46
|
+
|
|
47
|
+
These endpoints require digits only (no + prefix).
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
number: Phone number in any format.
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
Digits-only string suitable for Graph API assign/unassign operations.
|
|
54
|
+
|
|
55
|
+
Raises:
|
|
56
|
+
ValidationError: If the number is invalid.
|
|
57
|
+
|
|
58
|
+
Examples:
|
|
59
|
+
>>> format_for_assign("+1 (206) 555-1234")
|
|
60
|
+
'12065551234'
|
|
61
|
+
>>> format_for_assign("12065551234")
|
|
62
|
+
'12065551234'
|
|
63
|
+
"""
|
|
64
|
+
return normalize_phone_number(number)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def format_for_update(number: str) -> str:
|
|
68
|
+
"""Format a phone number for the updateNumber API endpoint.
|
|
69
|
+
|
|
70
|
+
This endpoint requires E.164 format with + prefix.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
number: Phone number in any format.
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
E.164 formatted string with + prefix (e.g., "+12065551234").
|
|
77
|
+
|
|
78
|
+
Raises:
|
|
79
|
+
ValidationError: If the number is invalid.
|
|
80
|
+
|
|
81
|
+
Examples:
|
|
82
|
+
>>> format_for_update("1 (206) 555-1234")
|
|
83
|
+
'+12065551234'
|
|
84
|
+
>>> format_for_update("+12065551234")
|
|
85
|
+
'+12065551234'
|
|
86
|
+
"""
|
|
87
|
+
digits = normalize_phone_number(number)
|
|
88
|
+
return f"+{digits}"
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def format_for_display(number: str) -> str:
|
|
92
|
+
"""Format a phone number for user-friendly display.
|
|
93
|
+
|
|
94
|
+
Formats US/Canada NANP numbers as: +1 (NPA) NXX-XXXX
|
|
95
|
+
For international numbers, returns E.164 format.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
number: Phone number in any format.
|
|
99
|
+
|
|
100
|
+
Returns:
|
|
101
|
+
Formatted string for display (e.g., "+1 (206) 555-1234").
|
|
102
|
+
|
|
103
|
+
Raises:
|
|
104
|
+
ValidationError: If the number is invalid.
|
|
105
|
+
|
|
106
|
+
Examples:
|
|
107
|
+
>>> format_for_display("12065551234")
|
|
108
|
+
'+1 (206) 555-1234'
|
|
109
|
+
>>> format_for_display("+442071234567")
|
|
110
|
+
'+442071234567'
|
|
111
|
+
"""
|
|
112
|
+
digits = normalize_phone_number(number)
|
|
113
|
+
|
|
114
|
+
# US/Canada NANP: 11 digits starting with 1
|
|
115
|
+
if len(digits) == 11 and digits.startswith("1"):
|
|
116
|
+
return f"+1 ({digits[1:4]}) {digits[4:7]}-{digits[7:11]}"
|
|
117
|
+
|
|
118
|
+
# US/Canada NANP without country code: 10 digits
|
|
119
|
+
if len(digits) == 10:
|
|
120
|
+
return f"+1 ({digits[0:3]}) {digits[3:6]}-{digits[6:10]}"
|
|
121
|
+
|
|
122
|
+
# International numbers: return E.164 format
|
|
123
|
+
return f"+{digits}"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def match_pattern(number: str, pattern: str) -> bool:
|
|
127
|
+
"""Check if a phone number matches a search pattern.
|
|
128
|
+
|
|
129
|
+
Supports wildcards for flexible searching:
|
|
130
|
+
- `*` matches any sequence of characters
|
|
131
|
+
- Exact digit sequences match those digits anywhere in the number
|
|
132
|
+
- Patterns are matched against the normalized (digits-only) number
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
number: Phone number to check (any format accepted).
|
|
136
|
+
pattern: Search pattern, optionally with wildcards.
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
True if the number matches the pattern, False otherwise.
|
|
140
|
+
|
|
141
|
+
Raises:
|
|
142
|
+
ValidationError: If the number is invalid.
|
|
143
|
+
|
|
144
|
+
Examples:
|
|
145
|
+
>>> match_pattern("+12065551234", "206*")
|
|
146
|
+
True
|
|
147
|
+
>>> match_pattern("+12065551234", "*555*")
|
|
148
|
+
True
|
|
149
|
+
>>> match_pattern("+12065551234", "1234")
|
|
150
|
+
True
|
|
151
|
+
>>> match_pattern("+12065551234", "999")
|
|
152
|
+
False
|
|
153
|
+
"""
|
|
154
|
+
digits = normalize_phone_number(number)
|
|
155
|
+
|
|
156
|
+
# Normalize pattern: strip non-digit except *
|
|
157
|
+
normalized_pattern = re.sub(r"[^\d*]", "", pattern)
|
|
158
|
+
|
|
159
|
+
if not normalized_pattern:
|
|
160
|
+
return False
|
|
161
|
+
|
|
162
|
+
# If pattern has no wildcards, treat as substring search
|
|
163
|
+
if "*" not in normalized_pattern:
|
|
164
|
+
return normalized_pattern in digits
|
|
165
|
+
|
|
166
|
+
# Convert wildcards to regex pattern
|
|
167
|
+
# Escape any regex special chars first, then replace \* with .*
|
|
168
|
+
regex_pattern = re.escape(normalized_pattern).replace(r"\*", ".*")
|
|
169
|
+
|
|
170
|
+
# Use search to find pattern anywhere in the number
|
|
171
|
+
# This allows "206*" to match area codes even with country code prefix
|
|
172
|
+
return bool(re.search(regex_pattern, digits))
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: teams-phone-cli
|
|
3
|
+
Version: 0.1.2
|
|
4
|
+
Summary: CLI tool for managing Microsoft Teams Phone configurations
|
|
5
|
+
License-File: LICENSE
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Requires-Dist: cryptography>=41.0.0
|
|
8
|
+
Requires-Dist: httpx>=0.25.0
|
|
9
|
+
Requires-Dist: msal>=1.24.0
|
|
10
|
+
Requires-Dist: pydantic>=2.0.0
|
|
11
|
+
Requires-Dist: rich>=13.0.0
|
|
12
|
+
Requires-Dist: tenacity>=8.0.0
|
|
13
|
+
Requires-Dist: tomli>=2.0.0; python_version < '3.11'
|
|
14
|
+
Requires-Dist: tomlkit>=0.12.0
|
|
15
|
+
Requires-Dist: typer>=0.9.0
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
teams_phone/__init__.py,sha256=HDpi3pFZt7yIDIXwT7evnoqE_AIThaf33CAcIIesj2I,92
|
|
2
|
+
teams_phone/__main__.py,sha256=OlqRTyKnFobyvqc0RQ1pcG-SQWUoIku27Mt4yge3tKc,133
|
|
3
|
+
teams_phone/constants.py,sha256=_Kly2IalHvmRRdNyTMfl8STdu1MGhD3cr66_hvzidB4,3249
|
|
4
|
+
teams_phone/exceptions.py,sha256=1KRvVmy2shBrFKvaQ1eOhYZLtgisTrnpO25gBxR-Jko,3883
|
|
5
|
+
teams_phone/utils.py,sha256=nZdxHHQYUYtPAgj_GaoUtfnddyQLqrK6fTSk5j1fHSY,5022
|
|
6
|
+
teams_phone/cli/__init__.py,sha256=KKYQG1nFJB7lwVqb4xu0_jN7O_PyCmbJow_TYErlfo8,281
|
|
7
|
+
teams_phone/cli/api_check.py,sha256=U6oMSJgT618aRisypSdwXFm-O1pKKOVrimXL-nj-QWU,8679
|
|
8
|
+
teams_phone/cli/auth.py,sha256=iBJBCJ3ycsnFoAom5VQACANWZroV5fBHDJBmwYPjFo0,6695
|
|
9
|
+
teams_phone/cli/context.py,sha256=1Jn8mcZ4HJhMtuqWE1zM6kNeefgjCzKr3rqlESwBPeA,3478
|
|
10
|
+
teams_phone/cli/helpers.py,sha256=VkhzUrZaleI32NX8uZYeAneOmMumz5jCurLO_OR2jng,2107
|
|
11
|
+
teams_phone/cli/locations.py,sha256=gk8iZLaGhMvIRlxEnG0Fe9lLisOJ2xF__h1De_RPwes,9843
|
|
12
|
+
teams_phone/cli/main.py,sha256=mHtwei2jI6ESsL49IO2d_I9iiMqhjcfpzJ94Z5IMUqU,3218
|
|
13
|
+
teams_phone/cli/numbers.py,sha256=PX2OfKwaB8dSpbPtvnVZXxvvZIWwHZFL2b97MrqX0sM,62894
|
|
14
|
+
teams_phone/cli/policies.py,sha256=dJ61-hYC3AxafD_AtCzAU_IIxGf1CN0H7vI6vUuxRgA,32973
|
|
15
|
+
teams_phone/cli/tenants.py,sha256=Ivtyr0gZarkHsZ2JbZ2Kuck-yu0DvLtoEF2d2qaUe8I,12495
|
|
16
|
+
teams_phone/cli/users.py,sha256=Ch2K-L84aqx0PS_myM1OJLIyXvLq3N2kiG8L6gFC5hI,13572
|
|
17
|
+
teams_phone/infrastructure/__init__.py,sha256=GzlfRBqyswREd1W13FudNUpl1OzJiXNnM3su3F90fMQ,629
|
|
18
|
+
teams_phone/infrastructure/cache_manager.py,sha256=4LuGV_Tfagh48it69yjgZaFrysi0_a_VHgT2j7UnEh0,9286
|
|
19
|
+
teams_phone/infrastructure/config_manager.py,sha256=qAxM0dhcGGz3tWs2qDSfaJgOH_8R3LnmmrTjspgtcIs,7342
|
|
20
|
+
teams_phone/infrastructure/debug_logger.py,sha256=fDEspLv0SooXh8CR5S1I1aisxYsIOiaU1511pQoJuKs,9379
|
|
21
|
+
teams_phone/infrastructure/graph_client.py,sha256=k8-MiFqYCcfr529DoAYh4rdhiWV-t4xrsez-IE1Ztyw,23620
|
|
22
|
+
teams_phone/infrastructure/output_formatter.py,sha256=WTOfPq7jK_HnfNV7hYhvs9gZl8XdxDsa8l1AF723BCM,7188
|
|
23
|
+
teams_phone/models/__init__.py,sha256=CxtDTZ5A8Xh0s4FpD0WIxydISZ38e7-JzrEj-nLniKM,1793
|
|
24
|
+
teams_phone/models/api_responses.py,sha256=qA4m6X6AHZpt8_C-njQZfaIsv7lSwP8yHZ1mi7K_wiU,2362
|
|
25
|
+
teams_phone/models/auth.py,sha256=YohV8uKHycBo0rUHLyXw2PrpR20YrRZuD7Esyzn6A9w,3119
|
|
26
|
+
teams_phone/models/cache.py,sha256=faTOhi_-0hu-Q5YdS7oK6tE9mxKIgstZ6zCJrvdCO-4,756
|
|
27
|
+
teams_phone/models/config.py,sha256=ujJ5thvg6vVcwVF9poFMfP81EW1aKN1UaLMP9i_b0Hc,1956
|
|
28
|
+
teams_phone/models/location.py,sha256=NSWrXIvp9HnaXAMasE_cAWbgSdn_eBPl3xUEvEF59nA,1565
|
|
29
|
+
teams_phone/models/number.py,sha256=GroicsBpHaCMSYPuPlPPFM0iEFHqDPHyCZSD6VZrQ-o,6822
|
|
30
|
+
teams_phone/models/policy.py,sha256=FmN7ZrTz2BfFOqmorfvHVBIoxnCwo6f7rpKjCY22UAU,962
|
|
31
|
+
teams_phone/models/tenant.py,sha256=FEQZjT7udIocddaqvFFGLdY_-4DF5oPOt7k4UJQyU1w,1473
|
|
32
|
+
teams_phone/models/user.py,sha256=1hPbk7P9X8WxyzawGMQZrpPNPN8s7hxUx_ZHQfgTKko,4411
|
|
33
|
+
teams_phone/services/__init__.py,sha256=RvY68ZVDFFxLwu1iNbGXaIB85JwvJOQPdXDGFkYnjfM,722
|
|
34
|
+
teams_phone/services/auth_service.py,sha256=v5TQQzdDXlu1pWaPeITwHEOYS878B67At3FyIUoePus,20658
|
|
35
|
+
teams_phone/services/bulk_operations.py,sha256=oz7gpOc-SCOaK1EoS6W7QUbGlNR6V6nEH0SKAjlhDrE,19850
|
|
36
|
+
teams_phone/services/location_service.py,sha256=j_IqSHZjSOx-SeQGzfRYAQwpjtdkyTJjMABXoMpWUjA,6185
|
|
37
|
+
teams_phone/services/number_service.py,sha256=xBar84f-sy6CXOaRzJ9jXQzNP4MRgVWF52AGlaTTbEg,18837
|
|
38
|
+
teams_phone/services/policy_service.py,sha256=lKI9XvKePrEFG8GWwmaFt4HuXfzVS1hf7ltq4o77bJY,12358
|
|
39
|
+
teams_phone/services/tenant_service.py,sha256=wfzYnyOfZb_1yuzQ3vy_tX-z2OYIjpa6zqPFhsv5miI,7447
|
|
40
|
+
teams_phone/services/user_service.py,sha256=-FtJTfbz3oE-nD4o0kJv8jBQqIAADQWft_nn2dDiNnE,16645
|
|
41
|
+
teams_phone_cli-0.1.2.dist-info/METADATA,sha256=kRUfVltMAtAqT-ioI4OldD-dJZS-g5EC8Et8DNBy0Ys,468
|
|
42
|
+
teams_phone_cli-0.1.2.dist-info/WHEEL,sha256=WLgqFyCfm_KASv4WHyYy0P3pM_m7J5L9k2skdKLirC8,87
|
|
43
|
+
teams_phone_cli-0.1.2.dist-info/entry_points.txt,sha256=53waHsIjr6V14VxvxuC05zJJnIniudNOuneWGSPVDqs,57
|
|
44
|
+
teams_phone_cli-0.1.2.dist-info/licenses/LICENSE,sha256=FnxAMYZhIzUrihYFEuAr3nGudfb13-HT-3PUmwCrLi0,1069
|
|
45
|
+
teams_phone_cli-0.1.2.dist-info/RECORD,,
|