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,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
|
+
)
|