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