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.
Files changed (45) hide show
  1. teams_phone/__init__.py +3 -0
  2. teams_phone/__main__.py +7 -0
  3. teams_phone/cli/__init__.py +8 -0
  4. teams_phone/cli/api_check.py +267 -0
  5. teams_phone/cli/auth.py +201 -0
  6. teams_phone/cli/context.py +108 -0
  7. teams_phone/cli/helpers.py +65 -0
  8. teams_phone/cli/locations.py +308 -0
  9. teams_phone/cli/main.py +99 -0
  10. teams_phone/cli/numbers.py +1644 -0
  11. teams_phone/cli/policies.py +893 -0
  12. teams_phone/cli/tenants.py +364 -0
  13. teams_phone/cli/users.py +394 -0
  14. teams_phone/constants.py +97 -0
  15. teams_phone/exceptions.py +137 -0
  16. teams_phone/infrastructure/__init__.py +22 -0
  17. teams_phone/infrastructure/cache_manager.py +274 -0
  18. teams_phone/infrastructure/config_manager.py +209 -0
  19. teams_phone/infrastructure/debug_logger.py +321 -0
  20. teams_phone/infrastructure/graph_client.py +666 -0
  21. teams_phone/infrastructure/output_formatter.py +234 -0
  22. teams_phone/models/__init__.py +76 -0
  23. teams_phone/models/api_responses.py +69 -0
  24. teams_phone/models/auth.py +100 -0
  25. teams_phone/models/cache.py +25 -0
  26. teams_phone/models/config.py +66 -0
  27. teams_phone/models/location.py +36 -0
  28. teams_phone/models/number.py +184 -0
  29. teams_phone/models/policy.py +26 -0
  30. teams_phone/models/tenant.py +45 -0
  31. teams_phone/models/user.py +117 -0
  32. teams_phone/services/__init__.py +21 -0
  33. teams_phone/services/auth_service.py +536 -0
  34. teams_phone/services/bulk_operations.py +562 -0
  35. teams_phone/services/location_service.py +195 -0
  36. teams_phone/services/number_service.py +489 -0
  37. teams_phone/services/policy_service.py +330 -0
  38. teams_phone/services/tenant_service.py +205 -0
  39. teams_phone/services/user_service.py +435 -0
  40. teams_phone/utils.py +172 -0
  41. teams_phone_cli-0.1.2.dist-info/METADATA +15 -0
  42. teams_phone_cli-0.1.2.dist-info/RECORD +45 -0
  43. teams_phone_cli-0.1.2.dist-info/WHEEL +4 -0
  44. teams_phone_cli-0.1.2.dist-info/entry_points.txt +2 -0
  45. teams_phone_cli-0.1.2.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,195 @@
1
+ """Location service for emergency location operations.
2
+
3
+ This module provides the LocationService class for listing, searching, and
4
+ validating emergency locations from the CSV cache.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from datetime import timedelta
10
+ from typing import TYPE_CHECKING, Any
11
+
12
+ from teams_phone.constants import CSV_STALE_DAYS
13
+ from teams_phone.exceptions import NotFoundError
14
+ from teams_phone.models import EmergencyLocation
15
+
16
+
17
+ if TYPE_CHECKING:
18
+ from teams_phone.infrastructure import CacheManager
19
+
20
+
21
+ class LocationService:
22
+ """Service for emergency location operations.
23
+
24
+ Provides methods for listing, searching, and validating emergency
25
+ locations from the CSV cache.
26
+
27
+ Attributes:
28
+ cache_manager: CacheManager instance for CSV access.
29
+ """
30
+
31
+ def __init__(self, cache_manager: CacheManager) -> None:
32
+ """Initialize the LocationService.
33
+
34
+ Args:
35
+ cache_manager: CacheManager instance for CSV reading.
36
+ """
37
+ self.cache_manager = cache_manager
38
+
39
+ def list_locations(self) -> list[EmergencyLocation]:
40
+ """List all emergency locations from the cache.
41
+
42
+ Returns:
43
+ List of EmergencyLocation objects. Empty list if CSV doesn't
44
+ exist or is malformed.
45
+ """
46
+ raw_data = self.cache_manager.read_locations_csv()
47
+ locations: list[EmergencyLocation] = []
48
+
49
+ for row in raw_data:
50
+ # Create a new dict with boolean conversion for isDefault field
51
+ # CacheManager returns dict[str, str], but Pydantic needs bool for isDefault
52
+ validated_row: dict[str, Any] = dict(row)
53
+ validated_row["isDefault"] = row.get("isDefault", "").lower() == "true"
54
+ locations.append(EmergencyLocation.model_validate(validated_row))
55
+
56
+ return locations
57
+
58
+ def get_location(self, location_id: str) -> EmergencyLocation | None:
59
+ """Get a single location by ID.
60
+
61
+ Args:
62
+ location_id: The location's unique identifier (GUID).
63
+
64
+ Returns:
65
+ EmergencyLocation if found, None otherwise.
66
+ """
67
+ locations = self.list_locations()
68
+
69
+ for location in locations:
70
+ if location.location_id == location_id:
71
+ return location
72
+
73
+ return None
74
+
75
+ def search_locations(self, query: str) -> list[EmergencyLocation]:
76
+ """Search locations by description, city, or address.
77
+
78
+ Performs case-insensitive search across description, city, and
79
+ address fields.
80
+
81
+ Args:
82
+ query: Search query string.
83
+
84
+ Returns:
85
+ List of EmergencyLocation objects matching the query.
86
+ """
87
+ locations = self.list_locations()
88
+ query_lower = query.lower()
89
+ results: list[EmergencyLocation] = []
90
+
91
+ for location in locations:
92
+ # Search in description, city, and address fields
93
+ if (
94
+ query_lower in (location.description or "").lower()
95
+ or query_lower in (location.city or "").lower()
96
+ or query_lower in (location.address or "").lower()
97
+ ):
98
+ results.append(location)
99
+
100
+ return results
101
+
102
+ def validate_location(self, identifier: str) -> EmergencyLocation:
103
+ """Validate and retrieve a location by ID or description.
104
+
105
+ Attempts to find a location matching the identifier. Searches by
106
+ exact location ID first, then by exact description match.
107
+
108
+ Args:
109
+ identifier: Location ID (GUID) or description.
110
+
111
+ Returns:
112
+ EmergencyLocation for the matched location.
113
+
114
+ Raises:
115
+ NotFoundError: If no location matches the identifier.
116
+ """
117
+ location = self._find_location(identifier)
118
+
119
+ if location is None:
120
+ raise NotFoundError(
121
+ f"Location '{identifier}' not found",
122
+ remediation=(
123
+ "Verify the location ID or description is correct.\n"
124
+ "Use 'teams-phone locations list' to see available locations."
125
+ ),
126
+ )
127
+
128
+ return location
129
+
130
+ def _find_location(self, identifier: str) -> EmergencyLocation | None:
131
+ """Find a location by ID or description.
132
+
133
+ Args:
134
+ identifier: Location ID (GUID) or description.
135
+
136
+ Returns:
137
+ EmergencyLocation if found, None otherwise.
138
+ """
139
+ locations = self.list_locations()
140
+
141
+ # First, try exact ID match
142
+ for location in locations:
143
+ if location.location_id == identifier:
144
+ return location
145
+
146
+ # Then, try exact description match (case-insensitive)
147
+ identifier_lower = identifier.lower()
148
+ for location in locations:
149
+ if (location.description or "").lower() == identifier_lower:
150
+ return location
151
+
152
+ return None
153
+
154
+ def get_cache_age(self) -> timedelta | None:
155
+ """Get the age of the location cache.
156
+
157
+ Returns:
158
+ timedelta representing the cache age, or None if no cache exists.
159
+ """
160
+ age_days = self.cache_manager.get_cache_age_days()
161
+
162
+ if age_days is None:
163
+ return None
164
+
165
+ return timedelta(days=age_days)
166
+
167
+ def is_cache_stale(self) -> bool:
168
+ """Check if the location cache is stale.
169
+
170
+ Returns:
171
+ True if cache doesn't exist or is older than CSV_STALE_DAYS (30 days).
172
+ """
173
+ return self.cache_manager.is_cache_stale()
174
+
175
+ def get_staleness_warning(self) -> str | None:
176
+ """Get a warning message if the cache is stale.
177
+
178
+ Returns:
179
+ Warning message string if cache is stale, None otherwise.
180
+ """
181
+ if not self.is_cache_stale():
182
+ return None
183
+
184
+ age_days = self.cache_manager.get_cache_age_days()
185
+
186
+ if age_days is None:
187
+ return (
188
+ "Location cache not found. "
189
+ "Run the PowerShell export script to create the cache."
190
+ )
191
+
192
+ return (
193
+ f"Location cache is {age_days} days old (threshold: {CSV_STALE_DAYS} days). "
194
+ "Consider refreshing by running the PowerShell export script."
195
+ )
@@ -0,0 +1,489 @@
1
+ """Phone number service for Teams Phone number inventory operations.
2
+
3
+ This module provides the NumberService class for listing, searching, and
4
+ retrieving phone number assignments from Microsoft Graph API.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import random
11
+ import re
12
+ import time
13
+ from collections.abc import Callable
14
+ from datetime import datetime, timezone
15
+ from typing import TYPE_CHECKING, Any
16
+
17
+ from teams_phone.exceptions import NotFoundError, ValidationError
18
+ from teams_phone.models import (
19
+ AssignmentStatus,
20
+ AsyncOperation,
21
+ NumberAssignment,
22
+ NumberType,
23
+ OperationStatus,
24
+ )
25
+ from teams_phone.utils import format_for_assign, format_for_update, match_pattern
26
+
27
+
28
+ if TYPE_CHECKING:
29
+ from teams_phone.infrastructure.graph_client import GraphClient
30
+
31
+
32
+ class NumberService:
33
+ """Service for phone number inventory operations.
34
+
35
+ Provides methods for listing, searching, and retrieving phone number
36
+ assignments from the Microsoft Graph API.
37
+
38
+ Attributes:
39
+ graph_client: GraphClient instance for API communication.
40
+ """
41
+
42
+ def __init__(self, graph_client: GraphClient) -> None:
43
+ """Initialize the NumberService.
44
+
45
+ Args:
46
+ graph_client: GraphClient instance for API communication.
47
+ """
48
+ self.graph_client = graph_client
49
+
50
+ async def list_numbers(
51
+ self,
52
+ *,
53
+ status: AssignmentStatus | None = None,
54
+ number_type: NumberType | None = None,
55
+ city: str | None = None,
56
+ page_size: int = 100,
57
+ max_items: int | None = None,
58
+ ) -> list[NumberAssignment]:
59
+ """List phone numbers with optional filtering and pagination.
60
+
61
+ Retrieves phone number assignments from Microsoft Graph API
62
+ with support for filtering by assignment status, number type,
63
+ and city.
64
+
65
+ Note:
66
+ The city filter is applied client-side because the Graph API
67
+ does not support OData filtering by city. This means all numbers
68
+ matching other filters are fetched before city filtering is applied,
69
+ which may impact performance for large inventories.
70
+
71
+ Args:
72
+ status: Filter by assignment status (unassigned, userAssigned, etc.).
73
+ If None, return all numbers regardless of assignment status.
74
+ number_type: Filter by number type (directRouting, callingPlan, etc.).
75
+ If None, return all number types.
76
+ city: Filter by city name (case-sensitive, client-side filter).
77
+ If None, return all cities.
78
+ page_size: Number of items per page (default 100, max 999).
79
+ max_items: Maximum total items to return. If None, return all items.
80
+ Note: When using city filter, max_items applies before city
81
+ filtering, so fewer results may be returned.
82
+
83
+ Returns:
84
+ List of NumberAssignment objects matching the filter criteria.
85
+
86
+ Raises:
87
+ ContractError: If API response is missing expected fields.
88
+ AuthenticationError: If authentication fails.
89
+ RateLimitError: If rate limit exceeded after retries.
90
+ """
91
+ numbers: list[NumberAssignment] = []
92
+ params: dict[str, Any] = {"$top": str(min(page_size, 999))}
93
+
94
+ # Build OData $filter string based on parameters
95
+ # Note: city cannot be filtered via OData per API limitations
96
+ filters: list[str] = []
97
+ if status is not None:
98
+ filters.append(f"assignmentStatus eq '{status.value}'")
99
+ if number_type is not None:
100
+ filters.append(f"numberType eq '{number_type.value}'")
101
+
102
+ if filters:
103
+ params["$filter"] = " and ".join(filters)
104
+
105
+ async for item in self.graph_client.get_paginated(
106
+ "/admin/teams/telephoneNumberManagement/numberAssignments",
107
+ params=params,
108
+ max_items=max_items,
109
+ ):
110
+ number = NumberAssignment.model_validate(item)
111
+ # Client-side city filter (OData doesn't support city filter)
112
+ if city is not None and number.city != city:
113
+ continue
114
+ numbers.append(number)
115
+
116
+ return numbers
117
+
118
+ async def get_number(self, phone_number: str) -> NumberAssignment:
119
+ """Get a single phone number assignment by phone number.
120
+
121
+ Accepts phone numbers in any format and normalizes to E.164 format
122
+ before querying the Graph API.
123
+
124
+ Args:
125
+ phone_number: Phone number in any format (E.164, digits-only, formatted).
126
+ Examples: "+12065551234", "12065551234", "+1-206-555-1234".
127
+
128
+ Returns:
129
+ NumberAssignment for the specified phone number.
130
+
131
+ Raises:
132
+ NotFoundError: If the phone number is not found in inventory.
133
+ ValidationError: If the phone number format is invalid.
134
+ ContractError: If API response doesn't match expected schema.
135
+ """
136
+ normalized = format_for_update(phone_number)
137
+ params = {"$filter": f"telephoneNumber eq '{normalized}'"}
138
+
139
+ async for item in self.graph_client.get_paginated(
140
+ "/admin/teams/telephoneNumberManagement/numberAssignments",
141
+ params=params,
142
+ max_items=1,
143
+ ):
144
+ return NumberAssignment.model_validate(item)
145
+
146
+ raise NotFoundError(
147
+ f"Phone number '{normalized}' not found",
148
+ remediation=(
149
+ "Verify the phone number is in your inventory. "
150
+ "Use `teams-phone numbers list` to see available numbers."
151
+ ),
152
+ )
153
+
154
+ async def search_numbers(
155
+ self,
156
+ pattern: str,
157
+ *,
158
+ max_results: int = 100,
159
+ ) -> list[NumberAssignment]:
160
+ """Search phone numbers by wildcard pattern.
161
+
162
+ Fetches all numbers and applies client-side pattern matching.
163
+ Supports wildcards: '206*' (prefix), '*1234' (suffix), '*555*' (contains).
164
+
165
+ Note:
166
+ The Graph API does not support wildcard filtering, so all numbers
167
+ are fetched first and then filtered client-side. For large inventories
168
+ this may impact performance.
169
+
170
+ Args:
171
+ pattern: Search pattern with optional wildcards (*).
172
+ - '206*' matches numbers containing 206 anywhere
173
+ - '*1234' matches numbers ending with 1234
174
+ - '*555*' matches numbers containing 555 anywhere
175
+ - '1234' matches numbers containing that substring
176
+ max_results: Maximum number of results to return (default 100).
177
+
178
+ Returns:
179
+ List of NumberAssignment objects matching the pattern.
180
+
181
+ Raises:
182
+ ContractError: If API response doesn't match expected schema.
183
+ AuthenticationError: If authentication fails.
184
+ RateLimitError: If rate limit exceeded after retries.
185
+ """
186
+ results: list[NumberAssignment] = []
187
+
188
+ # Fetch all numbers - no server-side pattern filtering available
189
+ all_numbers = await self.list_numbers()
190
+
191
+ # Apply client-side pattern matching
192
+ for number in all_numbers:
193
+ if match_pattern(number.telephone_number, pattern):
194
+ results.append(number)
195
+ if len(results) >= max_results:
196
+ break
197
+
198
+ return results
199
+
200
+ async def poll_operation(
201
+ self,
202
+ operation_id: str,
203
+ *,
204
+ timeout: int = 60,
205
+ on_progress: Callable[[AsyncOperation], None] | None = None,
206
+ ) -> AsyncOperation:
207
+ """Poll an async operation until completion or timeout.
208
+
209
+ Polls the Graph API operation status endpoint at 2-3 second intervals
210
+ with jitter until the operation reaches a terminal state (succeeded,
211
+ failed, or skipped) or the timeout is exceeded.
212
+
213
+ Args:
214
+ operation_id: The operation ID from the Location header of a 202 response.
215
+ timeout: Maximum time in seconds to wait for completion (default 60s).
216
+ on_progress: Optional callback invoked on each poll iteration with
217
+ the current AsyncOperation state.
218
+
219
+ Returns:
220
+ AsyncOperation model representing the final state of the operation.
221
+
222
+ Raises:
223
+ TimeoutError: If the operation does not complete within the timeout.
224
+ ContractError: If the API response doesn't match expected schema.
225
+ AuthenticationError: If authentication fails.
226
+ RateLimitError: If rate limit exceeded after retries.
227
+ """
228
+ terminal_states = {
229
+ OperationStatus.SUCCEEDED,
230
+ OperationStatus.FAILED,
231
+ OperationStatus.SKIPPED,
232
+ }
233
+ endpoint = f"/admin/teams/telephoneNumberManagement/operations/{operation_id}"
234
+
235
+ start = time.time()
236
+ while time.time() - start < timeout:
237
+ result = await self.graph_client.get(endpoint)
238
+ operation = AsyncOperation.model_validate(result)
239
+
240
+ if on_progress is not None:
241
+ on_progress(operation)
242
+
243
+ if operation.status in terminal_states:
244
+ return operation
245
+
246
+ # 2-3 second interval with jitter
247
+ await asyncio.sleep(2 + random.uniform(0, 1))
248
+
249
+ raise TimeoutError(
250
+ f"Operation {operation_id} did not complete within {timeout}s"
251
+ )
252
+
253
+ def _extract_operation_id(self, location_header: str) -> str:
254
+ """Extract operation ID from Location header.
255
+
256
+ Parses the operation ID from the Location header returned by
257
+ 202 Accepted responses for assign/unassign operations.
258
+
259
+ Args:
260
+ location_header: The Location header value containing the
261
+ operations URL. Supports both formats:
262
+ - .../operations('{operation_id}') - OData function style
263
+ - .../operations/{operation_id} - REST path style
264
+
265
+ Returns:
266
+ The extracted operation ID string.
267
+
268
+ Raises:
269
+ ValidationError: If the header format doesn't match the
270
+ expected pattern.
271
+ """
272
+ # Try OData function style first: operations('{id}')
273
+ pattern = r"operations\('([^']+)'\)"
274
+ match = re.search(pattern, location_header)
275
+
276
+ # Fall back to REST path style: operations/{id}
277
+ if not match:
278
+ pattern = r"operations/([^/\s]+)$"
279
+ match = re.search(pattern, location_header)
280
+
281
+ if not match:
282
+ raise ValidationError(
283
+ f"Invalid Location header format: '{location_header}'",
284
+ remediation=(
285
+ "Expected format: .../operations('{operation_id}') or "
286
+ ".../operations/{operation_id}. "
287
+ "This may indicate an API response format change."
288
+ ),
289
+ )
290
+
291
+ return match.group(1)
292
+
293
+ async def assign_number(
294
+ self,
295
+ user_id: str,
296
+ phone_number: str,
297
+ number_type: NumberType,
298
+ *,
299
+ location_id: str | None = None,
300
+ wait: bool = True,
301
+ timeout: int = 60,
302
+ on_progress: Callable[[AsyncOperation], None] | None = None,
303
+ ) -> AsyncOperation:
304
+ """Assign a phone number to a user.
305
+
306
+ Args:
307
+ user_id: User's Entra object ID (GUID).
308
+ phone_number: Phone number in any format.
309
+ number_type: Type of phone number (DirectRouting, CallingPlan, OperatorConnect).
310
+ location_id: Emergency location ID. Required for Calling Plan numbers.
311
+ wait: If True, poll for operation completion. If False, return immediately.
312
+ timeout: Maximum seconds to wait for completion (default 60).
313
+ on_progress: Optional callback invoked on each poll iteration with
314
+ the current AsyncOperation state.
315
+
316
+ Returns:
317
+ AsyncOperation representing the assignment result.
318
+
319
+ Raises:
320
+ ValidationError: If Calling Plan number is missing required location_id.
321
+ TimeoutError: If wait=True and operation doesn't complete within timeout.
322
+ """
323
+ # E911 validation: Calling Plan numbers require emergency location
324
+ if number_type == NumberType.CALLING_PLAN and not location_id:
325
+ raise ValidationError(
326
+ f"Emergency location required for Calling Plan number {phone_number}",
327
+ remediation="Use --location flag or set default_location in tenant config",
328
+ )
329
+
330
+ # Build request body
331
+ body: dict[str, Any] = {
332
+ "telephoneNumber": format_for_assign(phone_number),
333
+ "numberType": number_type.value,
334
+ "assignmentTargetId": user_id,
335
+ }
336
+ if location_id:
337
+ body["locationId"] = location_id
338
+
339
+ # Make POST request to assignNumber endpoint
340
+ response = await self.graph_client.post_with_response(
341
+ "/admin/teams/telephoneNumberManagement/numberAssignments/assignNumber",
342
+ body,
343
+ )
344
+
345
+ # Extract operation ID from Location header
346
+ location_header = response.headers.get("location", "")
347
+ operation_id = self._extract_operation_id(location_header)
348
+
349
+ if wait:
350
+ # Poll for completion
351
+ return await self.poll_operation(
352
+ operation_id, timeout=timeout, on_progress=on_progress
353
+ )
354
+
355
+ # Return immediately with notStarted status
356
+ return AsyncOperation.model_validate(
357
+ {
358
+ "id": operation_id,
359
+ "createdDateTime": datetime.now(timezone.utc).isoformat(),
360
+ "status": OperationStatus.NOT_STARTED.value,
361
+ "numbers": [],
362
+ }
363
+ )
364
+
365
+ async def unassign_number(
366
+ self,
367
+ phone_number: str,
368
+ number_type: NumberType,
369
+ *,
370
+ wait: bool = True,
371
+ timeout: int = 60,
372
+ on_progress: Callable[[AsyncOperation], None] | None = None,
373
+ ) -> AsyncOperation:
374
+ """Unassign a phone number from its current user.
375
+
376
+ Args:
377
+ phone_number: Phone number in any format.
378
+ number_type: Type of phone number (DirectRouting, CallingPlan, OperatorConnect).
379
+ wait: If True, poll for operation completion. If False, return immediately.
380
+ timeout: Maximum seconds to wait for completion (default 60).
381
+ on_progress: Optional callback invoked on each poll iteration with
382
+ the current AsyncOperation state.
383
+
384
+ Returns:
385
+ AsyncOperation representing the unassignment result.
386
+
387
+ Raises:
388
+ TimeoutError: If wait=True and operation doesn't complete within timeout.
389
+ """
390
+ # Build request body (simpler than assign - no user or location required)
391
+ body: dict[str, Any] = {
392
+ "telephoneNumber": format_for_assign(phone_number),
393
+ "numberType": number_type.value,
394
+ }
395
+
396
+ # Make POST request to unassignNumber endpoint
397
+ response = await self.graph_client.post_with_response(
398
+ "/admin/teams/telephoneNumberManagement/numberAssignments/unassignNumber",
399
+ body,
400
+ )
401
+
402
+ # Extract operation ID from Location header
403
+ location_header = response.headers.get("location", "")
404
+ operation_id = self._extract_operation_id(location_header)
405
+
406
+ if wait:
407
+ # Poll for completion
408
+ return await self.poll_operation(
409
+ operation_id, timeout=timeout, on_progress=on_progress
410
+ )
411
+
412
+ # Return immediately with notStarted status
413
+ return AsyncOperation.model_validate(
414
+ {
415
+ "id": operation_id,
416
+ "createdDateTime": datetime.now(timezone.utc).isoformat(),
417
+ "status": OperationStatus.NOT_STARTED.value,
418
+ "numbers": [],
419
+ }
420
+ )
421
+
422
+ async def update_number(
423
+ self,
424
+ phone_number: str,
425
+ *,
426
+ location_id: str | None = None,
427
+ clear_location: bool = False,
428
+ network_site_id: str | None = None,
429
+ clear_network_site: bool = False,
430
+ ) -> None:
431
+ """Update phone number location or network site settings.
432
+
433
+ This is a synchronous operation (returns 200 OK immediately, not 202 Accepted).
434
+ Use this to change emergency location or network site on an already-assigned number.
435
+
436
+ Args:
437
+ phone_number: Phone number in any format.
438
+ location_id: Emergency location ID to set. Mutually exclusive with clear_location.
439
+ clear_location: Set to True to clear the emergency location.
440
+ network_site_id: Network site ID to set. Mutually exclusive with clear_network_site.
441
+ clear_network_site: Set to True to clear the network site.
442
+
443
+ Raises:
444
+ ValidationError: If both location_id and clear_location are specified,
445
+ or both network_site_id and clear_network_site are specified,
446
+ or no update parameters are provided.
447
+ """
448
+ # Validate mutually exclusive options
449
+ if location_id and clear_location:
450
+ raise ValidationError(
451
+ "Cannot specify both --location and --clear-location",
452
+ remediation="Use either --location <id> to set a location or --clear-location to remove it.",
453
+ )
454
+ if network_site_id and clear_network_site:
455
+ raise ValidationError(
456
+ "Cannot specify both --network-site and --clear-network-site",
457
+ remediation="Use either --network-site <id> to set a site or --clear-network-site to remove it.",
458
+ )
459
+
460
+ # Build request body with only the fields being updated
461
+ body: dict[str, Any] = {
462
+ "telephoneNumber": format_for_update(phone_number),
463
+ }
464
+
465
+ # Handle location updates
466
+ if location_id:
467
+ body["locationId"] = location_id
468
+ elif clear_location:
469
+ body["locationId"] = "" # Empty string clears the field
470
+
471
+ # Handle network site updates
472
+ if network_site_id:
473
+ body["networkSiteId"] = network_site_id
474
+ elif clear_network_site:
475
+ body["networkSiteId"] = "" # Empty string clears the field
476
+
477
+ # Validate at least one update is requested
478
+ if len(body) == 1: # Only telephoneNumber is present
479
+ raise ValidationError(
480
+ "No update parameters specified",
481
+ remediation="Use --location, --clear-location, --network-site, or --clear-network-site to specify what to update.",
482
+ )
483
+
484
+ # Make POST request to updateNumber endpoint
485
+ # This is a synchronous operation - returns 200 OK (not 202 Accepted)
486
+ await self.graph_client.post(
487
+ "/admin/teams/telephoneNumberManagement/numberAssignments/updateNumber",
488
+ body,
489
+ )