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