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,893 @@
1
+ """Voice policy management commands for Teams Phone CLI.
2
+
3
+ This module provides CLI commands for listing, showing, and assigning
4
+ voice policies from the CSV cache and via Graph API.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ from datetime import datetime
11
+ from pathlib import Path
12
+ from typing import TYPE_CHECKING, Any
13
+
14
+ import typer
15
+
16
+ from teams_phone.cli.context import get_context
17
+ from teams_phone.exceptions import ConfigurationError, TeamsPhoneError, ValidationError
18
+ from teams_phone.infrastructure import CacheManager, GraphClient
19
+ from teams_phone.models import EffectivePolicyAssignment, Policy
20
+ from teams_phone.services import AuthService
21
+ from teams_phone.services.bulk_operations import (
22
+ BulkPolicyResult,
23
+ BulkPolicyRow,
24
+ BulkPolicyValidationResult,
25
+ parse_policy_csv,
26
+ write_policy_results_csv,
27
+ )
28
+ from teams_phone.services.policy_service import PolicyService
29
+ from teams_phone.services.user_service import UserService
30
+
31
+
32
+ if TYPE_CHECKING:
33
+ from teams_phone.infrastructure import OutputFormatter
34
+
35
+ policies_app = typer.Typer(
36
+ name="policies",
37
+ help="Manage voice policies.",
38
+ no_args_is_help=True,
39
+ )
40
+
41
+
42
+ # Mapping from CLI policy type names to Graph API policy type names
43
+ POLICY_TYPE_MAP = {
44
+ "calling": "TeamsCallingPolicy",
45
+ "voicemail": "TeamsVoicemailPolicy",
46
+ "caller-id": "CallingLineIdentity",
47
+ "dial-plan": "TenantDialPlan",
48
+ "voice-routing": "OnlineVoiceRoutingPolicy",
49
+ "emergency": "TeamsEmergencyCallingPolicy",
50
+ "emergency-routing": "TeamsEmergencyCallRoutingPolicy",
51
+ }
52
+
53
+ # Reverse mapping for display
54
+ POLICY_TYPE_DISPLAY = {v: k for k, v in POLICY_TYPE_MAP.items()}
55
+
56
+
57
+ def _resolve_policy_type(policy_type: str | None) -> str | None:
58
+ """Resolve a CLI policy type name to the Graph API policy type name.
59
+
60
+ If policy_type is already a valid API name (e.g., TeamsCallingPolicy),
61
+ returns it as-is. If it's a CLI alias (e.g., calling), maps it to the API name.
62
+ """
63
+ if policy_type is None:
64
+ return None
65
+
66
+ # Check if it's a CLI alias
67
+ policy_type_lower = policy_type.lower()
68
+ if policy_type_lower in POLICY_TYPE_MAP:
69
+ return POLICY_TYPE_MAP[policy_type_lower]
70
+
71
+ # Check if it's already a valid API name (case-insensitive)
72
+ for api_name in POLICY_TYPE_MAP.values():
73
+ if policy_type_lower == api_name.lower():
74
+ return api_name
75
+
76
+ # Return as-is for unknown types (service will handle validation)
77
+ return policy_type
78
+
79
+
80
+ def _format_is_global(is_global: bool) -> str:
81
+ """Format is_global status for display."""
82
+ return "Yes" if is_global else "No"
83
+
84
+
85
+ def _format_assignment_type(assignment_type: str) -> str:
86
+ """Format assignment type for display."""
87
+ display_names = {
88
+ "direct": "Direct",
89
+ "group": "Group",
90
+ "global": "Global (Default)",
91
+ }
92
+ return display_names.get(assignment_type.lower(), assignment_type)
93
+
94
+
95
+ def _format_policy_type_display(policy_type: str) -> str:
96
+ """Format policy type for CLI display, using short names when available."""
97
+ return POLICY_TYPE_DISPLAY.get(policy_type, policy_type)
98
+
99
+
100
+ def _policy_to_list_dict(policy: Policy) -> dict[str, Any]:
101
+ """Convert Policy to dict for list table display."""
102
+ return {
103
+ "policy_type": _format_policy_type_display(policy.policy_type),
104
+ "policy_name": policy.policy_name,
105
+ "description": policy.description or "—",
106
+ "is_global": _format_is_global(policy.is_global),
107
+ }
108
+
109
+
110
+ def _policy_to_json_dict(policy: Policy) -> dict[str, Any]:
111
+ """Convert Policy to dict for JSON output."""
112
+ return {
113
+ "policy_type": policy.policy_type,
114
+ "policy_name": policy.policy_name,
115
+ "policy_id": policy.policy_id,
116
+ "description": policy.description,
117
+ "is_global": policy.is_global,
118
+ }
119
+
120
+
121
+ def _assignment_to_list_dict(assignment: EffectivePolicyAssignment) -> dict[str, Any]:
122
+ """Convert EffectivePolicyAssignment to dict for show table display."""
123
+ return {
124
+ "policy_type": _format_policy_type_display(assignment.policy_type),
125
+ "policy_name": assignment.policy_assignment.display_name or "Global (Default)",
126
+ "assignment_type": _format_assignment_type(
127
+ assignment.policy_assignment.assignment_type
128
+ ),
129
+ }
130
+
131
+
132
+ def _assignment_to_json_dict(assignment: EffectivePolicyAssignment) -> dict[str, Any]:
133
+ """Convert EffectivePolicyAssignment to dict for JSON output."""
134
+ return {
135
+ "policy_type": assignment.policy_type,
136
+ "policy_name": assignment.policy_assignment.display_name,
137
+ "assignment_type": assignment.policy_assignment.assignment_type,
138
+ "policy_id": assignment.policy_assignment.policy_id,
139
+ "group_id": assignment.policy_assignment.group_id,
140
+ }
141
+
142
+
143
+ def _get_sync_service() -> PolicyService:
144
+ """Create a PolicyService instance for sync-only operations (list).
145
+
146
+ Note: This creates a service with a MagicMock graph_client since list_policies
147
+ only uses the cache_manager. For async operations, use _run_async instead.
148
+ """
149
+ from unittest.mock import MagicMock
150
+
151
+ cache_manager = CacheManager()
152
+ mock_graph = MagicMock()
153
+ return PolicyService(mock_graph, cache_manager)
154
+
155
+
156
+ def _check_staleness(service: PolicyService, formatter: OutputFormatter) -> None:
157
+ """Check for staleness warning and display if needed."""
158
+ staleness_warning = service.get_staleness_warning()
159
+ if staleness_warning:
160
+ formatter.warning(staleness_warning)
161
+
162
+
163
+ def _run_async(
164
+ ctx: typer.Context,
165
+ coro_factory: Any,
166
+ ) -> tuple[Any, OutputFormatter]:
167
+ """Run an async operation with PolicyService and UserService.
168
+
169
+ Creates the required infrastructure (GraphClient, services) and runs
170
+ the async operation using asyncio.run().
171
+
172
+ Args:
173
+ ctx: Typer context with CLIContext.
174
+ coro_factory: A callable that takes (PolicyService, UserService)
175
+ and returns a coroutine.
176
+
177
+ Returns:
178
+ Tuple of (result, OutputFormatter) where result is the coroutine's return value.
179
+ """
180
+ cli_ctx = get_context(ctx)
181
+ formatter = cli_ctx.get_output_formatter()
182
+ config_manager = cli_ctx.get_config_manager()
183
+ cache_manager = CacheManager()
184
+ auth_service = AuthService(config_manager, cache_manager)
185
+
186
+ async def _execute() -> Any:
187
+ async with GraphClient(auth_service) as graph_client:
188
+ policy_service = PolicyService(graph_client, cache_manager)
189
+ user_service = UserService(graph_client)
190
+ return await coro_factory(policy_service, user_service)
191
+
192
+ result = asyncio.run(_execute())
193
+ return result, formatter
194
+
195
+
196
+ @policies_app.command("list")
197
+ def list_policies(
198
+ ctx: typer.Context,
199
+ policy_type: str | None = typer.Argument(
200
+ None,
201
+ help="Filter by policy type: calling, voicemail, emergency, emergency-routing, caller-id, dial-plan, voice-routing.",
202
+ ),
203
+ ) -> None:
204
+ """List available voice policies from CSV cache.
205
+
206
+ Shows all voice policies exported via PowerShell, optionally filtered by type.
207
+ Use the policy type argument to filter by a specific policy type.
208
+
209
+ Examples:
210
+
211
+ teams-phone policies list
212
+
213
+ teams-phone policies list calling
214
+
215
+ teams-phone policies list emergency --json
216
+ """
217
+ cli_ctx = get_context(ctx)
218
+ formatter = cli_ctx.get_output_formatter()
219
+
220
+ try:
221
+ service = _get_sync_service()
222
+
223
+ # Check for staleness before output
224
+ _check_staleness(service, formatter)
225
+
226
+ # Resolve policy type (CLI alias to API name)
227
+ resolved_type = _resolve_policy_type(policy_type)
228
+ policies = service.list_policies(policy_type=resolved_type)
229
+
230
+ if cli_ctx.json_output:
231
+ formatter.json([_policy_to_json_dict(p) for p in policies])
232
+ else:
233
+ if not policies:
234
+ if policy_type:
235
+ formatter.info(f"No policies found for type '{policy_type}'")
236
+ else:
237
+ formatter.info("No policies found")
238
+ else:
239
+ title = "Voice Policies"
240
+ if policy_type:
241
+ title = f"Voice Policies ({policy_type})"
242
+ formatter.table(
243
+ data=[_policy_to_list_dict(p) for p in policies],
244
+ columns=[
245
+ "policy_type",
246
+ "policy_name",
247
+ "description",
248
+ "is_global",
249
+ ],
250
+ title=title,
251
+ )
252
+ except TeamsPhoneError as e:
253
+ formatter.error(e.message, remediation=e.remediation)
254
+ raise typer.Exit(e.exit_code) from None
255
+
256
+
257
+ @policies_app.command("show")
258
+ def show_user_policies(
259
+ ctx: typer.Context,
260
+ user: str = typer.Argument(
261
+ ...,
262
+ help="User identifier: UPN (email), display name, or object ID.",
263
+ ),
264
+ ) -> None:
265
+ """Show all policy assignments for a user.
266
+
267
+ Displays the effective policies assigned to a user, including
268
+ how each policy was assigned (Direct, Group, or Global).
269
+
270
+ Examples:
271
+
272
+ teams-phone policies show john.doe@contoso.com
273
+
274
+ teams-phone policies show "John Doe"
275
+
276
+ teams-phone policies show 12345678-1234-1234-1234-123456789012
277
+ """
278
+ cli_ctx = get_context(ctx)
279
+ formatter = cli_ctx.get_output_formatter()
280
+
281
+ try:
282
+
283
+ async def _get_policies(
284
+ policy_service: PolicyService, user_service: UserService
285
+ ) -> tuple[list[EffectivePolicyAssignment], str, str]:
286
+ # Resolve user to get user_id
287
+ user_config = await user_service.resolve_user(user)
288
+ # Get user's effective policy assignments
289
+ assignments = await policy_service.get_user_policies(user_config.id)
290
+ return assignments, user_config.display_name or user, user_config.id
291
+
292
+ (assignments, display_name, user_id), _ = _run_async(ctx, _get_policies)
293
+
294
+ if cli_ctx.json_output:
295
+ formatter.json(
296
+ {
297
+ "user_id": user_id,
298
+ "display_name": display_name,
299
+ "policies": [_assignment_to_json_dict(a) for a in assignments],
300
+ }
301
+ )
302
+ else:
303
+ formatter.info(f"Policy Assignments for {display_name}")
304
+ formatter.info("")
305
+
306
+ if not assignments:
307
+ formatter.info("No policy assignments found")
308
+ else:
309
+ formatter.table(
310
+ data=[_assignment_to_list_dict(a) for a in assignments],
311
+ columns=[
312
+ "policy_type",
313
+ "policy_name",
314
+ "assignment_type",
315
+ ],
316
+ title="Effective Policies",
317
+ )
318
+ except TeamsPhoneError as e:
319
+ formatter.error(e.message, remediation=e.remediation)
320
+ raise typer.Exit(e.exit_code) from None
321
+
322
+
323
+ @policies_app.command("assign")
324
+ def assign_policy(
325
+ ctx: typer.Context,
326
+ user: str = typer.Argument(
327
+ ...,
328
+ help="User identifier: UPN (email), display name, or object ID.",
329
+ ),
330
+ policy_type: str = typer.Argument(
331
+ ...,
332
+ help="Policy type: calling, voicemail, emergency, emergency-routing, caller-id, dial-plan, voice-routing.",
333
+ ),
334
+ policy_name: str = typer.Argument(
335
+ ...,
336
+ help="Name of the policy to assign (use 'Global' for default).",
337
+ ),
338
+ dry_run: bool = typer.Option(
339
+ False,
340
+ "--dry-run",
341
+ help="Preview assignment without making changes.",
342
+ ),
343
+ force: bool = typer.Option(
344
+ False,
345
+ "--force",
346
+ "-f",
347
+ help="Skip confirmation prompt.",
348
+ ),
349
+ ) -> None:
350
+ """Assign a policy to a user.
351
+
352
+ Validates the policy exists and assigns it to the specified user.
353
+ Use --dry-run to preview the assignment without making changes.
354
+
355
+ Examples:
356
+
357
+ teams-phone policies assign john@contoso.com calling AllowCalling
358
+
359
+ teams-phone policies assign "John Doe" emergency "High Priority" --dry-run
360
+
361
+ teams-phone policies assign john@contoso.com dial-plan Global --force
362
+ """
363
+ cli_ctx = get_context(ctx)
364
+ formatter = cli_ctx.get_output_formatter()
365
+
366
+ # Resolve policy type from CLI alias to API name
367
+ resolved_type = _resolve_policy_type(policy_type)
368
+ if resolved_type is None:
369
+ formatter.error(
370
+ f"Invalid policy type: {policy_type}",
371
+ remediation=(
372
+ "Valid policy types are: calling, voicemail, emergency, "
373
+ "emergency-routing, caller-id, dial-plan, voice-routing"
374
+ ),
375
+ )
376
+ raise typer.Exit(5) # ValidationError exit code
377
+
378
+ try:
379
+ # First resolve the user
380
+ async def _resolve_user(
381
+ policy_service: PolicyService, user_service: UserService
382
+ ) -> tuple[str, str, str]:
383
+ user_config = await user_service.resolve_user(user)
384
+ return (
385
+ user_config.id,
386
+ user_config.display_name or user,
387
+ user_config.user_principal_name,
388
+ )
389
+
390
+ (user_id, display_name, upn), _ = _run_async(ctx, _resolve_user)
391
+
392
+ # Validate policy exists in cache (for dry-run and confirmation)
393
+ sync_service = _get_sync_service()
394
+ policy = sync_service.validate_policy(resolved_type, policy_name)
395
+
396
+ # Handle dry-run output
397
+ if dry_run:
398
+ if cli_ctx.json_output:
399
+ formatter.json(
400
+ {
401
+ "dry_run": True,
402
+ "user_id": user_id,
403
+ "user_principal_name": upn,
404
+ "display_name": display_name,
405
+ "policy_type": resolved_type,
406
+ "policy_name": policy_name,
407
+ "policy_id": policy.policy_id,
408
+ }
409
+ )
410
+ else:
411
+ formatter.info("Dry Run Preview:")
412
+ formatter.info(f" User: {display_name}")
413
+ formatter.info(f" UPN: {upn}")
414
+ formatter.info(
415
+ f" Policy Type: {_format_policy_type_display(resolved_type)}"
416
+ )
417
+ formatter.info(f" Policy Name: {policy_name}")
418
+ formatter.info("")
419
+ formatter.info("No changes made (dry run)")
420
+ raise typer.Exit(0)
421
+
422
+ # Confirm before executing (unless --force or --json)
423
+ if not force and not cli_ctx.json_output:
424
+ formatter.info("Assignment Details:")
425
+ formatter.info(f" User: {display_name}")
426
+ formatter.info(
427
+ f" Policy Type: {_format_policy_type_display(resolved_type)}"
428
+ )
429
+ formatter.info(f" Policy Name: {policy_name}")
430
+ formatter.info("")
431
+ confirm = typer.confirm("Proceed with assignment?")
432
+ if not confirm:
433
+ formatter.info("Assignment cancelled")
434
+ raise typer.Exit(0)
435
+
436
+ # Execute assignment
437
+ async def _assign(
438
+ policy_service: PolicyService, user_service: UserService
439
+ ) -> None:
440
+ await policy_service.assign_policy(user_id, resolved_type, policy_name)
441
+
442
+ _run_async(ctx, _assign)
443
+
444
+ # Success output
445
+ if cli_ctx.json_output:
446
+ formatter.json(
447
+ {
448
+ "success": True,
449
+ "user_id": user_id,
450
+ "user_principal_name": upn,
451
+ "display_name": display_name,
452
+ "policy_type": resolved_type,
453
+ "policy_name": policy_name,
454
+ }
455
+ )
456
+ else:
457
+ formatter.success(
458
+ f"Assigned {_format_policy_type_display(resolved_type)} policy "
459
+ f"'{policy_name}' to {display_name}"
460
+ )
461
+
462
+ except TeamsPhoneError as e:
463
+ formatter.error(e.message, remediation=e.remediation)
464
+ raise typer.Exit(e.exit_code) from None
465
+
466
+
467
+ def _validate_policy_rows(
468
+ rows: list[BulkPolicyRow],
469
+ sync_service: PolicyService,
470
+ ) -> BulkPolicyValidationResult:
471
+ """Validate parsed policy CSV rows against the policy cache.
472
+
473
+ Performs validation on each row:
474
+ - Resolves policy type from CLI alias to API name
475
+ - Rejects TeamsVoicemailPolicy (not supported for batch assignment)
476
+ - Validates policy exists in cache
477
+
478
+ Args:
479
+ rows: List of BulkPolicyRow objects to validate.
480
+ sync_service: PolicyService instance for cache-only validation.
481
+
482
+ Returns:
483
+ BulkPolicyValidationResult with validated rows and counts.
484
+ """
485
+ # Build set of valid API names for type validation
486
+ valid_api_names = set(POLICY_TYPE_MAP.values())
487
+
488
+ for row in rows:
489
+ # Skip validation if row already has errors from parsing
490
+ if row.errors:
491
+ continue
492
+
493
+ # Resolve policy type (CLI alias to API name)
494
+ resolved_type = _resolve_policy_type(row.policy_type)
495
+
496
+ # Check if the resolved type is a known API name
497
+ if resolved_type is None or resolved_type not in valid_api_names:
498
+ row.errors.append(
499
+ f"Invalid policy type: '{row.policy_type}'. "
500
+ "Valid types: calling, voicemail, emergency, emergency-routing, "
501
+ "caller-id, dial-plan, voice-routing"
502
+ )
503
+ continue
504
+
505
+ # Update the row with resolved policy type
506
+ row.policy_type = resolved_type
507
+
508
+ # Check for unsupported TeamsVoicemailPolicy
509
+ if resolved_type == "TeamsVoicemailPolicy":
510
+ row.errors.append(
511
+ "TeamsVoicemailPolicy is not supported for batch assignment. "
512
+ "Use PowerShell cmdlet Grant-CsTeamsVoicemailPolicy instead."
513
+ )
514
+ continue
515
+
516
+ # Validate policy exists in cache
517
+ try:
518
+ sync_service.validate_policy(resolved_type, row.policy_name)
519
+ except TeamsPhoneError as e:
520
+ row.errors.append(e.message)
521
+
522
+ # Compute counts
523
+ valid_count = sum(1 for r in rows if not r.errors)
524
+ error_count = sum(1 for r in rows if r.errors)
525
+ warning_count = sum(1 for r in rows if r.warnings)
526
+
527
+ return BulkPolicyValidationResult(
528
+ rows=rows,
529
+ valid_count=valid_count,
530
+ error_count=error_count,
531
+ warning_count=warning_count,
532
+ )
533
+
534
+
535
+ @policies_app.command("bulk-assign")
536
+ def bulk_assign( # noqa: C901 - Bulk operation with validation, dry-run, progress, and error handling
537
+ ctx: typer.Context,
538
+ csv_file: Path = typer.Argument(
539
+ ...,
540
+ help="Path to CSV file with user, policy_type, and policy_name columns.",
541
+ exists=True,
542
+ readable=True,
543
+ ),
544
+ dry_run: bool = typer.Option(
545
+ False,
546
+ "--dry-run",
547
+ help="Validate and preview all assignments without executing.",
548
+ ),
549
+ continue_on_error: bool = typer.Option(
550
+ False,
551
+ "--continue-on-error",
552
+ help="Continue processing after individual failures.",
553
+ ),
554
+ output: Path | None = typer.Option(
555
+ None,
556
+ "--output",
557
+ "-o",
558
+ help="Path to write results CSV file.",
559
+ ),
560
+ yes: bool = typer.Option(
561
+ False,
562
+ "--yes",
563
+ "-y",
564
+ help="Skip confirmation prompt.",
565
+ ),
566
+ ) -> None:
567
+ """Bulk assign policies from a CSV file.
568
+
569
+ Reads a CSV file containing user, policy_type, and policy_name columns.
570
+ Validates all rows against the policy cache, displays a summary, and executes
571
+ assignments (unless --dry-run is specified).
572
+
573
+ CSV format:
574
+ user,policy_type,policy_name
575
+ john@contoso.com,calling,AllowCalling
576
+ jane@contoso.com,dial-plan,US-DialPlan
577
+
578
+ Examples:
579
+ # Preview bulk assignment (validation only)
580
+ teams-phone policies bulk-assign assignments.csv --dry-run
581
+
582
+ # Execute bulk assignment
583
+ teams-phone policies bulk-assign assignments.csv
584
+
585
+ # Continue on errors and save results to file
586
+ teams-phone policies bulk-assign assignments.csv --continue-on-error -o results.csv
587
+ """
588
+ cli_ctx = get_context(ctx)
589
+ formatter = cli_ctx.get_output_formatter()
590
+
591
+ try:
592
+ # Step 1: Parse CSV file
593
+ rows = parse_policy_csv(csv_file)
594
+
595
+ if not rows:
596
+ if cli_ctx.json_output:
597
+ formatter.json(
598
+ {
599
+ "status": "no_rows",
600
+ "message": "No data rows found in CSV file",
601
+ "file": str(csv_file),
602
+ }
603
+ )
604
+ else:
605
+ formatter.info("No data rows found in CSV file")
606
+ raise typer.Exit(0)
607
+
608
+ # Step 2: Validate all rows against policy cache
609
+ sync_service = _get_sync_service()
610
+
611
+ # Check for staleness before validation
612
+ _check_staleness(sync_service, formatter)
613
+
614
+ validation_result = _validate_policy_rows(rows, sync_service)
615
+
616
+ # Step 3: Display validation summary
617
+ if cli_ctx.json_output:
618
+ # JSON output for validation results
619
+ validation_json = {
620
+ "dry_run": dry_run,
621
+ "file": str(csv_file),
622
+ "total_rows": len(validation_result.rows),
623
+ "valid_count": validation_result.valid_count,
624
+ "error_count": validation_result.error_count,
625
+ "warning_count": validation_result.warning_count,
626
+ "rows": [
627
+ {
628
+ "row_number": r.row_number,
629
+ "user": r.user,
630
+ "policy_type": r.policy_type,
631
+ "policy_name": r.policy_name,
632
+ "errors": r.errors,
633
+ "warnings": r.warnings,
634
+ "valid": len(r.errors) == 0,
635
+ }
636
+ for r in validation_result.rows
637
+ ],
638
+ }
639
+ if dry_run:
640
+ formatter.json(validation_json)
641
+ # Exit with code 1 if there are validation errors, 0 otherwise
642
+ raise typer.Exit(1 if validation_result.error_count > 0 else 0)
643
+ else:
644
+ # Human-readable validation summary
645
+ formatter.info("Validation Summary:")
646
+ formatter.info(f" File: {csv_file}")
647
+ formatter.info(f" Total rows: {len(validation_result.rows)}")
648
+ formatter.info(f" Valid: {validation_result.valid_count}")
649
+ formatter.info(f" Errors: {validation_result.error_count}")
650
+ formatter.info(f" Warnings: {validation_result.warning_count}")
651
+ formatter.info("")
652
+
653
+ # Show rows with errors
654
+ if validation_result.error_count > 0:
655
+ formatter.info("Validation Errors:")
656
+ for row in validation_result.rows:
657
+ if row.errors:
658
+ error_str = "; ".join(row.errors)
659
+ formatter.info(
660
+ f" Row {row.row_number}: {row.user} - {error_str}"
661
+ )
662
+ formatter.info("")
663
+
664
+ # Show rows with warnings (if any valid rows have warnings)
665
+ warning_rows = [
666
+ r for r in validation_result.rows if r.warnings and not r.errors
667
+ ]
668
+ if warning_rows:
669
+ formatter.info("Warnings:")
670
+ for row in warning_rows:
671
+ warning_str = "; ".join(row.warnings)
672
+ formatter.info(
673
+ f" Row {row.row_number}: {row.user} - {warning_str}"
674
+ )
675
+ formatter.info("")
676
+
677
+ # Step 4: Handle dry-run exit
678
+ if dry_run:
679
+ formatter.info("Dry run complete. No changes made.")
680
+ raise typer.Exit(1 if validation_result.error_count > 0 else 0)
681
+
682
+ # Step 5: Handle validation errors without --continue-on-error
683
+ if validation_result.error_count > 0 and not continue_on_error:
684
+ raise ValidationError(
685
+ f"Validation failed for {validation_result.error_count} row(s)",
686
+ remediation=(
687
+ "Fix the errors in the CSV file and try again, or use\n"
688
+ "--continue-on-error to process only valid rows."
689
+ ),
690
+ )
691
+
692
+ # Step 6: Check if there are any valid rows to process
693
+ if validation_result.valid_count == 0:
694
+ formatter.info("No valid rows to process")
695
+ raise typer.Exit(1)
696
+
697
+ # Step 7: Confirmation prompt (unless --yes or JSON mode)
698
+ if not yes and not cli_ctx.json_output:
699
+ formatter.info(
700
+ f"Ready to assign {validation_result.valid_count} policy/policies"
701
+ )
702
+ if validation_result.error_count > 0:
703
+ formatter.info(
704
+ f" ({validation_result.error_count} row(s) will be skipped due to errors)"
705
+ )
706
+ formatter.info("")
707
+ confirm = typer.confirm("Proceed with bulk assignment?")
708
+ if not confirm:
709
+ formatter.info("Bulk assignment cancelled")
710
+ raise typer.Exit(0)
711
+
712
+ # Step 8: Execute bulk assignments with progress bar
713
+ # Initialize counters and results list
714
+ success_count = 0
715
+ fail_count = 0
716
+ results: list[BulkPolicyResult] = []
717
+
718
+ # Get valid rows only (rows without errors)
719
+ valid_rows = [r for r in validation_result.rows if not r.errors]
720
+
721
+ # Add skipped results for rows with validation errors
722
+ for row in validation_result.rows:
723
+ if row.errors:
724
+ error_msg = "; ".join(row.errors)
725
+ results.append(
726
+ BulkPolicyResult(
727
+ row_number=row.row_number,
728
+ user=row.user,
729
+ policy_type=row.policy_type,
730
+ policy_name=row.policy_name,
731
+ status="skipped",
732
+ error=error_msg,
733
+ )
734
+ )
735
+
736
+ # Execute with progress bar
737
+ try:
738
+ total = len(valid_rows)
739
+ with formatter.progress(total, "Processing") as progress:
740
+ # Get the task_id (first and only task added by progress context)
741
+ task_id = list(progress.task_ids)[0]
742
+
743
+ async def _execute_with_progress(
744
+ policy_service: PolicyService, user_service: UserService
745
+ ) -> list[BulkPolicyResult]:
746
+ nonlocal success_count, fail_count
747
+
748
+ execution_results: list[BulkPolicyResult] = []
749
+
750
+ for idx, row in enumerate(valid_rows, start=1):
751
+ # Update progress description with current counts
752
+ desc = f"Processing [{idx}/{total}] OK:{success_count} FAIL:{fail_count}"
753
+ progress.update(task_id, description=desc)
754
+
755
+ try:
756
+ # Resolve user to get user_id
757
+ resolved_user = await user_service.resolve_user(row.user)
758
+
759
+ # Assign policy (synchronous - no polling needed)
760
+ await policy_service.assign_policy(
761
+ resolved_user.id, row.policy_type, row.policy_name
762
+ )
763
+
764
+ success_count += 1
765
+ execution_results.append(
766
+ BulkPolicyResult(
767
+ row_number=row.row_number,
768
+ user=row.user,
769
+ policy_type=row.policy_type,
770
+ policy_name=row.policy_name,
771
+ status="success",
772
+ )
773
+ )
774
+
775
+ except TeamsPhoneError as e:
776
+ fail_count += 1
777
+ execution_results.append(
778
+ BulkPolicyResult(
779
+ row_number=row.row_number,
780
+ user=row.user,
781
+ policy_type=row.policy_type,
782
+ policy_name=row.policy_name,
783
+ status="failed",
784
+ error=e.message,
785
+ )
786
+ )
787
+ # Stop on first error if --continue-on-error not set
788
+ if not continue_on_error:
789
+ progress.update(task_id, advance=1)
790
+ break
791
+
792
+ # Advance progress bar
793
+ progress.update(task_id, advance=1)
794
+
795
+ return execution_results
796
+
797
+ execution_results, _ = _run_async(ctx, _execute_with_progress)
798
+ results.extend(execution_results)
799
+
800
+ # Mark remaining rows as skipped if stopped early (not continue-on-error)
801
+ processed_row_numbers = {r.row_number for r in results}
802
+ for row in valid_rows:
803
+ if row.row_number not in processed_row_numbers:
804
+ results.append(
805
+ BulkPolicyResult(
806
+ row_number=row.row_number,
807
+ user=row.user,
808
+ policy_type=row.policy_type,
809
+ policy_name=row.policy_name,
810
+ status="skipped",
811
+ error="Stopped due to previous error",
812
+ )
813
+ )
814
+
815
+ except KeyboardInterrupt:
816
+ # Handle Ctrl+C gracefully - add remaining rows as skipped
817
+ formatter.info("\nInterrupted. Processing partial results...")
818
+ processed_row_numbers = {r.row_number for r in results}
819
+ for row in valid_rows:
820
+ if row.row_number not in processed_row_numbers:
821
+ results.append(
822
+ BulkPolicyResult(
823
+ row_number=row.row_number,
824
+ user=row.user,
825
+ policy_type=row.policy_type,
826
+ policy_name=row.policy_name,
827
+ status="skipped",
828
+ error="Cancelled by user",
829
+ )
830
+ )
831
+
832
+ # Step 9: Write results to output file
833
+ # Determine output path: use --output if provided, else generate timestamp-based name
834
+ if output:
835
+ output_path = output
836
+ else:
837
+ timestamp = datetime.now().strftime("%Y%m%d-%H%M%S")
838
+ output_path = Path(f"bulk-policy-results-{timestamp}.csv")
839
+
840
+ # Write results file
841
+ try:
842
+ write_policy_results_csv(results, output_path)
843
+ results_written = True
844
+ except ConfigurationError as e:
845
+ # Warn but don't fail if assignments succeeded
846
+ results_written = False
847
+ if not cli_ctx.json_output:
848
+ formatter.warning(f"Could not write results file: {e.message}")
849
+
850
+ # Step 10: Display execution summary
851
+ skipped_count = len(results) - success_count - fail_count
852
+
853
+ if cli_ctx.json_output:
854
+ # JSON output with full results
855
+ json_output = {
856
+ "status": "completed",
857
+ "summary": {
858
+ "total": len(results),
859
+ "successful": success_count,
860
+ "failed": fail_count,
861
+ "skipped": skipped_count,
862
+ },
863
+ "results_file": str(output_path) if results_written else None,
864
+ "results": [
865
+ {
866
+ "row": r.row_number,
867
+ "user": r.user,
868
+ "policy_type": r.policy_type,
869
+ "policy_name": r.policy_name,
870
+ "status": r.status,
871
+ "error": r.error,
872
+ }
873
+ for r in results
874
+ ],
875
+ }
876
+ formatter.json(json_output)
877
+ else:
878
+ formatter.info("")
879
+ formatter.info("Execution Summary:")
880
+ formatter.info(f" Successful: {success_count}")
881
+ formatter.info(f" Failed: {fail_count}")
882
+ formatter.info(f" Skipped: {skipped_count}")
883
+ if results_written:
884
+ formatter.info(f" Results file: {output_path}")
885
+
886
+ # Exit with appropriate code
887
+ if fail_count > 0:
888
+ raise typer.Exit(1)
889
+ raise typer.Exit(0)
890
+
891
+ except TeamsPhoneError as e:
892
+ formatter.error(e.message, remediation=e.remediation)
893
+ raise typer.Exit(e.exit_code) from None