teams-phone-cli 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. teams_phone/__init__.py +3 -0
  2. teams_phone/__main__.py +7 -0
  3. teams_phone/cli/__init__.py +8 -0
  4. teams_phone/cli/api_check.py +267 -0
  5. teams_phone/cli/auth.py +201 -0
  6. teams_phone/cli/context.py +108 -0
  7. teams_phone/cli/helpers.py +65 -0
  8. teams_phone/cli/locations.py +308 -0
  9. teams_phone/cli/main.py +99 -0
  10. teams_phone/cli/numbers.py +1644 -0
  11. teams_phone/cli/policies.py +893 -0
  12. teams_phone/cli/tenants.py +364 -0
  13. teams_phone/cli/users.py +394 -0
  14. teams_phone/constants.py +97 -0
  15. teams_phone/exceptions.py +137 -0
  16. teams_phone/infrastructure/__init__.py +22 -0
  17. teams_phone/infrastructure/cache_manager.py +274 -0
  18. teams_phone/infrastructure/config_manager.py +209 -0
  19. teams_phone/infrastructure/debug_logger.py +321 -0
  20. teams_phone/infrastructure/graph_client.py +666 -0
  21. teams_phone/infrastructure/output_formatter.py +234 -0
  22. teams_phone/models/__init__.py +76 -0
  23. teams_phone/models/api_responses.py +69 -0
  24. teams_phone/models/auth.py +100 -0
  25. teams_phone/models/cache.py +25 -0
  26. teams_phone/models/config.py +66 -0
  27. teams_phone/models/location.py +36 -0
  28. teams_phone/models/number.py +184 -0
  29. teams_phone/models/policy.py +26 -0
  30. teams_phone/models/tenant.py +45 -0
  31. teams_phone/models/user.py +117 -0
  32. teams_phone/services/__init__.py +21 -0
  33. teams_phone/services/auth_service.py +536 -0
  34. teams_phone/services/bulk_operations.py +562 -0
  35. teams_phone/services/location_service.py +195 -0
  36. teams_phone/services/number_service.py +489 -0
  37. teams_phone/services/policy_service.py +330 -0
  38. teams_phone/services/tenant_service.py +205 -0
  39. teams_phone/services/user_service.py +435 -0
  40. teams_phone/utils.py +172 -0
  41. teams_phone_cli-0.1.2.dist-info/METADATA +15 -0
  42. teams_phone_cli-0.1.2.dist-info/RECORD +45 -0
  43. teams_phone_cli-0.1.2.dist-info/WHEEL +4 -0
  44. teams_phone_cli-0.1.2.dist-info/entry_points.txt +2 -0
  45. teams_phone_cli-0.1.2.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,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