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,1644 @@
|
|
|
1
|
+
"""Phone number management commands for Teams Phone CLI.
|
|
2
|
+
|
|
3
|
+
This module provides CLI commands for listing, showing, and searching
|
|
4
|
+
phone numbers in the tenant inventory.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
from collections.abc import Callable, Coroutine
|
|
11
|
+
from datetime import datetime
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import TYPE_CHECKING, Any
|
|
14
|
+
|
|
15
|
+
import typer
|
|
16
|
+
|
|
17
|
+
from teams_phone.cli.context import get_context
|
|
18
|
+
from teams_phone.cli.helpers import run_with_service
|
|
19
|
+
from teams_phone.exceptions import ConfigurationError, TeamsPhoneError, ValidationError
|
|
20
|
+
from teams_phone.infrastructure import CacheManager, ConfigManager, GraphClient
|
|
21
|
+
from teams_phone.models import (
|
|
22
|
+
ActivationState,
|
|
23
|
+
AssignmentStatus,
|
|
24
|
+
AsyncOperation,
|
|
25
|
+
NumberAssignment,
|
|
26
|
+
NumberType,
|
|
27
|
+
OperationStatus,
|
|
28
|
+
)
|
|
29
|
+
from teams_phone.services import AuthService
|
|
30
|
+
from teams_phone.services.bulk_operations import (
|
|
31
|
+
BulkAssignResult,
|
|
32
|
+
BulkOperations,
|
|
33
|
+
BulkValidationResult,
|
|
34
|
+
parse_assign_csv,
|
|
35
|
+
write_results_csv,
|
|
36
|
+
)
|
|
37
|
+
from teams_phone.services.location_service import LocationService
|
|
38
|
+
from teams_phone.services.number_service import NumberService
|
|
39
|
+
from teams_phone.services.user_service import UserService
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
if TYPE_CHECKING:
|
|
43
|
+
from teams_phone.infrastructure import OutputFormatter
|
|
44
|
+
from teams_phone.models import EmergencyLocation, UserConfiguration
|
|
45
|
+
|
|
46
|
+
numbers_app = typer.Typer(
|
|
47
|
+
name="numbers",
|
|
48
|
+
help="Manage phone numbers.",
|
|
49
|
+
no_args_is_help=True,
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class AssignServices:
|
|
54
|
+
"""Container for services needed by assign command."""
|
|
55
|
+
|
|
56
|
+
def __init__(
|
|
57
|
+
self,
|
|
58
|
+
user_service: UserService,
|
|
59
|
+
number_service: NumberService,
|
|
60
|
+
location_service: LocationService,
|
|
61
|
+
config_manager: ConfigManager,
|
|
62
|
+
) -> None:
|
|
63
|
+
self.user_service = user_service
|
|
64
|
+
self.number_service = number_service
|
|
65
|
+
self.location_service = location_service
|
|
66
|
+
self.config_manager = config_manager
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _run_assign_async(
|
|
70
|
+
ctx: typer.Context,
|
|
71
|
+
coro_factory: Any,
|
|
72
|
+
) -> tuple[Any, OutputFormatter]:
|
|
73
|
+
"""Run an async operation with multiple services for assign/unassign.
|
|
74
|
+
|
|
75
|
+
Creates UserService, NumberService, LocationService and runs
|
|
76
|
+
the async operation using asyncio.run().
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
ctx: Typer context with CLIContext.
|
|
80
|
+
coro_factory: A callable that takes AssignServices and returns a coroutine.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
Tuple of (result, OutputFormatter) where result is the coroutine's return value.
|
|
84
|
+
"""
|
|
85
|
+
cli_ctx = get_context(ctx)
|
|
86
|
+
formatter = cli_ctx.get_output_formatter()
|
|
87
|
+
config_manager = cli_ctx.get_config_manager()
|
|
88
|
+
cache_manager = CacheManager()
|
|
89
|
+
auth_service = AuthService(config_manager, cache_manager)
|
|
90
|
+
|
|
91
|
+
async def _execute() -> Any:
|
|
92
|
+
async with GraphClient(auth_service) as graph_client:
|
|
93
|
+
services = AssignServices(
|
|
94
|
+
user_service=UserService(graph_client),
|
|
95
|
+
number_service=NumberService(graph_client),
|
|
96
|
+
location_service=LocationService(cache_manager),
|
|
97
|
+
config_manager=config_manager,
|
|
98
|
+
)
|
|
99
|
+
return await coro_factory(services)
|
|
100
|
+
|
|
101
|
+
result = asyncio.run(_execute())
|
|
102
|
+
return result, formatter
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _run_bulk_assign_async(
|
|
106
|
+
ctx: typer.Context,
|
|
107
|
+
coro_factory: Callable[[BulkOperations, ConfigManager], Coroutine[Any, Any, Any]],
|
|
108
|
+
) -> tuple[Any, OutputFormatter]:
|
|
109
|
+
"""Run an async operation with BulkOperations for bulk-assign.
|
|
110
|
+
|
|
111
|
+
Creates UserService, NumberService, LocationService, and BulkOperations,
|
|
112
|
+
then runs the async operation using asyncio.run().
|
|
113
|
+
|
|
114
|
+
Args:
|
|
115
|
+
ctx: Typer context with CLIContext.
|
|
116
|
+
coro_factory: A callable that takes (BulkOperations, ConfigManager)
|
|
117
|
+
and returns a coroutine.
|
|
118
|
+
|
|
119
|
+
Returns:
|
|
120
|
+
Tuple of (result, OutputFormatter) where result is the coroutine's return value.
|
|
121
|
+
"""
|
|
122
|
+
cli_ctx = get_context(ctx)
|
|
123
|
+
formatter = cli_ctx.get_output_formatter()
|
|
124
|
+
config_manager = cli_ctx.get_config_manager()
|
|
125
|
+
cache_manager = CacheManager()
|
|
126
|
+
auth_service = AuthService(config_manager, cache_manager)
|
|
127
|
+
|
|
128
|
+
async def _execute() -> Any:
|
|
129
|
+
async with GraphClient(auth_service) as graph_client:
|
|
130
|
+
user_service = UserService(graph_client)
|
|
131
|
+
number_service = NumberService(graph_client)
|
|
132
|
+
location_service = LocationService(cache_manager)
|
|
133
|
+
bulk_ops = BulkOperations(user_service, number_service, location_service)
|
|
134
|
+
return await coro_factory(bulk_ops, config_manager)
|
|
135
|
+
|
|
136
|
+
result = asyncio.run(_execute())
|
|
137
|
+
return result, formatter
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _run_with_progress(
|
|
141
|
+
formatter: OutputFormatter,
|
|
142
|
+
coro_factory: Callable[
|
|
143
|
+
[Callable[[AsyncOperation], None] | None], Coroutine[Any, Any, AsyncOperation]
|
|
144
|
+
],
|
|
145
|
+
initial_message: str = "Waiting for operation...",
|
|
146
|
+
) -> AsyncOperation:
|
|
147
|
+
"""Run an async operation with progress spinner display.
|
|
148
|
+
|
|
149
|
+
Displays a Rich Status spinner during async polling operations. The spinner
|
|
150
|
+
text updates with the current operation status on each poll callback.
|
|
151
|
+
In JSON mode, runs without spinner display.
|
|
152
|
+
|
|
153
|
+
Args:
|
|
154
|
+
formatter: OutputFormatter for console access and json_mode check.
|
|
155
|
+
coro_factory: A callable that takes an on_progress callback and returns
|
|
156
|
+
an awaitable that yields AsyncOperation.
|
|
157
|
+
initial_message: Initial status message shown in spinner.
|
|
158
|
+
|
|
159
|
+
Returns:
|
|
160
|
+
The final AsyncOperation result.
|
|
161
|
+
"""
|
|
162
|
+
if formatter.json_mode:
|
|
163
|
+
# No spinner in JSON mode - run silently
|
|
164
|
+
return asyncio.run(coro_factory(None))
|
|
165
|
+
|
|
166
|
+
# Use Rich Status for spinner display during polling
|
|
167
|
+
status_holder: dict[str, Any] = {"status": None}
|
|
168
|
+
|
|
169
|
+
def on_progress(operation: AsyncOperation) -> None:
|
|
170
|
+
"""Update spinner text with operation status."""
|
|
171
|
+
if status_holder["status"] is not None:
|
|
172
|
+
status_text = _format_operation_status(operation.status)
|
|
173
|
+
status_holder["status"].update(f"Waiting for operation... ({status_text})")
|
|
174
|
+
|
|
175
|
+
async def _execute_with_spinner() -> AsyncOperation:
|
|
176
|
+
spinner_name = formatter.get_spinner_name()
|
|
177
|
+
with formatter.console.status(initial_message, spinner=spinner_name) as status:
|
|
178
|
+
status_holder["status"] = status
|
|
179
|
+
return await coro_factory(on_progress)
|
|
180
|
+
|
|
181
|
+
return asyncio.run(_execute_with_spinner())
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _format_status(status: AssignmentStatus) -> str:
|
|
185
|
+
"""Format assignment status for display."""
|
|
186
|
+
display_names = {
|
|
187
|
+
AssignmentStatus.UNASSIGNED: "Unassigned",
|
|
188
|
+
AssignmentStatus.USER_ASSIGNED: "Assigned",
|
|
189
|
+
AssignmentStatus.CONFERENCE_ASSIGNED: "Conference",
|
|
190
|
+
AssignmentStatus.VOICE_APPLICATION_ASSIGNED: "Voice App",
|
|
191
|
+
}
|
|
192
|
+
return display_names.get(status, status.value)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _format_number_type(number_type: NumberType) -> str:
|
|
196
|
+
"""Format number type for display."""
|
|
197
|
+
display_names = {
|
|
198
|
+
NumberType.DIRECT_ROUTING: "Direct Routing",
|
|
199
|
+
NumberType.CALLING_PLAN: "Calling Plan",
|
|
200
|
+
NumberType.OPERATOR_CONNECT: "Operator Connect",
|
|
201
|
+
}
|
|
202
|
+
return display_names.get(number_type, number_type.value)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _format_activation_state(state: ActivationState) -> str:
|
|
206
|
+
"""Format activation state for display."""
|
|
207
|
+
display_names = {
|
|
208
|
+
ActivationState.ACTIVATED: "Activated",
|
|
209
|
+
ActivationState.ASSIGNMENT_PENDING: "Assignment Pending",
|
|
210
|
+
ActivationState.ASSIGNMENT_FAILED: "Assignment Failed",
|
|
211
|
+
ActivationState.UPDATE_PENDING: "Update Pending",
|
|
212
|
+
ActivationState.UPDATE_FAILED: "Update Failed",
|
|
213
|
+
}
|
|
214
|
+
return display_names.get(state, state.value)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def _format_capabilities(capabilities: list[str]) -> str:
|
|
218
|
+
"""Format capabilities list for display."""
|
|
219
|
+
if not capabilities:
|
|
220
|
+
return "—"
|
|
221
|
+
# Shorten common capability names
|
|
222
|
+
short_names = {
|
|
223
|
+
"userAssignment": "User",
|
|
224
|
+
"serviceAssignment": "Service",
|
|
225
|
+
"conferenceAssignment": "Conference",
|
|
226
|
+
}
|
|
227
|
+
formatted = [short_names.get(c, c) for c in capabilities]
|
|
228
|
+
return ", ".join(formatted)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _number_to_list_dict(number: NumberAssignment) -> dict[str, Any]:
|
|
232
|
+
"""Convert NumberAssignment to dict for list/search table display."""
|
|
233
|
+
return {
|
|
234
|
+
"phone_number": number.telephone_number,
|
|
235
|
+
"status": _format_status(number.assignment_status),
|
|
236
|
+
"type": _format_number_type(number.number_type),
|
|
237
|
+
"city": number.city or "—",
|
|
238
|
+
"capabilities": _format_capabilities(number.capabilities),
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _number_to_json_dict(number: NumberAssignment) -> dict[str, Any]:
|
|
243
|
+
"""Convert NumberAssignment to dict for JSON output."""
|
|
244
|
+
return {
|
|
245
|
+
"id": number.id,
|
|
246
|
+
"telephone_number": number.telephone_number,
|
|
247
|
+
"number_type": number.number_type.value,
|
|
248
|
+
"assignment_status": number.assignment_status.value,
|
|
249
|
+
"activation_state": number.activation_state.value,
|
|
250
|
+
"capabilities": number.capabilities,
|
|
251
|
+
"city": number.city,
|
|
252
|
+
"iso_country_code": number.iso_country_code,
|
|
253
|
+
"location_id": number.location_id,
|
|
254
|
+
"civic_address_id": number.civic_address_id,
|
|
255
|
+
"network_site_id": number.network_site_id,
|
|
256
|
+
"assignment_target_id": number.assignment_target_id,
|
|
257
|
+
"assignment_category": (
|
|
258
|
+
number.assignment_category.value if number.assignment_category else None
|
|
259
|
+
),
|
|
260
|
+
"operator_id": number.operator_id,
|
|
261
|
+
"port_in_status": number.port_in_status,
|
|
262
|
+
"number_source": number.number_source,
|
|
263
|
+
"supported_customer_actions": number.supported_customer_actions,
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
@numbers_app.command("list")
|
|
268
|
+
def list_numbers(
|
|
269
|
+
ctx: typer.Context,
|
|
270
|
+
status: AssignmentStatus | None = typer.Option(
|
|
271
|
+
None,
|
|
272
|
+
"--status",
|
|
273
|
+
"-s",
|
|
274
|
+
help="Filter by assignment status (unassigned, userAssigned, conferenceAssigned, voiceApplicationAssigned).",
|
|
275
|
+
),
|
|
276
|
+
number_type: NumberType | None = typer.Option(
|
|
277
|
+
None,
|
|
278
|
+
"--type",
|
|
279
|
+
"-t",
|
|
280
|
+
help="Filter by number type (directRouting, callingPlan, operatorConnect).",
|
|
281
|
+
),
|
|
282
|
+
city: str | None = typer.Option(
|
|
283
|
+
None,
|
|
284
|
+
"--city",
|
|
285
|
+
"-c",
|
|
286
|
+
help="Filter by city (case-sensitive, client-side filter).",
|
|
287
|
+
),
|
|
288
|
+
limit: int | None = typer.Option(
|
|
289
|
+
None,
|
|
290
|
+
"--limit",
|
|
291
|
+
"-l",
|
|
292
|
+
help="Limit results to n numbers.",
|
|
293
|
+
),
|
|
294
|
+
all_numbers: bool = typer.Option(
|
|
295
|
+
False,
|
|
296
|
+
"--all",
|
|
297
|
+
help="Fetch all numbers (may be slow for large inventories).",
|
|
298
|
+
),
|
|
299
|
+
) -> None:
|
|
300
|
+
"""List phone numbers in inventory."""
|
|
301
|
+
cli_ctx = get_context(ctx)
|
|
302
|
+
formatter = cli_ctx.get_output_formatter()
|
|
303
|
+
|
|
304
|
+
# Validate mutually exclusive options
|
|
305
|
+
if limit is not None and all_numbers:
|
|
306
|
+
formatter.error(
|
|
307
|
+
"Cannot specify both --limit and --all",
|
|
308
|
+
remediation="Use either --limit <n> to limit results or --all to fetch all numbers.",
|
|
309
|
+
)
|
|
310
|
+
raise typer.Exit(5) # ValidationError exit code
|
|
311
|
+
|
|
312
|
+
# Determine max_items: None for --all, provided limit, or default 100
|
|
313
|
+
if all_numbers:
|
|
314
|
+
max_items = None
|
|
315
|
+
elif limit is not None:
|
|
316
|
+
max_items = limit
|
|
317
|
+
else:
|
|
318
|
+
max_items = 100 # Default limit
|
|
319
|
+
|
|
320
|
+
try:
|
|
321
|
+
numbers, formatter = run_with_service(
|
|
322
|
+
ctx,
|
|
323
|
+
service_factory=NumberService,
|
|
324
|
+
operation=lambda svc: svc.list_numbers(
|
|
325
|
+
status=status,
|
|
326
|
+
number_type=number_type,
|
|
327
|
+
city=city,
|
|
328
|
+
max_items=max_items,
|
|
329
|
+
),
|
|
330
|
+
)
|
|
331
|
+
|
|
332
|
+
if cli_ctx.json_output:
|
|
333
|
+
formatter.json([_number_to_json_dict(n) for n in numbers])
|
|
334
|
+
else:
|
|
335
|
+
if not numbers:
|
|
336
|
+
formatter.info("No numbers found matching the specified criteria")
|
|
337
|
+
else:
|
|
338
|
+
formatter.table(
|
|
339
|
+
data=[_number_to_list_dict(n) for n in numbers],
|
|
340
|
+
columns=[
|
|
341
|
+
"phone_number",
|
|
342
|
+
"status",
|
|
343
|
+
"type",
|
|
344
|
+
"city",
|
|
345
|
+
"capabilities",
|
|
346
|
+
],
|
|
347
|
+
title="Phone Numbers",
|
|
348
|
+
)
|
|
349
|
+
except TeamsPhoneError as e:
|
|
350
|
+
formatter.error(e.message, remediation=e.remediation)
|
|
351
|
+
raise typer.Exit(e.exit_code) from None
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
@numbers_app.command("show")
|
|
355
|
+
def show_number( # noqa: C901 - CLI command rendering multiple detail sections with optional fields
|
|
356
|
+
ctx: typer.Context,
|
|
357
|
+
number: str = typer.Argument(
|
|
358
|
+
...,
|
|
359
|
+
help="Phone number (E.164 format or partial).",
|
|
360
|
+
),
|
|
361
|
+
) -> None:
|
|
362
|
+
"""Show detailed information for a specific phone number."""
|
|
363
|
+
cli_ctx = get_context(ctx)
|
|
364
|
+
formatter = cli_ctx.get_output_formatter()
|
|
365
|
+
|
|
366
|
+
try:
|
|
367
|
+
number_info, _ = run_with_service(
|
|
368
|
+
ctx,
|
|
369
|
+
service_factory=NumberService,
|
|
370
|
+
operation=lambda svc: svc.get_number(number),
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
if cli_ctx.json_output:
|
|
374
|
+
formatter.json(_number_to_json_dict(number_info))
|
|
375
|
+
else:
|
|
376
|
+
# Number Details section
|
|
377
|
+
identity_data = [
|
|
378
|
+
{"field": "Phone Number", "value": number_info.telephone_number},
|
|
379
|
+
{"field": "ID", "value": number_info.id},
|
|
380
|
+
]
|
|
381
|
+
if number_info.operator_id:
|
|
382
|
+
identity_data.append(
|
|
383
|
+
{"field": "Operator ID", "value": number_info.operator_id}
|
|
384
|
+
)
|
|
385
|
+
formatter.table(
|
|
386
|
+
data=identity_data,
|
|
387
|
+
columns=["field", "value"],
|
|
388
|
+
title="Number Details",
|
|
389
|
+
)
|
|
390
|
+
|
|
391
|
+
# Assignment section
|
|
392
|
+
assignment_data = [
|
|
393
|
+
{
|
|
394
|
+
"field": "Status",
|
|
395
|
+
"value": _format_status(number_info.assignment_status),
|
|
396
|
+
},
|
|
397
|
+
]
|
|
398
|
+
if number_info.assignment_target_id:
|
|
399
|
+
assignment_data.append(
|
|
400
|
+
{"field": "Assigned To", "value": number_info.assignment_target_id}
|
|
401
|
+
)
|
|
402
|
+
if number_info.assignment_category:
|
|
403
|
+
assignment_data.append(
|
|
404
|
+
{
|
|
405
|
+
"field": "Category",
|
|
406
|
+
"value": number_info.assignment_category.value,
|
|
407
|
+
}
|
|
408
|
+
)
|
|
409
|
+
formatter.table(
|
|
410
|
+
data=assignment_data,
|
|
411
|
+
columns=["field", "value"],
|
|
412
|
+
title="Assignment",
|
|
413
|
+
)
|
|
414
|
+
|
|
415
|
+
# Source section
|
|
416
|
+
source_data = [
|
|
417
|
+
{
|
|
418
|
+
"field": "Number Type",
|
|
419
|
+
"value": _format_number_type(number_info.number_type),
|
|
420
|
+
},
|
|
421
|
+
{
|
|
422
|
+
"field": "Activation State",
|
|
423
|
+
"value": _format_activation_state(number_info.activation_state),
|
|
424
|
+
},
|
|
425
|
+
]
|
|
426
|
+
if number_info.number_source:
|
|
427
|
+
source_data.append(
|
|
428
|
+
{"field": "Source", "value": number_info.number_source}
|
|
429
|
+
)
|
|
430
|
+
if number_info.port_in_status:
|
|
431
|
+
source_data.append(
|
|
432
|
+
{"field": "Port-In Status", "value": number_info.port_in_status}
|
|
433
|
+
)
|
|
434
|
+
formatter.table(
|
|
435
|
+
data=source_data,
|
|
436
|
+
columns=["field", "value"],
|
|
437
|
+
title="Source",
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
# Capabilities section
|
|
441
|
+
capabilities_value = (
|
|
442
|
+
", ".join(number_info.capabilities)
|
|
443
|
+
if number_info.capabilities
|
|
444
|
+
else "None"
|
|
445
|
+
)
|
|
446
|
+
formatter.table(
|
|
447
|
+
data=[{"field": "Capabilities", "value": capabilities_value}],
|
|
448
|
+
columns=["field", "value"],
|
|
449
|
+
title="Capabilities",
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
# Location section
|
|
453
|
+
location_data = [
|
|
454
|
+
{"field": "City", "value": number_info.city or "—"},
|
|
455
|
+
{"field": "Country", "value": number_info.iso_country_code or "—"},
|
|
456
|
+
]
|
|
457
|
+
if number_info.location_id:
|
|
458
|
+
location_data.append(
|
|
459
|
+
{"field": "Emergency Location ID", "value": number_info.location_id}
|
|
460
|
+
)
|
|
461
|
+
if number_info.civic_address_id:
|
|
462
|
+
location_data.append(
|
|
463
|
+
{"field": "Civic Address ID", "value": number_info.civic_address_id}
|
|
464
|
+
)
|
|
465
|
+
formatter.table(
|
|
466
|
+
data=location_data,
|
|
467
|
+
columns=["field", "value"],
|
|
468
|
+
title="Location",
|
|
469
|
+
)
|
|
470
|
+
|
|
471
|
+
# Network section (if applicable)
|
|
472
|
+
if number_info.network_site_id:
|
|
473
|
+
formatter.table(
|
|
474
|
+
data=[
|
|
475
|
+
{
|
|
476
|
+
"field": "Network Site ID",
|
|
477
|
+
"value": number_info.network_site_id,
|
|
478
|
+
}
|
|
479
|
+
],
|
|
480
|
+
columns=["field", "value"],
|
|
481
|
+
title="Network",
|
|
482
|
+
)
|
|
483
|
+
|
|
484
|
+
except TeamsPhoneError as e:
|
|
485
|
+
formatter.error(e.message, remediation=e.remediation)
|
|
486
|
+
raise typer.Exit(e.exit_code) from None
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
@numbers_app.command("search")
|
|
490
|
+
def search_numbers(
|
|
491
|
+
ctx: typer.Context,
|
|
492
|
+
pattern: str = typer.Argument(
|
|
493
|
+
...,
|
|
494
|
+
help="Search pattern (supports wildcards: 206*, *1234, *555*).",
|
|
495
|
+
),
|
|
496
|
+
limit: int = typer.Option(
|
|
497
|
+
25,
|
|
498
|
+
"--limit",
|
|
499
|
+
"-l",
|
|
500
|
+
help="Maximum results (default: 25).",
|
|
501
|
+
),
|
|
502
|
+
) -> None:
|
|
503
|
+
"""Search phone numbers by pattern.
|
|
504
|
+
|
|
505
|
+
Supports wildcards: '206*' (prefix), '*1234' (suffix), '*555*' (contains).
|
|
506
|
+
Note: Searches all numbers client-side, may be slow for large inventories.
|
|
507
|
+
"""
|
|
508
|
+
cli_ctx = get_context(ctx)
|
|
509
|
+
formatter = cli_ctx.get_output_formatter()
|
|
510
|
+
|
|
511
|
+
try:
|
|
512
|
+
numbers, _ = run_with_service(
|
|
513
|
+
ctx,
|
|
514
|
+
service_factory=NumberService,
|
|
515
|
+
operation=lambda svc: svc.search_numbers(pattern, max_results=limit),
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
if cli_ctx.json_output:
|
|
519
|
+
formatter.json([_number_to_json_dict(n) for n in numbers])
|
|
520
|
+
else:
|
|
521
|
+
if not numbers:
|
|
522
|
+
formatter.info(f"No numbers found matching '{pattern}'")
|
|
523
|
+
else:
|
|
524
|
+
formatter.table(
|
|
525
|
+
data=[_number_to_list_dict(n) for n in numbers],
|
|
526
|
+
columns=[
|
|
527
|
+
"phone_number",
|
|
528
|
+
"status",
|
|
529
|
+
"type",
|
|
530
|
+
"city",
|
|
531
|
+
"capabilities",
|
|
532
|
+
],
|
|
533
|
+
title=f"Search Results for '{pattern}'",
|
|
534
|
+
)
|
|
535
|
+
except TeamsPhoneError as e:
|
|
536
|
+
formatter.error(e.message, remediation=e.remediation)
|
|
537
|
+
raise typer.Exit(e.exit_code) from None
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def _format_operation_status(status: OperationStatus) -> str:
|
|
541
|
+
"""Format operation status for display."""
|
|
542
|
+
display_names = {
|
|
543
|
+
OperationStatus.NOT_STARTED: "Not Started",
|
|
544
|
+
OperationStatus.RUNNING: "Running",
|
|
545
|
+
OperationStatus.SUCCEEDED: "Succeeded",
|
|
546
|
+
OperationStatus.FAILED: "Failed",
|
|
547
|
+
OperationStatus.SKIPPED: "Skipped",
|
|
548
|
+
}
|
|
549
|
+
return display_names.get(status, status.value)
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
def _operation_to_json_dict(
|
|
553
|
+
operation: AsyncOperation,
|
|
554
|
+
user: UserConfiguration,
|
|
555
|
+
phone_number: str,
|
|
556
|
+
number_type: NumberType,
|
|
557
|
+
location: EmergencyLocation | None,
|
|
558
|
+
) -> dict[str, Any]:
|
|
559
|
+
"""Convert AsyncOperation to dict for JSON output."""
|
|
560
|
+
return {
|
|
561
|
+
"operation_id": operation.id,
|
|
562
|
+
"status": operation.status.value,
|
|
563
|
+
"created_date_time": operation.created_date_time.isoformat(),
|
|
564
|
+
"user_id": user.id,
|
|
565
|
+
"user_principal_name": user.user_principal_name,
|
|
566
|
+
"display_name": user.display_name,
|
|
567
|
+
"phone_number": phone_number,
|
|
568
|
+
"number_type": number_type.value,
|
|
569
|
+
"location_id": location.location_id if location else None,
|
|
570
|
+
"location_description": location.description if location else None,
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
@numbers_app.command("assign")
|
|
575
|
+
def assign_number( # noqa: C901 - CLI command with dry-run, wait, force modes and JSON output
|
|
576
|
+
ctx: typer.Context,
|
|
577
|
+
user: str = typer.Argument(
|
|
578
|
+
...,
|
|
579
|
+
help="User identifier (UPN, display name, or object ID).",
|
|
580
|
+
),
|
|
581
|
+
number: str = typer.Argument(
|
|
582
|
+
...,
|
|
583
|
+
help="Phone number to assign (E.164 format or partial).",
|
|
584
|
+
),
|
|
585
|
+
location: str | None = typer.Option(
|
|
586
|
+
None,
|
|
587
|
+
"--location",
|
|
588
|
+
"-l",
|
|
589
|
+
help="Emergency location ID or description. Required for Calling Plan numbers.",
|
|
590
|
+
),
|
|
591
|
+
number_type: NumberType = typer.Option(
|
|
592
|
+
NumberType.CALLING_PLAN,
|
|
593
|
+
"--type",
|
|
594
|
+
"-t",
|
|
595
|
+
help="Number type (directRouting, callingPlan, operatorConnect).",
|
|
596
|
+
),
|
|
597
|
+
dry_run: bool = typer.Option(
|
|
598
|
+
False,
|
|
599
|
+
"--dry-run",
|
|
600
|
+
help="Preview assignment without making changes.",
|
|
601
|
+
),
|
|
602
|
+
no_wait: bool = typer.Option(
|
|
603
|
+
False,
|
|
604
|
+
"--no-wait",
|
|
605
|
+
help="Return immediately without waiting for operation to complete.",
|
|
606
|
+
),
|
|
607
|
+
force: bool = typer.Option(
|
|
608
|
+
False,
|
|
609
|
+
"--force",
|
|
610
|
+
"-f",
|
|
611
|
+
help="Skip confirmation prompt.",
|
|
612
|
+
),
|
|
613
|
+
) -> None:
|
|
614
|
+
"""Assign a phone number to a user.
|
|
615
|
+
|
|
616
|
+
Resolves the user by UPN, display name, or object ID, validates the
|
|
617
|
+
emergency location for Calling Plan numbers, and assigns the phone number.
|
|
618
|
+
|
|
619
|
+
Examples:
|
|
620
|
+
|
|
621
|
+
teams-phone numbers assign john@contoso.com +14255551234
|
|
622
|
+
|
|
623
|
+
teams-phone numbers assign "John Smith" +14255551234 --location "Seattle HQ"
|
|
624
|
+
|
|
625
|
+
teams-phone numbers assign john@contoso.com +14255551234 --dry-run
|
|
626
|
+
"""
|
|
627
|
+
cli_ctx = get_context(ctx)
|
|
628
|
+
formatter = cli_ctx.get_output_formatter()
|
|
629
|
+
|
|
630
|
+
async def _resolve(
|
|
631
|
+
services: AssignServices,
|
|
632
|
+
) -> tuple[
|
|
633
|
+
UserConfiguration,
|
|
634
|
+
EmergencyLocation | None,
|
|
635
|
+
]:
|
|
636
|
+
# Step 1: Resolve user
|
|
637
|
+
resolved_user = await services.user_service.resolve_user(user)
|
|
638
|
+
|
|
639
|
+
# Step 2: Resolve location
|
|
640
|
+
resolved_location: EmergencyLocation | None = None
|
|
641
|
+
|
|
642
|
+
if location:
|
|
643
|
+
# Explicit location provided
|
|
644
|
+
resolved_location = services.location_service.validate_location(location)
|
|
645
|
+
elif number_type == NumberType.CALLING_PLAN:
|
|
646
|
+
# Check tenant default location
|
|
647
|
+
default_tenant_name = services.config_manager.get_default_tenant()
|
|
648
|
+
if default_tenant_name:
|
|
649
|
+
tenant_profile = services.config_manager.get_tenant(default_tenant_name)
|
|
650
|
+
if tenant_profile.default_location:
|
|
651
|
+
resolved_location = services.location_service.validate_location(
|
|
652
|
+
tenant_profile.default_location
|
|
653
|
+
)
|
|
654
|
+
return resolved_user, resolved_location
|
|
655
|
+
|
|
656
|
+
raise ValidationError(
|
|
657
|
+
f"Emergency location required for Calling Plan number {number}",
|
|
658
|
+
remediation=(
|
|
659
|
+
"Use --location to specify an emergency location, or\n"
|
|
660
|
+
"set default_location in tenant config with:\n"
|
|
661
|
+
" teams-phone tenants update <name> --default-location <id>"
|
|
662
|
+
),
|
|
663
|
+
)
|
|
664
|
+
|
|
665
|
+
return resolved_user, resolved_location
|
|
666
|
+
|
|
667
|
+
try:
|
|
668
|
+
(resolved_user, resolved_location), _ = _run_assign_async(ctx, _resolve)
|
|
669
|
+
|
|
670
|
+
# Handle dry-run output
|
|
671
|
+
if dry_run:
|
|
672
|
+
if cli_ctx.json_output:
|
|
673
|
+
formatter.json(
|
|
674
|
+
{
|
|
675
|
+
"dry_run": True,
|
|
676
|
+
"user_id": resolved_user.id,
|
|
677
|
+
"user_principal_name": resolved_user.user_principal_name,
|
|
678
|
+
"display_name": resolved_user.display_name,
|
|
679
|
+
"phone_number": number,
|
|
680
|
+
"number_type": number_type.value,
|
|
681
|
+
"location_id": resolved_location.location_id
|
|
682
|
+
if resolved_location
|
|
683
|
+
else None,
|
|
684
|
+
"location_description": (
|
|
685
|
+
resolved_location.description if resolved_location else None
|
|
686
|
+
),
|
|
687
|
+
}
|
|
688
|
+
)
|
|
689
|
+
else:
|
|
690
|
+
formatter.info("Dry Run Preview:")
|
|
691
|
+
formatter.info(
|
|
692
|
+
f" User: {resolved_user.display_name or resolved_user.user_principal_name}"
|
|
693
|
+
)
|
|
694
|
+
formatter.info(f" User ID: {resolved_user.id}")
|
|
695
|
+
formatter.info(f" Phone Number: {number}")
|
|
696
|
+
formatter.info(f" Number Type: {_format_number_type(number_type)}")
|
|
697
|
+
if resolved_location:
|
|
698
|
+
formatter.info(f" Location: {resolved_location.description}")
|
|
699
|
+
formatter.info(f" Location ID: {resolved_location.location_id}")
|
|
700
|
+
formatter.info("")
|
|
701
|
+
formatter.info("No changes made (dry run)")
|
|
702
|
+
raise typer.Exit(0)
|
|
703
|
+
|
|
704
|
+
# Confirm before executing (unless --force or --json)
|
|
705
|
+
if not force and not cli_ctx.json_output:
|
|
706
|
+
formatter.info("Assignment Details:")
|
|
707
|
+
formatter.info(
|
|
708
|
+
f" User: {resolved_user.display_name or resolved_user.user_principal_name}"
|
|
709
|
+
)
|
|
710
|
+
formatter.info(f" Phone Number: {number}")
|
|
711
|
+
formatter.info(f" Number Type: {_format_number_type(number_type)}")
|
|
712
|
+
if resolved_location:
|
|
713
|
+
formatter.info(f" Location: {resolved_location.description}")
|
|
714
|
+
formatter.info("")
|
|
715
|
+
confirm = typer.confirm("Proceed with assignment?")
|
|
716
|
+
if not confirm:
|
|
717
|
+
formatter.info("Assignment cancelled")
|
|
718
|
+
raise typer.Exit(0)
|
|
719
|
+
|
|
720
|
+
# Execute assignment
|
|
721
|
+
if no_wait:
|
|
722
|
+
# No waiting - return immediately after initiating
|
|
723
|
+
async def _execute_assign_no_wait(
|
|
724
|
+
services: AssignServices,
|
|
725
|
+
) -> AsyncOperation:
|
|
726
|
+
return await services.number_service.assign_number(
|
|
727
|
+
user_id=resolved_user.id,
|
|
728
|
+
phone_number=number,
|
|
729
|
+
number_type=number_type,
|
|
730
|
+
location_id=resolved_location.location_id
|
|
731
|
+
if resolved_location
|
|
732
|
+
else None,
|
|
733
|
+
wait=False,
|
|
734
|
+
)
|
|
735
|
+
|
|
736
|
+
result, _ = _run_assign_async(ctx, _execute_assign_no_wait)
|
|
737
|
+
else:
|
|
738
|
+
# Wait with progress spinner (unless JSON mode)
|
|
739
|
+
config_manager = cli_ctx.get_config_manager()
|
|
740
|
+
cache_manager = CacheManager()
|
|
741
|
+
auth_service = AuthService(config_manager, cache_manager)
|
|
742
|
+
|
|
743
|
+
def _make_assign_coro(
|
|
744
|
+
on_progress: Callable[[AsyncOperation], None] | None,
|
|
745
|
+
) -> Coroutine[Any, Any, AsyncOperation]:
|
|
746
|
+
async def _execute() -> AsyncOperation:
|
|
747
|
+
async with GraphClient(auth_service) as graph_client:
|
|
748
|
+
service = NumberService(graph_client)
|
|
749
|
+
return await service.assign_number(
|
|
750
|
+
user_id=resolved_user.id,
|
|
751
|
+
phone_number=number,
|
|
752
|
+
number_type=number_type,
|
|
753
|
+
location_id=resolved_location.location_id
|
|
754
|
+
if resolved_location
|
|
755
|
+
else None,
|
|
756
|
+
wait=True,
|
|
757
|
+
on_progress=on_progress,
|
|
758
|
+
)
|
|
759
|
+
|
|
760
|
+
return _execute()
|
|
761
|
+
|
|
762
|
+
result = _run_with_progress(
|
|
763
|
+
formatter,
|
|
764
|
+
_make_assign_coro,
|
|
765
|
+
initial_message="Assigning phone number...",
|
|
766
|
+
)
|
|
767
|
+
|
|
768
|
+
# Handle result output
|
|
769
|
+
if cli_ctx.json_output:
|
|
770
|
+
formatter.json(
|
|
771
|
+
_operation_to_json_dict(
|
|
772
|
+
result, resolved_user, number, number_type, resolved_location
|
|
773
|
+
)
|
|
774
|
+
)
|
|
775
|
+
else:
|
|
776
|
+
if result.status == OperationStatus.SUCCEEDED:
|
|
777
|
+
formatter.success(
|
|
778
|
+
f"Assigned {number} to {resolved_user.display_name or resolved_user.user_principal_name}"
|
|
779
|
+
)
|
|
780
|
+
elif result.status == OperationStatus.FAILED:
|
|
781
|
+
formatter.error(
|
|
782
|
+
f"Assignment failed for {number}",
|
|
783
|
+
remediation=(
|
|
784
|
+
"Check that the number is available and the user is eligible.\n"
|
|
785
|
+
f"Operation ID: {result.id}"
|
|
786
|
+
),
|
|
787
|
+
)
|
|
788
|
+
raise typer.Exit(1)
|
|
789
|
+
elif no_wait:
|
|
790
|
+
formatter.info(f"Assignment initiated for {number}")
|
|
791
|
+
formatter.info(f"Operation ID: {result.id}")
|
|
792
|
+
formatter.info(f"Status: {_format_operation_status(result.status)}")
|
|
793
|
+
formatter.info("")
|
|
794
|
+
formatter.info("Use the operation ID to check status later.")
|
|
795
|
+
else:
|
|
796
|
+
# Other status (running, not_started)
|
|
797
|
+
formatter.info(
|
|
798
|
+
f"Assignment status: {_format_operation_status(result.status)}"
|
|
799
|
+
)
|
|
800
|
+
formatter.info(f"Operation ID: {result.id}")
|
|
801
|
+
|
|
802
|
+
except TeamsPhoneError as e:
|
|
803
|
+
formatter.error(e.message, remediation=e.remediation)
|
|
804
|
+
raise typer.Exit(e.exit_code) from None
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
def _unassign_operation_to_json_dict(
|
|
808
|
+
operation: AsyncOperation,
|
|
809
|
+
phone_number: str,
|
|
810
|
+
number_type: NumberType,
|
|
811
|
+
previous_assignee: str | None,
|
|
812
|
+
) -> dict[str, Any]:
|
|
813
|
+
"""Convert AsyncOperation to dict for unassign JSON output."""
|
|
814
|
+
return {
|
|
815
|
+
"operation_id": operation.id,
|
|
816
|
+
"status": operation.status.value,
|
|
817
|
+
"phone_number": phone_number,
|
|
818
|
+
"number_type": number_type.value,
|
|
819
|
+
"previous_assignee": previous_assignee,
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
@numbers_app.command("unassign")
|
|
824
|
+
def unassign_number( # noqa: C901 - CLI command with dry-run, wait, force modes and JSON output
|
|
825
|
+
ctx: typer.Context,
|
|
826
|
+
number: str = typer.Argument(
|
|
827
|
+
...,
|
|
828
|
+
help="Phone number to unassign (E.164 format or partial).",
|
|
829
|
+
),
|
|
830
|
+
dry_run: bool = typer.Option(
|
|
831
|
+
False,
|
|
832
|
+
"--dry-run",
|
|
833
|
+
help="Preview unassignment without making changes.",
|
|
834
|
+
),
|
|
835
|
+
no_wait: bool = typer.Option(
|
|
836
|
+
False,
|
|
837
|
+
"--no-wait",
|
|
838
|
+
help="Return immediately without waiting for operation to complete.",
|
|
839
|
+
),
|
|
840
|
+
force: bool = typer.Option(
|
|
841
|
+
False,
|
|
842
|
+
"--force",
|
|
843
|
+
"-f",
|
|
844
|
+
help="Skip confirmation prompt.",
|
|
845
|
+
),
|
|
846
|
+
) -> None:
|
|
847
|
+
"""Unassign a phone number from its current user.
|
|
848
|
+
|
|
849
|
+
Looks up the phone number to get its current assignment, displays a
|
|
850
|
+
confirmation prompt (unless --force), and unassigns the number.
|
|
851
|
+
|
|
852
|
+
Examples:
|
|
853
|
+
|
|
854
|
+
teams-phone numbers unassign +14255551234
|
|
855
|
+
|
|
856
|
+
teams-phone numbers unassign +14255551234 --force
|
|
857
|
+
|
|
858
|
+
teams-phone numbers unassign +14255551234 --dry-run
|
|
859
|
+
"""
|
|
860
|
+
cli_ctx = get_context(ctx)
|
|
861
|
+
formatter = cli_ctx.get_output_formatter()
|
|
862
|
+
|
|
863
|
+
try:
|
|
864
|
+
# Step 1: Look up the number to get current assignment
|
|
865
|
+
number_info, _ = run_with_service(
|
|
866
|
+
ctx,
|
|
867
|
+
service_factory=NumberService,
|
|
868
|
+
operation=lambda svc: svc.get_number(number),
|
|
869
|
+
)
|
|
870
|
+
|
|
871
|
+
# Step 2: Validate that the number is currently assigned
|
|
872
|
+
if number_info.assignment_status != AssignmentStatus.USER_ASSIGNED:
|
|
873
|
+
raise ValidationError(
|
|
874
|
+
f"Phone number {number_info.telephone_number} is not assigned to a user",
|
|
875
|
+
remediation=(
|
|
876
|
+
f"Current status: {_format_status(number_info.assignment_status)}.\n"
|
|
877
|
+
"Only numbers with status 'Assigned' (userAssigned) can be unassigned."
|
|
878
|
+
),
|
|
879
|
+
)
|
|
880
|
+
|
|
881
|
+
# Handle dry-run output
|
|
882
|
+
if dry_run:
|
|
883
|
+
if cli_ctx.json_output:
|
|
884
|
+
formatter.json(
|
|
885
|
+
{
|
|
886
|
+
"dry_run": True,
|
|
887
|
+
"phone_number": number_info.telephone_number,
|
|
888
|
+
"number_type": number_info.number_type.value,
|
|
889
|
+
"current_assignee": number_info.assignment_target_id,
|
|
890
|
+
}
|
|
891
|
+
)
|
|
892
|
+
else:
|
|
893
|
+
formatter.info("Dry Run Preview:")
|
|
894
|
+
formatter.info(f" Phone Number: {number_info.telephone_number}")
|
|
895
|
+
formatter.info(
|
|
896
|
+
f" Number Type: {_format_number_type(number_info.number_type)}"
|
|
897
|
+
)
|
|
898
|
+
formatter.info(
|
|
899
|
+
f" Current Assignee: {number_info.assignment_target_id}"
|
|
900
|
+
)
|
|
901
|
+
formatter.info("")
|
|
902
|
+
formatter.info("No changes made (dry run)")
|
|
903
|
+
raise typer.Exit(0)
|
|
904
|
+
|
|
905
|
+
# Confirm before executing (unless --force or --json)
|
|
906
|
+
if not force and not cli_ctx.json_output:
|
|
907
|
+
formatter.info("Unassignment Details:")
|
|
908
|
+
formatter.info(f" Phone Number: {number_info.telephone_number}")
|
|
909
|
+
formatter.info(
|
|
910
|
+
f" Number Type: {_format_number_type(number_info.number_type)}"
|
|
911
|
+
)
|
|
912
|
+
formatter.info(f" Current Assignee: {number_info.assignment_target_id}")
|
|
913
|
+
formatter.info("")
|
|
914
|
+
confirm = typer.confirm("Proceed with unassignment?")
|
|
915
|
+
if not confirm:
|
|
916
|
+
formatter.info("Unassignment cancelled")
|
|
917
|
+
raise typer.Exit(0)
|
|
918
|
+
|
|
919
|
+
# Execute unassignment
|
|
920
|
+
if no_wait:
|
|
921
|
+
# No waiting - return immediately after initiating
|
|
922
|
+
result, _ = run_with_service(
|
|
923
|
+
ctx,
|
|
924
|
+
service_factory=NumberService,
|
|
925
|
+
operation=lambda svc: svc.unassign_number(
|
|
926
|
+
phone_number=number_info.telephone_number,
|
|
927
|
+
number_type=number_info.number_type,
|
|
928
|
+
wait=False,
|
|
929
|
+
),
|
|
930
|
+
)
|
|
931
|
+
else:
|
|
932
|
+
# Wait with progress spinner (unless JSON mode)
|
|
933
|
+
config_manager = cli_ctx.get_config_manager()
|
|
934
|
+
cache_manager = CacheManager()
|
|
935
|
+
auth_service = AuthService(config_manager, cache_manager)
|
|
936
|
+
|
|
937
|
+
def _make_unassign_coro(
|
|
938
|
+
on_progress: Callable[[AsyncOperation], None] | None,
|
|
939
|
+
) -> Coroutine[Any, Any, AsyncOperation]:
|
|
940
|
+
async def _execute() -> AsyncOperation:
|
|
941
|
+
async with GraphClient(auth_service) as graph_client:
|
|
942
|
+
service = NumberService(graph_client)
|
|
943
|
+
return await service.unassign_number(
|
|
944
|
+
phone_number=number_info.telephone_number,
|
|
945
|
+
number_type=number_info.number_type,
|
|
946
|
+
wait=True,
|
|
947
|
+
on_progress=on_progress,
|
|
948
|
+
)
|
|
949
|
+
|
|
950
|
+
return _execute()
|
|
951
|
+
|
|
952
|
+
result = _run_with_progress(
|
|
953
|
+
formatter,
|
|
954
|
+
_make_unassign_coro,
|
|
955
|
+
initial_message="Unassigning phone number...",
|
|
956
|
+
)
|
|
957
|
+
|
|
958
|
+
# Handle result output
|
|
959
|
+
if cli_ctx.json_output:
|
|
960
|
+
formatter.json(
|
|
961
|
+
_unassign_operation_to_json_dict(
|
|
962
|
+
result,
|
|
963
|
+
number_info.telephone_number,
|
|
964
|
+
number_info.number_type,
|
|
965
|
+
number_info.assignment_target_id,
|
|
966
|
+
)
|
|
967
|
+
)
|
|
968
|
+
else:
|
|
969
|
+
if result.status == OperationStatus.SUCCEEDED:
|
|
970
|
+
formatter.success(f"Unassigned {number_info.telephone_number}")
|
|
971
|
+
elif result.status == OperationStatus.FAILED:
|
|
972
|
+
formatter.error(
|
|
973
|
+
f"Unassignment failed for {number_info.telephone_number}",
|
|
974
|
+
remediation=f"Operation ID: {result.id}",
|
|
975
|
+
)
|
|
976
|
+
raise typer.Exit(1)
|
|
977
|
+
elif no_wait:
|
|
978
|
+
formatter.info(
|
|
979
|
+
f"Unassignment initiated for {number_info.telephone_number}"
|
|
980
|
+
)
|
|
981
|
+
formatter.info(f"Operation ID: {result.id}")
|
|
982
|
+
formatter.info(f"Status: {_format_operation_status(result.status)}")
|
|
983
|
+
formatter.info("")
|
|
984
|
+
formatter.info("Use the operation ID to check status later.")
|
|
985
|
+
else:
|
|
986
|
+
# Other status (running, not_started)
|
|
987
|
+
formatter.info(
|
|
988
|
+
f"Unassignment status: {_format_operation_status(result.status)}"
|
|
989
|
+
)
|
|
990
|
+
formatter.info(f"Operation ID: {result.id}")
|
|
991
|
+
|
|
992
|
+
except TeamsPhoneError as e:
|
|
993
|
+
formatter.error(e.message, remediation=e.remediation)
|
|
994
|
+
raise typer.Exit(e.exit_code) from None
|
|
995
|
+
|
|
996
|
+
|
|
997
|
+
def _update_to_json_dict(
|
|
998
|
+
phone_number: str,
|
|
999
|
+
number_info: NumberAssignment,
|
|
1000
|
+
location: EmergencyLocation | None,
|
|
1001
|
+
clear_location: bool,
|
|
1002
|
+
network_site_id: str | None,
|
|
1003
|
+
clear_network_site: bool,
|
|
1004
|
+
) -> dict[str, Any]:
|
|
1005
|
+
"""Convert update parameters to dict for JSON output."""
|
|
1006
|
+
result: dict[str, Any] = {
|
|
1007
|
+
"phone_number": number_info.telephone_number,
|
|
1008
|
+
"number_type": number_info.number_type.value,
|
|
1009
|
+
}
|
|
1010
|
+
if location:
|
|
1011
|
+
result["location_id"] = location.location_id
|
|
1012
|
+
result["location_description"] = location.description
|
|
1013
|
+
elif clear_location:
|
|
1014
|
+
result["location_id"] = None
|
|
1015
|
+
result["clear_location"] = True
|
|
1016
|
+
if network_site_id:
|
|
1017
|
+
result["network_site_id"] = network_site_id
|
|
1018
|
+
elif clear_network_site:
|
|
1019
|
+
result["network_site_id"] = None
|
|
1020
|
+
result["clear_network_site"] = True
|
|
1021
|
+
return result
|
|
1022
|
+
|
|
1023
|
+
|
|
1024
|
+
@numbers_app.command("update")
|
|
1025
|
+
def update_number( # noqa: C901 - CLI command with dry-run, force modes, mutual exclusion, and JSON output
|
|
1026
|
+
ctx: typer.Context,
|
|
1027
|
+
number: str = typer.Argument(
|
|
1028
|
+
...,
|
|
1029
|
+
help="Phone number to update (E.164 format or partial).",
|
|
1030
|
+
),
|
|
1031
|
+
location: str | None = typer.Option(
|
|
1032
|
+
None,
|
|
1033
|
+
"--location",
|
|
1034
|
+
"-l",
|
|
1035
|
+
help="Emergency location ID or description to set.",
|
|
1036
|
+
),
|
|
1037
|
+
clear_location: bool = typer.Option(
|
|
1038
|
+
False,
|
|
1039
|
+
"--clear-location",
|
|
1040
|
+
help="Clear the emergency location.",
|
|
1041
|
+
),
|
|
1042
|
+
network_site: str | None = typer.Option(
|
|
1043
|
+
None,
|
|
1044
|
+
"--network-site",
|
|
1045
|
+
"-n",
|
|
1046
|
+
help="Network site ID to set.",
|
|
1047
|
+
),
|
|
1048
|
+
clear_network_site: bool = typer.Option(
|
|
1049
|
+
False,
|
|
1050
|
+
"--clear-network-site",
|
|
1051
|
+
help="Clear the network site.",
|
|
1052
|
+
),
|
|
1053
|
+
dry_run: bool = typer.Option(
|
|
1054
|
+
False,
|
|
1055
|
+
"--dry-run",
|
|
1056
|
+
help="Preview update without making changes.",
|
|
1057
|
+
),
|
|
1058
|
+
force: bool = typer.Option(
|
|
1059
|
+
False,
|
|
1060
|
+
"--force",
|
|
1061
|
+
"-f",
|
|
1062
|
+
help="Skip confirmation prompt.",
|
|
1063
|
+
),
|
|
1064
|
+
) -> None:
|
|
1065
|
+
"""Update phone number location or network site settings.
|
|
1066
|
+
|
|
1067
|
+
Change the emergency location or network site on an already-assigned phone
|
|
1068
|
+
number. This is a synchronous operation that takes effect immediately.
|
|
1069
|
+
|
|
1070
|
+
Examples:
|
|
1071
|
+
|
|
1072
|
+
teams-phone numbers update +14255551234 --location "Seattle HQ"
|
|
1073
|
+
|
|
1074
|
+
teams-phone numbers update +14255551234 --clear-location
|
|
1075
|
+
|
|
1076
|
+
teams-phone numbers update +14255551234 --network-site "Site-001"
|
|
1077
|
+
|
|
1078
|
+
teams-phone numbers update +14255551234 --location "NYC Office" --dry-run
|
|
1079
|
+
"""
|
|
1080
|
+
cli_ctx = get_context(ctx)
|
|
1081
|
+
formatter = cli_ctx.get_output_formatter()
|
|
1082
|
+
|
|
1083
|
+
# Validate mutually exclusive options
|
|
1084
|
+
if location and clear_location:
|
|
1085
|
+
formatter.error(
|
|
1086
|
+
"Cannot specify both --location and --clear-location",
|
|
1087
|
+
remediation="Use either --location <id> to set a location or --clear-location to remove it.",
|
|
1088
|
+
)
|
|
1089
|
+
raise typer.Exit(5) # ValidationError exit code
|
|
1090
|
+
if network_site and clear_network_site:
|
|
1091
|
+
formatter.error(
|
|
1092
|
+
"Cannot specify both --network-site and --clear-network-site",
|
|
1093
|
+
remediation="Use either --network-site <id> to set a site or --clear-network-site to remove it.",
|
|
1094
|
+
)
|
|
1095
|
+
raise typer.Exit(5)
|
|
1096
|
+
|
|
1097
|
+
# Validate at least one update is requested
|
|
1098
|
+
if not any([location, clear_location, network_site, clear_network_site]):
|
|
1099
|
+
formatter.error(
|
|
1100
|
+
"No update parameters specified",
|
|
1101
|
+
remediation="Use --location, --clear-location, --network-site, or --clear-network-site to specify what to update.",
|
|
1102
|
+
)
|
|
1103
|
+
raise typer.Exit(5)
|
|
1104
|
+
|
|
1105
|
+
try:
|
|
1106
|
+
# Step 1: Look up the number to verify it exists and is assigned
|
|
1107
|
+
number_info, formatter = run_with_service(
|
|
1108
|
+
ctx,
|
|
1109
|
+
service_factory=NumberService,
|
|
1110
|
+
operation=lambda svc: svc.get_number(number),
|
|
1111
|
+
)
|
|
1112
|
+
|
|
1113
|
+
# Step 2: Validate that the number is assigned
|
|
1114
|
+
if number_info.assignment_status != AssignmentStatus.USER_ASSIGNED:
|
|
1115
|
+
raise ValidationError(
|
|
1116
|
+
f"Phone number {number_info.telephone_number} is not assigned to a user",
|
|
1117
|
+
remediation=(
|
|
1118
|
+
f"Current status: {_format_status(number_info.assignment_status)}.\n"
|
|
1119
|
+
"Only numbers assigned to users can have their settings updated."
|
|
1120
|
+
),
|
|
1121
|
+
)
|
|
1122
|
+
|
|
1123
|
+
# Step 3: Resolve location if provided
|
|
1124
|
+
resolved_location: EmergencyLocation | None = None
|
|
1125
|
+
if location:
|
|
1126
|
+
cache_manager = CacheManager()
|
|
1127
|
+
location_service = LocationService(cache_manager)
|
|
1128
|
+
resolved_location = location_service.validate_location(location)
|
|
1129
|
+
|
|
1130
|
+
# Handle dry-run output
|
|
1131
|
+
if dry_run:
|
|
1132
|
+
if cli_ctx.json_output:
|
|
1133
|
+
json_result = _update_to_json_dict(
|
|
1134
|
+
number,
|
|
1135
|
+
number_info,
|
|
1136
|
+
resolved_location,
|
|
1137
|
+
clear_location,
|
|
1138
|
+
network_site,
|
|
1139
|
+
clear_network_site,
|
|
1140
|
+
)
|
|
1141
|
+
json_result["dry_run"] = True
|
|
1142
|
+
formatter.json(json_result)
|
|
1143
|
+
else:
|
|
1144
|
+
formatter.info("Dry Run Preview:")
|
|
1145
|
+
formatter.info(f" Phone Number: {number_info.telephone_number}")
|
|
1146
|
+
formatter.info(
|
|
1147
|
+
f" Number Type: {_format_number_type(number_info.number_type)}"
|
|
1148
|
+
)
|
|
1149
|
+
if resolved_location:
|
|
1150
|
+
formatter.info(f" New Location: {resolved_location.description}")
|
|
1151
|
+
formatter.info(f" Location ID: {resolved_location.location_id}")
|
|
1152
|
+
elif clear_location:
|
|
1153
|
+
formatter.info(" Location: Will be cleared")
|
|
1154
|
+
if network_site:
|
|
1155
|
+
formatter.info(f" New Network Site: {network_site}")
|
|
1156
|
+
elif clear_network_site:
|
|
1157
|
+
formatter.info(" Network Site: Will be cleared")
|
|
1158
|
+
formatter.info("")
|
|
1159
|
+
formatter.info("No changes made (dry run)")
|
|
1160
|
+
raise typer.Exit(0)
|
|
1161
|
+
|
|
1162
|
+
# Confirm before executing (unless --force or --json)
|
|
1163
|
+
if not force and not cli_ctx.json_output:
|
|
1164
|
+
formatter.info("Update Details:")
|
|
1165
|
+
formatter.info(f" Phone Number: {number_info.telephone_number}")
|
|
1166
|
+
formatter.info(
|
|
1167
|
+
f" Number Type: {_format_number_type(number_info.number_type)}"
|
|
1168
|
+
)
|
|
1169
|
+
if resolved_location:
|
|
1170
|
+
formatter.info(f" New Location: {resolved_location.description}")
|
|
1171
|
+
elif clear_location:
|
|
1172
|
+
formatter.info(" Location: Will be cleared")
|
|
1173
|
+
if network_site:
|
|
1174
|
+
formatter.info(f" New Network Site: {network_site}")
|
|
1175
|
+
elif clear_network_site:
|
|
1176
|
+
formatter.info(" Network Site: Will be cleared")
|
|
1177
|
+
formatter.info("")
|
|
1178
|
+
confirm = typer.confirm("Proceed with update?")
|
|
1179
|
+
if not confirm:
|
|
1180
|
+
formatter.info("Update cancelled")
|
|
1181
|
+
raise typer.Exit(0)
|
|
1182
|
+
|
|
1183
|
+
# Execute update
|
|
1184
|
+
run_with_service(
|
|
1185
|
+
ctx,
|
|
1186
|
+
service_factory=NumberService,
|
|
1187
|
+
operation=lambda svc: svc.update_number(
|
|
1188
|
+
phone_number=number_info.telephone_number,
|
|
1189
|
+
location_id=resolved_location.location_id
|
|
1190
|
+
if resolved_location
|
|
1191
|
+
else None,
|
|
1192
|
+
clear_location=clear_location,
|
|
1193
|
+
network_site_id=network_site,
|
|
1194
|
+
clear_network_site=clear_network_site,
|
|
1195
|
+
),
|
|
1196
|
+
)
|
|
1197
|
+
|
|
1198
|
+
# Handle result output
|
|
1199
|
+
if cli_ctx.json_output:
|
|
1200
|
+
json_result = _update_to_json_dict(
|
|
1201
|
+
number,
|
|
1202
|
+
number_info,
|
|
1203
|
+
resolved_location,
|
|
1204
|
+
clear_location,
|
|
1205
|
+
network_site,
|
|
1206
|
+
clear_network_site,
|
|
1207
|
+
)
|
|
1208
|
+
json_result["status"] = "success"
|
|
1209
|
+
formatter.json(json_result)
|
|
1210
|
+
else:
|
|
1211
|
+
formatter.success(f"Updated {number_info.telephone_number}")
|
|
1212
|
+
|
|
1213
|
+
except TeamsPhoneError as e:
|
|
1214
|
+
formatter.error(e.message, remediation=e.remediation)
|
|
1215
|
+
raise typer.Exit(e.exit_code) from None
|
|
1216
|
+
|
|
1217
|
+
|
|
1218
|
+
@numbers_app.command("bulk-assign")
|
|
1219
|
+
def bulk_assign( # noqa: C901 - Bulk operation with validation, dry-run, progress, and error handling
|
|
1220
|
+
ctx: typer.Context,
|
|
1221
|
+
csv_file: Path = typer.Argument(
|
|
1222
|
+
...,
|
|
1223
|
+
help="Path to CSV file with user, phone_number, and optional location columns.",
|
|
1224
|
+
exists=True,
|
|
1225
|
+
readable=True,
|
|
1226
|
+
),
|
|
1227
|
+
dry_run: bool = typer.Option(
|
|
1228
|
+
False,
|
|
1229
|
+
"--dry-run",
|
|
1230
|
+
help="Validate and preview all assignments without executing.",
|
|
1231
|
+
),
|
|
1232
|
+
continue_on_error: bool = typer.Option(
|
|
1233
|
+
False,
|
|
1234
|
+
"--continue-on-error",
|
|
1235
|
+
help="Continue processing after individual failures.",
|
|
1236
|
+
),
|
|
1237
|
+
default_location: str | None = typer.Option(
|
|
1238
|
+
None,
|
|
1239
|
+
"--default-location",
|
|
1240
|
+
help="Default emergency location for rows without a location column.",
|
|
1241
|
+
),
|
|
1242
|
+
output: Path | None = typer.Option(
|
|
1243
|
+
None,
|
|
1244
|
+
"--output",
|
|
1245
|
+
"-o",
|
|
1246
|
+
help="Path to write results CSV file.",
|
|
1247
|
+
),
|
|
1248
|
+
yes: bool = typer.Option(
|
|
1249
|
+
False,
|
|
1250
|
+
"--yes",
|
|
1251
|
+
"-y",
|
|
1252
|
+
help="Skip confirmation prompt.",
|
|
1253
|
+
),
|
|
1254
|
+
) -> None:
|
|
1255
|
+
"""Bulk assign phone numbers from a CSV file.
|
|
1256
|
+
|
|
1257
|
+
Reads a CSV file containing user, phone_number, and optional location columns.
|
|
1258
|
+
Validates all rows against Graph API, displays a summary, and executes
|
|
1259
|
+
assignments (unless --dry-run is specified).
|
|
1260
|
+
|
|
1261
|
+
CSV format:
|
|
1262
|
+
user,phone_number,location
|
|
1263
|
+
john@contoso.com,+14255551234,Seattle-HQ
|
|
1264
|
+
jane@contoso.com,+14255555678,
|
|
1265
|
+
|
|
1266
|
+
Examples:
|
|
1267
|
+
# Preview bulk assignment (validation only)
|
|
1268
|
+
teams-phone numbers bulk-assign assignments.csv --dry-run
|
|
1269
|
+
|
|
1270
|
+
# Execute bulk assignment with default location for missing values
|
|
1271
|
+
teams-phone numbers bulk-assign assignments.csv --default-location "Seattle-HQ"
|
|
1272
|
+
|
|
1273
|
+
# Continue on errors and save results to file
|
|
1274
|
+
teams-phone numbers bulk-assign assignments.csv --continue-on-error -o results.csv
|
|
1275
|
+
"""
|
|
1276
|
+
cli_ctx = get_context(ctx)
|
|
1277
|
+
formatter = cli_ctx.get_output_formatter()
|
|
1278
|
+
|
|
1279
|
+
try:
|
|
1280
|
+
# Step 1: Parse CSV file
|
|
1281
|
+
rows = parse_assign_csv(csv_file)
|
|
1282
|
+
|
|
1283
|
+
if not rows:
|
|
1284
|
+
if cli_ctx.json_output:
|
|
1285
|
+
formatter.json(
|
|
1286
|
+
{
|
|
1287
|
+
"status": "no_rows",
|
|
1288
|
+
"message": "No data rows found in CSV file",
|
|
1289
|
+
"file": str(csv_file),
|
|
1290
|
+
}
|
|
1291
|
+
)
|
|
1292
|
+
else:
|
|
1293
|
+
formatter.info("No data rows found in CSV file")
|
|
1294
|
+
raise typer.Exit(0)
|
|
1295
|
+
|
|
1296
|
+
# Apply default location to rows missing location
|
|
1297
|
+
# Priority: 1) CLI --default-location option, 2) tenant profile default_location
|
|
1298
|
+
resolved_default_location = default_location
|
|
1299
|
+
if not resolved_default_location:
|
|
1300
|
+
# Check tenant profile for default_location
|
|
1301
|
+
config_manager = cli_ctx.get_config_manager()
|
|
1302
|
+
default_tenant_name = config_manager.get_default_tenant()
|
|
1303
|
+
if default_tenant_name:
|
|
1304
|
+
tenant_profile = config_manager.get_tenant(default_tenant_name)
|
|
1305
|
+
if tenant_profile.default_location:
|
|
1306
|
+
resolved_default_location = tenant_profile.default_location
|
|
1307
|
+
if not cli_ctx.json_output:
|
|
1308
|
+
formatter.info(
|
|
1309
|
+
f"Using default location from tenant profile: "
|
|
1310
|
+
f"{resolved_default_location}"
|
|
1311
|
+
)
|
|
1312
|
+
|
|
1313
|
+
# Validate default location if one is resolved and rows need it
|
|
1314
|
+
rows_missing_location = [r for r in rows if not r.location]
|
|
1315
|
+
if resolved_default_location and rows_missing_location:
|
|
1316
|
+
# Validate the default location exists
|
|
1317
|
+
cache_manager = CacheManager()
|
|
1318
|
+
location_service = LocationService(cache_manager)
|
|
1319
|
+
location_service.validate_location(resolved_default_location)
|
|
1320
|
+
|
|
1321
|
+
# Apply default location to rows
|
|
1322
|
+
for row in rows_missing_location:
|
|
1323
|
+
row.location = resolved_default_location
|
|
1324
|
+
|
|
1325
|
+
# Step 2: Validate all rows against Graph API
|
|
1326
|
+
async def _validate(
|
|
1327
|
+
bulk_ops: BulkOperations, config_manager: ConfigManager
|
|
1328
|
+
) -> BulkValidationResult:
|
|
1329
|
+
return await bulk_ops.validate_assign_csv(rows)
|
|
1330
|
+
|
|
1331
|
+
validation_result, _ = _run_bulk_assign_async(ctx, _validate)
|
|
1332
|
+
|
|
1333
|
+
# Step 3: Display validation summary
|
|
1334
|
+
if cli_ctx.json_output:
|
|
1335
|
+
# JSON output for validation results
|
|
1336
|
+
validation_json = {
|
|
1337
|
+
"dry_run": dry_run,
|
|
1338
|
+
"file": str(csv_file),
|
|
1339
|
+
"total_rows": len(validation_result.rows),
|
|
1340
|
+
"valid_count": validation_result.valid_count,
|
|
1341
|
+
"error_count": validation_result.error_count,
|
|
1342
|
+
"warning_count": validation_result.warning_count,
|
|
1343
|
+
"rows": [
|
|
1344
|
+
{
|
|
1345
|
+
"row_number": r.row_number,
|
|
1346
|
+
"user": r.user,
|
|
1347
|
+
"phone_number": r.phone_number,
|
|
1348
|
+
"location": r.location,
|
|
1349
|
+
"errors": r.errors,
|
|
1350
|
+
"warnings": r.warnings,
|
|
1351
|
+
"valid": len(r.errors) == 0,
|
|
1352
|
+
}
|
|
1353
|
+
for r in validation_result.rows
|
|
1354
|
+
],
|
|
1355
|
+
}
|
|
1356
|
+
if dry_run:
|
|
1357
|
+
formatter.json(validation_json)
|
|
1358
|
+
# Exit with code 1 if there are validation errors, 0 otherwise
|
|
1359
|
+
raise typer.Exit(1 if validation_result.error_count > 0 else 0)
|
|
1360
|
+
else:
|
|
1361
|
+
# Human-readable validation summary
|
|
1362
|
+
formatter.info("Validation Summary:")
|
|
1363
|
+
formatter.info(f" File: {csv_file}")
|
|
1364
|
+
formatter.info(f" Total rows: {len(validation_result.rows)}")
|
|
1365
|
+
formatter.info(f" Valid: {validation_result.valid_count}")
|
|
1366
|
+
formatter.info(f" Errors: {validation_result.error_count}")
|
|
1367
|
+
formatter.info(f" Warnings: {validation_result.warning_count}")
|
|
1368
|
+
formatter.info("")
|
|
1369
|
+
|
|
1370
|
+
# Show rows with errors
|
|
1371
|
+
if validation_result.error_count > 0:
|
|
1372
|
+
formatter.info("Validation Errors:")
|
|
1373
|
+
for row in validation_result.rows:
|
|
1374
|
+
if row.errors:
|
|
1375
|
+
error_str = "; ".join(row.errors)
|
|
1376
|
+
formatter.info(
|
|
1377
|
+
f" Row {row.row_number}: {row.user} - {error_str}"
|
|
1378
|
+
)
|
|
1379
|
+
formatter.info("")
|
|
1380
|
+
|
|
1381
|
+
# Show rows with warnings (if any valid rows have warnings)
|
|
1382
|
+
warning_rows = [
|
|
1383
|
+
r for r in validation_result.rows if r.warnings and not r.errors
|
|
1384
|
+
]
|
|
1385
|
+
if warning_rows:
|
|
1386
|
+
formatter.info("Warnings:")
|
|
1387
|
+
for row in warning_rows:
|
|
1388
|
+
warning_str = "; ".join(row.warnings)
|
|
1389
|
+
formatter.info(
|
|
1390
|
+
f" Row {row.row_number}: {row.user} - {warning_str}"
|
|
1391
|
+
)
|
|
1392
|
+
formatter.info("")
|
|
1393
|
+
|
|
1394
|
+
# Step 4: Handle dry-run exit
|
|
1395
|
+
if dry_run:
|
|
1396
|
+
formatter.info("Dry run complete. No changes made.")
|
|
1397
|
+
raise typer.Exit(1 if validation_result.error_count > 0 else 0)
|
|
1398
|
+
|
|
1399
|
+
# Step 5: Handle validation errors without --continue-on-error
|
|
1400
|
+
if validation_result.error_count > 0 and not continue_on_error:
|
|
1401
|
+
raise ValidationError(
|
|
1402
|
+
f"Validation failed for {validation_result.error_count} row(s)",
|
|
1403
|
+
remediation=(
|
|
1404
|
+
"Fix the errors in the CSV file and try again, or use\n"
|
|
1405
|
+
"--continue-on-error to process only valid rows."
|
|
1406
|
+
),
|
|
1407
|
+
)
|
|
1408
|
+
|
|
1409
|
+
# Step 6: Check if there are any valid rows to process
|
|
1410
|
+
if validation_result.valid_count == 0:
|
|
1411
|
+
formatter.info("No valid rows to process")
|
|
1412
|
+
raise typer.Exit(1)
|
|
1413
|
+
|
|
1414
|
+
# Step 7: Confirmation prompt (unless --yes or JSON mode)
|
|
1415
|
+
if not yes and not cli_ctx.json_output:
|
|
1416
|
+
formatter.info(
|
|
1417
|
+
f"Ready to assign {validation_result.valid_count} phone number(s)"
|
|
1418
|
+
)
|
|
1419
|
+
if validation_result.error_count > 0:
|
|
1420
|
+
formatter.info(
|
|
1421
|
+
f" ({validation_result.error_count} row(s) will be skipped due to errors)"
|
|
1422
|
+
)
|
|
1423
|
+
formatter.info("")
|
|
1424
|
+
confirm = typer.confirm("Proceed with bulk assignment?")
|
|
1425
|
+
if not confirm:
|
|
1426
|
+
formatter.info("Bulk assignment cancelled")
|
|
1427
|
+
raise typer.Exit(0)
|
|
1428
|
+
|
|
1429
|
+
# Step 8: Execute bulk assignments with progress bar
|
|
1430
|
+
# Initialize counters and results list
|
|
1431
|
+
success_count = 0
|
|
1432
|
+
fail_count = 0
|
|
1433
|
+
results: list[BulkAssignResult] = []
|
|
1434
|
+
|
|
1435
|
+
# Get valid rows only (rows without errors)
|
|
1436
|
+
valid_rows = [r for r in validation_result.rows if not r.errors]
|
|
1437
|
+
|
|
1438
|
+
# Add skipped results for rows with validation errors
|
|
1439
|
+
for row in validation_result.rows:
|
|
1440
|
+
if row.errors:
|
|
1441
|
+
error_msg = "; ".join(row.errors)
|
|
1442
|
+
results.append(
|
|
1443
|
+
BulkAssignResult(
|
|
1444
|
+
row_number=row.row_number,
|
|
1445
|
+
user=row.user,
|
|
1446
|
+
phone_number=row.phone_number,
|
|
1447
|
+
status="skipped",
|
|
1448
|
+
error=error_msg,
|
|
1449
|
+
)
|
|
1450
|
+
)
|
|
1451
|
+
|
|
1452
|
+
# Execute with progress bar
|
|
1453
|
+
try:
|
|
1454
|
+
total = len(valid_rows)
|
|
1455
|
+
with formatter.progress(total, "Processing") as progress:
|
|
1456
|
+
# Get the task_id (first and only task added by progress context)
|
|
1457
|
+
task_id = list(progress.task_ids)[0]
|
|
1458
|
+
|
|
1459
|
+
async def _execute_with_progress(
|
|
1460
|
+
bulk_ops: BulkOperations, config_manager: ConfigManager
|
|
1461
|
+
) -> list[BulkAssignResult]:
|
|
1462
|
+
nonlocal success_count, fail_count
|
|
1463
|
+
|
|
1464
|
+
execution_results: list[BulkAssignResult] = []
|
|
1465
|
+
|
|
1466
|
+
for idx, row in enumerate(valid_rows, start=1):
|
|
1467
|
+
# Update progress description with current counts
|
|
1468
|
+
desc = f"Processing [{idx}/{total}] OK:{success_count} FAIL:{fail_count}"
|
|
1469
|
+
progress.update(task_id, description=desc)
|
|
1470
|
+
|
|
1471
|
+
try:
|
|
1472
|
+
# Resolve user to get user_id
|
|
1473
|
+
resolved_user = await bulk_ops.user_service.resolve_user(
|
|
1474
|
+
row.user
|
|
1475
|
+
)
|
|
1476
|
+
|
|
1477
|
+
# Get number to determine number_type
|
|
1478
|
+
number_info = await bulk_ops.number_service.get_number(
|
|
1479
|
+
row.phone_number
|
|
1480
|
+
)
|
|
1481
|
+
|
|
1482
|
+
# Resolve location if provided
|
|
1483
|
+
location_id: str | None = None
|
|
1484
|
+
if row.location:
|
|
1485
|
+
resolved_location = (
|
|
1486
|
+
bulk_ops.location_service.validate_location(
|
|
1487
|
+
row.location
|
|
1488
|
+
)
|
|
1489
|
+
)
|
|
1490
|
+
location_id = resolved_location.location_id
|
|
1491
|
+
|
|
1492
|
+
# Execute assignment
|
|
1493
|
+
operation = await bulk_ops.number_service.assign_number(
|
|
1494
|
+
user_id=resolved_user.id,
|
|
1495
|
+
phone_number=row.phone_number,
|
|
1496
|
+
number_type=number_info.number_type,
|
|
1497
|
+
location_id=location_id,
|
|
1498
|
+
wait=True,
|
|
1499
|
+
)
|
|
1500
|
+
|
|
1501
|
+
# Check operation status
|
|
1502
|
+
if operation.status == OperationStatus.SUCCEEDED:
|
|
1503
|
+
success_count += 1
|
|
1504
|
+
execution_results.append(
|
|
1505
|
+
BulkAssignResult(
|
|
1506
|
+
row_number=row.row_number,
|
|
1507
|
+
user=row.user,
|
|
1508
|
+
phone_number=row.phone_number,
|
|
1509
|
+
status="success",
|
|
1510
|
+
)
|
|
1511
|
+
)
|
|
1512
|
+
else:
|
|
1513
|
+
fail_count += 1
|
|
1514
|
+
execution_results.append(
|
|
1515
|
+
BulkAssignResult(
|
|
1516
|
+
row_number=row.row_number,
|
|
1517
|
+
user=row.user,
|
|
1518
|
+
phone_number=row.phone_number,
|
|
1519
|
+
status="failed",
|
|
1520
|
+
error=f"Operation status: {operation.status.value}",
|
|
1521
|
+
)
|
|
1522
|
+
)
|
|
1523
|
+
# Stop on first error if --continue-on-error not set
|
|
1524
|
+
if not continue_on_error:
|
|
1525
|
+
progress.update(task_id, advance=1)
|
|
1526
|
+
break
|
|
1527
|
+
|
|
1528
|
+
except TeamsPhoneError as e:
|
|
1529
|
+
fail_count += 1
|
|
1530
|
+
execution_results.append(
|
|
1531
|
+
BulkAssignResult(
|
|
1532
|
+
row_number=row.row_number,
|
|
1533
|
+
user=row.user,
|
|
1534
|
+
phone_number=row.phone_number,
|
|
1535
|
+
status="failed",
|
|
1536
|
+
error=e.message,
|
|
1537
|
+
)
|
|
1538
|
+
)
|
|
1539
|
+
# Stop on first error if --continue-on-error not set
|
|
1540
|
+
if not continue_on_error:
|
|
1541
|
+
progress.update(task_id, advance=1)
|
|
1542
|
+
break
|
|
1543
|
+
|
|
1544
|
+
# Advance progress bar
|
|
1545
|
+
progress.update(task_id, advance=1)
|
|
1546
|
+
|
|
1547
|
+
return execution_results
|
|
1548
|
+
|
|
1549
|
+
execution_results, _ = _run_bulk_assign_async(
|
|
1550
|
+
ctx, _execute_with_progress
|
|
1551
|
+
)
|
|
1552
|
+
results.extend(execution_results)
|
|
1553
|
+
|
|
1554
|
+
# Mark remaining rows as skipped if stopped early (not continue-on-error)
|
|
1555
|
+
processed_row_numbers = {r.row_number for r in results}
|
|
1556
|
+
for row in valid_rows:
|
|
1557
|
+
if row.row_number not in processed_row_numbers:
|
|
1558
|
+
results.append(
|
|
1559
|
+
BulkAssignResult(
|
|
1560
|
+
row_number=row.row_number,
|
|
1561
|
+
user=row.user,
|
|
1562
|
+
phone_number=row.phone_number,
|
|
1563
|
+
status="skipped",
|
|
1564
|
+
error="Stopped due to previous error",
|
|
1565
|
+
)
|
|
1566
|
+
)
|
|
1567
|
+
|
|
1568
|
+
except KeyboardInterrupt:
|
|
1569
|
+
# Handle Ctrl+C gracefully - add remaining rows as skipped
|
|
1570
|
+
formatter.info("\nInterrupted. Processing partial results...")
|
|
1571
|
+
processed_row_numbers = {r.row_number for r in results}
|
|
1572
|
+
for row in valid_rows:
|
|
1573
|
+
if row.row_number not in processed_row_numbers:
|
|
1574
|
+
results.append(
|
|
1575
|
+
BulkAssignResult(
|
|
1576
|
+
row_number=row.row_number,
|
|
1577
|
+
user=row.user,
|
|
1578
|
+
phone_number=row.phone_number,
|
|
1579
|
+
status="skipped",
|
|
1580
|
+
error="Cancelled by user",
|
|
1581
|
+
)
|
|
1582
|
+
)
|
|
1583
|
+
|
|
1584
|
+
# Step 9: Write results to output file
|
|
1585
|
+
# Determine output path: use --output if provided, else generate timestamp-based name
|
|
1586
|
+
if output:
|
|
1587
|
+
output_path = output
|
|
1588
|
+
else:
|
|
1589
|
+
timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
1590
|
+
output_path = Path(f"bulk-assign-results-{timestamp}.csv")
|
|
1591
|
+
|
|
1592
|
+
# Write results file
|
|
1593
|
+
try:
|
|
1594
|
+
write_results_csv(results, output_path)
|
|
1595
|
+
results_written = True
|
|
1596
|
+
except ConfigurationError as e:
|
|
1597
|
+
# Warn but don't fail if assignments succeeded
|
|
1598
|
+
results_written = False
|
|
1599
|
+
if not cli_ctx.json_output:
|
|
1600
|
+
formatter.warning(f"Could not write results file: {e.message}")
|
|
1601
|
+
|
|
1602
|
+
# Step 10: Display execution summary
|
|
1603
|
+
skipped_count = len(results) - success_count - fail_count
|
|
1604
|
+
|
|
1605
|
+
if cli_ctx.json_output:
|
|
1606
|
+
# JSON output with full results
|
|
1607
|
+
json_output = {
|
|
1608
|
+
"status": "completed",
|
|
1609
|
+
"summary": {
|
|
1610
|
+
"total": len(results),
|
|
1611
|
+
"successful": success_count,
|
|
1612
|
+
"failed": fail_count,
|
|
1613
|
+
"skipped": skipped_count,
|
|
1614
|
+
},
|
|
1615
|
+
"results_file": str(output_path) if results_written else None,
|
|
1616
|
+
"results": [
|
|
1617
|
+
{
|
|
1618
|
+
"row": r.row_number,
|
|
1619
|
+
"user": r.user,
|
|
1620
|
+
"phone_number": r.phone_number,
|
|
1621
|
+
"status": r.status,
|
|
1622
|
+
"error": r.error,
|
|
1623
|
+
}
|
|
1624
|
+
for r in results
|
|
1625
|
+
],
|
|
1626
|
+
}
|
|
1627
|
+
formatter.json(json_output)
|
|
1628
|
+
else:
|
|
1629
|
+
formatter.info("")
|
|
1630
|
+
formatter.info("Execution Summary:")
|
|
1631
|
+
formatter.info(f" Successful: {success_count}")
|
|
1632
|
+
formatter.info(f" Failed: {fail_count}")
|
|
1633
|
+
formatter.info(f" Skipped: {skipped_count}")
|
|
1634
|
+
if results_written:
|
|
1635
|
+
formatter.info(f" Results file: {output_path}")
|
|
1636
|
+
|
|
1637
|
+
# Exit with appropriate code
|
|
1638
|
+
if fail_count > 0:
|
|
1639
|
+
raise typer.Exit(1)
|
|
1640
|
+
raise typer.Exit(0)
|
|
1641
|
+
|
|
1642
|
+
except TeamsPhoneError as e:
|
|
1643
|
+
formatter.error(e.message, remediation=e.remediation)
|
|
1644
|
+
raise typer.Exit(e.exit_code) from None
|