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,364 @@
|
|
|
1
|
+
"""Tenant management commands for Teams Phone CLI.
|
|
2
|
+
|
|
3
|
+
This module provides CLI commands for managing multi-tenant configurations,
|
|
4
|
+
including listing, adding, removing, switching, and testing tenant profiles.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
from uuid import UUID
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from teams_phone.infrastructure import OutputFormatter
|
|
15
|
+
|
|
16
|
+
import typer
|
|
17
|
+
|
|
18
|
+
from teams_phone.cli.context import get_context
|
|
19
|
+
from teams_phone.exceptions import TeamsPhoneError
|
|
20
|
+
from teams_phone.infrastructure import CacheManager
|
|
21
|
+
from teams_phone.models import AuthMethod, TenantProfile
|
|
22
|
+
from teams_phone.services import AuthService, TenantService
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
tenants_app = typer.Typer(
|
|
26
|
+
name="tenants",
|
|
27
|
+
help="Manage tenant profiles.",
|
|
28
|
+
no_args_is_help=True,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _create_tenant_service(ctx: typer.Context) -> tuple[TenantService, OutputFormatter]:
|
|
33
|
+
"""Create TenantService and return it with the formatter.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
ctx: Typer context with CLIContext.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
Tuple of (TenantService, OutputFormatter).
|
|
40
|
+
"""
|
|
41
|
+
cli_ctx = get_context(ctx)
|
|
42
|
+
formatter = cli_ctx.get_output_formatter()
|
|
43
|
+
config_manager = cli_ctx.get_config_manager()
|
|
44
|
+
cache_manager = CacheManager()
|
|
45
|
+
auth_service = AuthService(config_manager, cache_manager)
|
|
46
|
+
tenant_service = TenantService(config_manager, auth_service)
|
|
47
|
+
return tenant_service, formatter
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@tenants_app.command("list")
|
|
51
|
+
def list_tenants(ctx: typer.Context) -> None:
|
|
52
|
+
"""List all configured tenant profiles."""
|
|
53
|
+
cli_ctx = get_context(ctx)
|
|
54
|
+
formatter = cli_ctx.get_output_formatter()
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
tenant_service, _ = _create_tenant_service(ctx)
|
|
58
|
+
tenants = tenant_service.list_tenants()
|
|
59
|
+
|
|
60
|
+
# Get current tenant name for marking "Current" status
|
|
61
|
+
try:
|
|
62
|
+
current = tenant_service.get_current_tenant()
|
|
63
|
+
current_name = current.name
|
|
64
|
+
except TeamsPhoneError:
|
|
65
|
+
current_name = None
|
|
66
|
+
|
|
67
|
+
if cli_ctx.json_output:
|
|
68
|
+
formatter.json(
|
|
69
|
+
[
|
|
70
|
+
{
|
|
71
|
+
"name": t.name,
|
|
72
|
+
"display_name": t.display_name,
|
|
73
|
+
"tenant_id": str(t.tenant_id),
|
|
74
|
+
"auth_method": t.auth_method.value,
|
|
75
|
+
"is_current": t.name == current_name,
|
|
76
|
+
}
|
|
77
|
+
for t in tenants
|
|
78
|
+
]
|
|
79
|
+
)
|
|
80
|
+
else:
|
|
81
|
+
if not tenants:
|
|
82
|
+
formatter.info("No tenants configured")
|
|
83
|
+
formatter.info("Add a tenant with: teams-phone tenants add")
|
|
84
|
+
else:
|
|
85
|
+
formatter.table(
|
|
86
|
+
data=[
|
|
87
|
+
{
|
|
88
|
+
"name": t.name,
|
|
89
|
+
"display_name": t.display_name,
|
|
90
|
+
"tenant_id": str(t.tenant_id),
|
|
91
|
+
"status": "Current" if t.name == current_name else "",
|
|
92
|
+
}
|
|
93
|
+
for t in tenants
|
|
94
|
+
],
|
|
95
|
+
columns=["name", "display_name", "tenant_id", "status"],
|
|
96
|
+
title="Configured Tenants",
|
|
97
|
+
)
|
|
98
|
+
except TeamsPhoneError as e:
|
|
99
|
+
formatter.error(e.message, remediation=e.remediation)
|
|
100
|
+
raise typer.Exit(e.exit_code) from None
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@tenants_app.command()
|
|
104
|
+
def current(ctx: typer.Context) -> None:
|
|
105
|
+
"""Show the currently active tenant."""
|
|
106
|
+
cli_ctx = get_context(ctx)
|
|
107
|
+
formatter = cli_ctx.get_output_formatter()
|
|
108
|
+
|
|
109
|
+
try:
|
|
110
|
+
tenant_service, _ = _create_tenant_service(ctx)
|
|
111
|
+
tenant = tenant_service.get_current_tenant()
|
|
112
|
+
|
|
113
|
+
if cli_ctx.json_output:
|
|
114
|
+
formatter.json(
|
|
115
|
+
{
|
|
116
|
+
"name": tenant.name,
|
|
117
|
+
"display_name": tenant.display_name,
|
|
118
|
+
"tenant_id": str(tenant.tenant_id),
|
|
119
|
+
"client_id": str(tenant.client_id),
|
|
120
|
+
"auth_method": tenant.auth_method.value,
|
|
121
|
+
"cert_path": tenant.cert_path,
|
|
122
|
+
"default_location": tenant.default_location,
|
|
123
|
+
}
|
|
124
|
+
)
|
|
125
|
+
else:
|
|
126
|
+
table_data = [
|
|
127
|
+
{"field": "Display Name", "value": tenant.display_name},
|
|
128
|
+
{"field": "Profile Name", "value": tenant.name},
|
|
129
|
+
{"field": "Tenant ID", "value": str(tenant.tenant_id)},
|
|
130
|
+
{"field": "Client ID", "value": str(tenant.client_id)},
|
|
131
|
+
{"field": "Auth Method", "value": tenant.auth_method.value},
|
|
132
|
+
]
|
|
133
|
+
if tenant.cert_path:
|
|
134
|
+
table_data.append({"field": "Certificate", "value": tenant.cert_path})
|
|
135
|
+
if tenant.default_location:
|
|
136
|
+
table_data.append(
|
|
137
|
+
{"field": "Default Location", "value": tenant.default_location}
|
|
138
|
+
)
|
|
139
|
+
formatter.table(
|
|
140
|
+
data=table_data,
|
|
141
|
+
columns=["field", "value"],
|
|
142
|
+
title="Current Tenant",
|
|
143
|
+
)
|
|
144
|
+
except TeamsPhoneError as e:
|
|
145
|
+
formatter.error(e.message, remediation=e.remediation)
|
|
146
|
+
raise typer.Exit(e.exit_code) from None
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@tenants_app.command()
|
|
150
|
+
def switch(
|
|
151
|
+
ctx: typer.Context,
|
|
152
|
+
name: str = typer.Argument(..., help="Name of the tenant profile to switch to."),
|
|
153
|
+
no_login: bool = typer.Option(
|
|
154
|
+
False,
|
|
155
|
+
"--no-login",
|
|
156
|
+
help="Skip automatic login when switching.",
|
|
157
|
+
),
|
|
158
|
+
) -> None:
|
|
159
|
+
"""Switch to a different tenant profile."""
|
|
160
|
+
cli_ctx = get_context(ctx)
|
|
161
|
+
formatter = cli_ctx.get_output_formatter()
|
|
162
|
+
|
|
163
|
+
try:
|
|
164
|
+
tenant_service, _ = _create_tenant_service(ctx)
|
|
165
|
+
tenant = tenant_service.switch_tenant(name, auto_login=not no_login)
|
|
166
|
+
|
|
167
|
+
if cli_ctx.json_output:
|
|
168
|
+
formatter.json(
|
|
169
|
+
{
|
|
170
|
+
"status": "switched",
|
|
171
|
+
"name": tenant.name,
|
|
172
|
+
"display_name": tenant.display_name,
|
|
173
|
+
"tenant_id": str(tenant.tenant_id),
|
|
174
|
+
}
|
|
175
|
+
)
|
|
176
|
+
else:
|
|
177
|
+
formatter.success(f"Switched to tenant: {tenant.display_name}")
|
|
178
|
+
formatter.info(f"Tenant ID: {tenant.tenant_id}")
|
|
179
|
+
except TeamsPhoneError as e:
|
|
180
|
+
formatter.error(e.message, remediation=e.remediation)
|
|
181
|
+
raise typer.Exit(e.exit_code) from None
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@tenants_app.command()
|
|
185
|
+
def test(
|
|
186
|
+
ctx: typer.Context,
|
|
187
|
+
name: str | None = typer.Argument(
|
|
188
|
+
None, help="Tenant profile name to test (default: current tenant)."
|
|
189
|
+
),
|
|
190
|
+
) -> None:
|
|
191
|
+
"""Test connection to a tenant."""
|
|
192
|
+
cli_ctx = get_context(ctx)
|
|
193
|
+
formatter = cli_ctx.get_output_formatter()
|
|
194
|
+
|
|
195
|
+
try:
|
|
196
|
+
tenant_service, _ = _create_tenant_service(ctx)
|
|
197
|
+
|
|
198
|
+
# Use current tenant if name not provided
|
|
199
|
+
if name is None:
|
|
200
|
+
current = tenant_service.get_current_tenant()
|
|
201
|
+
name = current.name
|
|
202
|
+
|
|
203
|
+
result = tenant_service.test_connection(name)
|
|
204
|
+
|
|
205
|
+
if cli_ctx.json_output:
|
|
206
|
+
formatter.json(
|
|
207
|
+
{
|
|
208
|
+
"success": result.success,
|
|
209
|
+
"tenant_name": result.tenant_name,
|
|
210
|
+
"latency_ms": result.latency_ms,
|
|
211
|
+
"permissions": result.permissions,
|
|
212
|
+
"error_message": result.error_message,
|
|
213
|
+
}
|
|
214
|
+
)
|
|
215
|
+
else:
|
|
216
|
+
if result.success:
|
|
217
|
+
formatter.success(f"Connection to '{result.tenant_name}' successful")
|
|
218
|
+
if result.latency_ms is not None:
|
|
219
|
+
formatter.info(f"Latency: {result.latency_ms}ms")
|
|
220
|
+
if result.permissions:
|
|
221
|
+
formatter.info(f"Permissions: {len(result.permissions)} scopes")
|
|
222
|
+
else:
|
|
223
|
+
formatter.error(
|
|
224
|
+
f"Connection to '{result.tenant_name}' failed",
|
|
225
|
+
remediation=result.error_message,
|
|
226
|
+
)
|
|
227
|
+
raise typer.Exit(1)
|
|
228
|
+
except TeamsPhoneError as e:
|
|
229
|
+
formatter.error(e.message, remediation=e.remediation)
|
|
230
|
+
raise typer.Exit(e.exit_code) from None
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@tenants_app.command()
|
|
234
|
+
def remove(
|
|
235
|
+
ctx: typer.Context,
|
|
236
|
+
name: str = typer.Argument(..., help="Name of the tenant profile to remove."),
|
|
237
|
+
force: bool = typer.Option(
|
|
238
|
+
False,
|
|
239
|
+
"--force",
|
|
240
|
+
"-f",
|
|
241
|
+
help="Skip confirmation prompt.",
|
|
242
|
+
),
|
|
243
|
+
) -> None:
|
|
244
|
+
"""Remove a tenant profile."""
|
|
245
|
+
cli_ctx = get_context(ctx)
|
|
246
|
+
formatter = cli_ctx.get_output_formatter()
|
|
247
|
+
|
|
248
|
+
try:
|
|
249
|
+
tenant_service, _ = _create_tenant_service(ctx)
|
|
250
|
+
|
|
251
|
+
# Confirm removal unless --force is used
|
|
252
|
+
if not force:
|
|
253
|
+
confirm = typer.confirm(f"Are you sure you want to remove tenant '{name}'?")
|
|
254
|
+
if not confirm:
|
|
255
|
+
formatter.info("Removal cancelled")
|
|
256
|
+
raise typer.Exit(0)
|
|
257
|
+
|
|
258
|
+
tenant_service.remove_tenant(name)
|
|
259
|
+
|
|
260
|
+
if cli_ctx.json_output:
|
|
261
|
+
formatter.json(
|
|
262
|
+
{
|
|
263
|
+
"status": "removed",
|
|
264
|
+
"name": name,
|
|
265
|
+
}
|
|
266
|
+
)
|
|
267
|
+
else:
|
|
268
|
+
formatter.success(f"Removed tenant: {name}")
|
|
269
|
+
except TeamsPhoneError as e:
|
|
270
|
+
formatter.error(e.message, remediation=e.remediation)
|
|
271
|
+
raise typer.Exit(e.exit_code) from None
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
@tenants_app.command()
|
|
275
|
+
def add(
|
|
276
|
+
ctx: typer.Context,
|
|
277
|
+
name: str = typer.Argument(..., help="Profile name for the tenant."),
|
|
278
|
+
tenant_id: str = typer.Argument(..., help="Microsoft Entra tenant ID (UUID)."),
|
|
279
|
+
client_id: str = typer.Argument(..., help="App registration client ID (UUID)."),
|
|
280
|
+
display_name: str = typer.Option(
|
|
281
|
+
...,
|
|
282
|
+
"--display-name",
|
|
283
|
+
"-d",
|
|
284
|
+
help="Human-readable display name for the tenant.",
|
|
285
|
+
),
|
|
286
|
+
auth_method: AuthMethod = typer.Option(
|
|
287
|
+
AuthMethod.CERTIFICATE,
|
|
288
|
+
"--auth-method",
|
|
289
|
+
"-a",
|
|
290
|
+
help="Authentication method.",
|
|
291
|
+
),
|
|
292
|
+
cert_path: str | None = typer.Option(
|
|
293
|
+
None,
|
|
294
|
+
"--cert-path",
|
|
295
|
+
"-c",
|
|
296
|
+
help="Path to certificate file (for certificate auth).",
|
|
297
|
+
),
|
|
298
|
+
default_location: str | None = typer.Option(
|
|
299
|
+
None,
|
|
300
|
+
"--default-location",
|
|
301
|
+
"-l",
|
|
302
|
+
help="Default emergency location for number assignments.",
|
|
303
|
+
),
|
|
304
|
+
) -> None:
|
|
305
|
+
"""Add a new tenant profile."""
|
|
306
|
+
cli_ctx = get_context(ctx)
|
|
307
|
+
formatter = cli_ctx.get_output_formatter()
|
|
308
|
+
|
|
309
|
+
try:
|
|
310
|
+
# Validate UUIDs
|
|
311
|
+
try:
|
|
312
|
+
validated_tenant_id = UUID(tenant_id)
|
|
313
|
+
except ValueError:
|
|
314
|
+
formatter.error(
|
|
315
|
+
f"Invalid tenant ID: {tenant_id}",
|
|
316
|
+
remediation="Tenant ID must be a valid UUID (e.g., xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx).",
|
|
317
|
+
)
|
|
318
|
+
raise typer.Exit(5) from None # ValidationError exit code
|
|
319
|
+
|
|
320
|
+
try:
|
|
321
|
+
validated_client_id = UUID(client_id)
|
|
322
|
+
except ValueError:
|
|
323
|
+
formatter.error(
|
|
324
|
+
f"Invalid client ID: {client_id}",
|
|
325
|
+
remediation="Client ID must be a valid UUID (e.g., xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx).",
|
|
326
|
+
)
|
|
327
|
+
raise typer.Exit(5) from None # ValidationError exit code
|
|
328
|
+
|
|
329
|
+
# Create tenant profile
|
|
330
|
+
profile = TenantProfile(
|
|
331
|
+
name=name,
|
|
332
|
+
tenant_id=validated_tenant_id,
|
|
333
|
+
client_id=validated_client_id,
|
|
334
|
+
display_name=display_name,
|
|
335
|
+
auth_method=auth_method,
|
|
336
|
+
cert_path=cert_path,
|
|
337
|
+
default_location=default_location,
|
|
338
|
+
)
|
|
339
|
+
|
|
340
|
+
tenant_service, _ = _create_tenant_service(ctx)
|
|
341
|
+
tenant_service.add_tenant(profile)
|
|
342
|
+
|
|
343
|
+
if cli_ctx.json_output:
|
|
344
|
+
formatter.json(
|
|
345
|
+
{
|
|
346
|
+
"status": "added",
|
|
347
|
+
"name": profile.name,
|
|
348
|
+
"display_name": profile.display_name,
|
|
349
|
+
"tenant_id": str(profile.tenant_id),
|
|
350
|
+
"client_id": str(profile.client_id),
|
|
351
|
+
"auth_method": profile.auth_method.value,
|
|
352
|
+
}
|
|
353
|
+
)
|
|
354
|
+
else:
|
|
355
|
+
formatter.success(f"Added tenant: {profile.display_name}")
|
|
356
|
+
formatter.info(f"Profile name: {profile.name}")
|
|
357
|
+
formatter.info(f"Tenant ID: {profile.tenant_id}")
|
|
358
|
+
formatter.info(f"Client ID: {profile.client_id}")
|
|
359
|
+
formatter.info(f"Auth method: {profile.auth_method.value}")
|
|
360
|
+
if profile.cert_path:
|
|
361
|
+
formatter.info(f"Certificate: {profile.cert_path}")
|
|
362
|
+
except TeamsPhoneError as e:
|
|
363
|
+
formatter.error(e.message, remediation=e.remediation)
|
|
364
|
+
raise typer.Exit(e.exit_code) from None
|