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,330 @@
|
|
|
1
|
+
"""Policy service for voice policy operations.
|
|
2
|
+
|
|
3
|
+
This module provides the PolicyService class for listing, filtering, and
|
|
4
|
+
validating voice policies from the CSV cache.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from datetime import timedelta
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
from teams_phone.constants import CSV_STALE_DAYS
|
|
13
|
+
from teams_phone.exceptions import NotFoundError, ValidationError
|
|
14
|
+
from teams_phone.models import EffectivePolicyAssignment, Policy, UserConfiguration
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from teams_phone.infrastructure import CacheManager, GraphClient
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _strip_odata_metadata(data: dict[str, Any]) -> dict[str, Any]:
|
|
22
|
+
"""Strip OData metadata fields from a response dict.
|
|
23
|
+
|
|
24
|
+
Graph API responses include @odata.context and other @ prefixed fields
|
|
25
|
+
that are not part of the actual data contract. This helper removes them
|
|
26
|
+
before Pydantic validation with extra="forbid".
|
|
27
|
+
"""
|
|
28
|
+
return {k: v for k, v in data.items() if not k.startswith("@")}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# Supported policy types for reference (not enforced in list_policies)
|
|
32
|
+
SUPPORTED_POLICY_TYPES = [
|
|
33
|
+
"TeamsCallingPolicy",
|
|
34
|
+
"TeamsVoicemailPolicy",
|
|
35
|
+
"CallingLineIdentity",
|
|
36
|
+
"TenantDialPlan",
|
|
37
|
+
"OnlineVoiceRoutingPolicy",
|
|
38
|
+
"TeamsEmergencyCallingPolicy",
|
|
39
|
+
"TeamsEmergencyCallRoutingPolicy",
|
|
40
|
+
]
|
|
41
|
+
|
|
42
|
+
# Policy types that support batch assignment via Graph API
|
|
43
|
+
# Note: TeamsVoicemailPolicy is NOT supported - use Grant-CsTeamsVoicemailPolicy PowerShell
|
|
44
|
+
ASSIGNABLE_POLICY_TYPES = [
|
|
45
|
+
"TeamsCallingPolicy",
|
|
46
|
+
"CallingLineIdentity",
|
|
47
|
+
"TenantDialPlan",
|
|
48
|
+
"OnlineVoiceRoutingPolicy",
|
|
49
|
+
"TeamsEmergencyCallingPolicy",
|
|
50
|
+
"TeamsEmergencyCallRoutingPolicy",
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class PolicyService:
|
|
55
|
+
"""Service for voice policy operations.
|
|
56
|
+
|
|
57
|
+
Provides methods for listing, filtering, and validating voice policies
|
|
58
|
+
from the CSV cache.
|
|
59
|
+
|
|
60
|
+
Attributes:
|
|
61
|
+
graph_client: GraphClient instance for API access (used in future subtasks).
|
|
62
|
+
cache_manager: CacheManager instance for CSV access.
|
|
63
|
+
"""
|
|
64
|
+
|
|
65
|
+
def __init__(self, graph_client: GraphClient, cache_manager: CacheManager) -> None:
|
|
66
|
+
"""Initialize the PolicyService.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
graph_client: GraphClient instance for Graph API operations.
|
|
70
|
+
cache_manager: CacheManager instance for CSV reading.
|
|
71
|
+
"""
|
|
72
|
+
self.graph_client = graph_client
|
|
73
|
+
self.cache_manager = cache_manager
|
|
74
|
+
|
|
75
|
+
def list_policies(self, policy_type: str | None = None) -> list[Policy]:
|
|
76
|
+
"""List policies from the cache, optionally filtered by type.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
policy_type: Optional policy type to filter by (case-insensitive).
|
|
80
|
+
If None, returns all policies.
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
List of Policy objects. Empty list if CSV doesn't exist or is malformed.
|
|
84
|
+
"""
|
|
85
|
+
raw_data = self.cache_manager.read_policies_csv()
|
|
86
|
+
policies: list[Policy] = []
|
|
87
|
+
|
|
88
|
+
for row in raw_data:
|
|
89
|
+
# Create a new dict with boolean conversion for isGlobal field
|
|
90
|
+
# CacheManager returns dict[str, str], but Pydantic needs bool for isGlobal
|
|
91
|
+
validated_row: dict[str, Any] = dict(row)
|
|
92
|
+
validated_row["isGlobal"] = row.get("isGlobal", "").lower() == "true"
|
|
93
|
+
policies.append(Policy.model_validate(validated_row))
|
|
94
|
+
|
|
95
|
+
# Filter by policy_type if provided (case-insensitive)
|
|
96
|
+
if policy_type is not None:
|
|
97
|
+
policy_type_lower = policy_type.lower()
|
|
98
|
+
policies = [
|
|
99
|
+
p for p in policies if p.policy_type.lower() == policy_type_lower
|
|
100
|
+
]
|
|
101
|
+
|
|
102
|
+
return policies
|
|
103
|
+
|
|
104
|
+
def validate_policy(self, policy_type: str, policy_name: str) -> Policy:
|
|
105
|
+
"""Validate and retrieve a policy by type and name.
|
|
106
|
+
|
|
107
|
+
Finds a policy matching both the type and name (case-insensitive).
|
|
108
|
+
|
|
109
|
+
Args:
|
|
110
|
+
policy_type: The policy type (e.g., "TeamsCallingPolicy").
|
|
111
|
+
policy_name: The policy name.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
Policy for the matched policy.
|
|
115
|
+
|
|
116
|
+
Raises:
|
|
117
|
+
NotFoundError: If no policy matches the type and name.
|
|
118
|
+
"""
|
|
119
|
+
policies = self.list_policies()
|
|
120
|
+
policy_type_lower = policy_type.lower()
|
|
121
|
+
policy_name_lower = policy_name.lower()
|
|
122
|
+
|
|
123
|
+
for policy in policies:
|
|
124
|
+
if (
|
|
125
|
+
policy.policy_type.lower() == policy_type_lower
|
|
126
|
+
and policy.policy_name.lower() == policy_name_lower
|
|
127
|
+
):
|
|
128
|
+
return policy
|
|
129
|
+
|
|
130
|
+
raise NotFoundError(
|
|
131
|
+
f"Policy '{policy_name}' of type '{policy_type}' not found",
|
|
132
|
+
remediation=(
|
|
133
|
+
"Verify the policy type and name are correct.\n"
|
|
134
|
+
"Use 'teams-phone policies list' to see available policies."
|
|
135
|
+
),
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
async def get_user_policies(
|
|
139
|
+
self, user_id: str, policy_type: str | None = None
|
|
140
|
+
) -> list[EffectivePolicyAssignment]:
|
|
141
|
+
"""Get a user's effective policy assignments.
|
|
142
|
+
|
|
143
|
+
Retrieves the effective policy assignments for a user from their
|
|
144
|
+
UserConfiguration via the Graph API.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
user_id: The user's Entra object ID (GUID).
|
|
148
|
+
policy_type: Optional policy type to filter by (case-insensitive).
|
|
149
|
+
If None, returns all effective policy assignments.
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
List of EffectivePolicyAssignment objects.
|
|
153
|
+
|
|
154
|
+
Raises:
|
|
155
|
+
NotFoundError: If the user is not found.
|
|
156
|
+
ContractError: If API response doesn't match expected schema.
|
|
157
|
+
AuthenticationError: If authentication fails.
|
|
158
|
+
"""
|
|
159
|
+
response = await self.graph_client.get(
|
|
160
|
+
f"/admin/teams/userConfigurations/{user_id}"
|
|
161
|
+
)
|
|
162
|
+
user_config = UserConfiguration.model_validate(_strip_odata_metadata(response))
|
|
163
|
+
assignments = user_config.effective_policy_assignments
|
|
164
|
+
|
|
165
|
+
if policy_type is not None:
|
|
166
|
+
policy_type_lower = policy_type.lower()
|
|
167
|
+
assignments = [
|
|
168
|
+
a for a in assignments if a.policy_type.lower() == policy_type_lower
|
|
169
|
+
]
|
|
170
|
+
|
|
171
|
+
return assignments
|
|
172
|
+
|
|
173
|
+
def get_cache_age(self) -> timedelta | None:
|
|
174
|
+
"""Get the age of the policy cache.
|
|
175
|
+
|
|
176
|
+
Returns:
|
|
177
|
+
timedelta representing the cache age, or None if no cache exists.
|
|
178
|
+
"""
|
|
179
|
+
age_days = self.cache_manager.get_cache_age_days()
|
|
180
|
+
|
|
181
|
+
if age_days is None:
|
|
182
|
+
return None
|
|
183
|
+
|
|
184
|
+
return timedelta(days=age_days)
|
|
185
|
+
|
|
186
|
+
def is_cache_stale(self) -> bool:
|
|
187
|
+
"""Check if the policy cache is stale.
|
|
188
|
+
|
|
189
|
+
Returns:
|
|
190
|
+
True if cache doesn't exist or is older than CSV_STALE_DAYS (30 days).
|
|
191
|
+
"""
|
|
192
|
+
return self.cache_manager.is_cache_stale()
|
|
193
|
+
|
|
194
|
+
def get_staleness_warning(self) -> str | None:
|
|
195
|
+
"""Get a warning message if the cache is stale.
|
|
196
|
+
|
|
197
|
+
Returns:
|
|
198
|
+
Warning message string if cache is stale, None otherwise.
|
|
199
|
+
"""
|
|
200
|
+
if not self.is_cache_stale():
|
|
201
|
+
return None
|
|
202
|
+
|
|
203
|
+
age_days = self.cache_manager.get_cache_age_days()
|
|
204
|
+
|
|
205
|
+
if age_days is None:
|
|
206
|
+
return (
|
|
207
|
+
"Policy cache not found. "
|
|
208
|
+
"Run the PowerShell export script to create the cache."
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
return (
|
|
212
|
+
f"Policy cache is {age_days} days old (threshold: {CSV_STALE_DAYS} days). "
|
|
213
|
+
"Consider refreshing by running the PowerShell export script."
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
async def resolve_policy_id(self, policy_type: str, policy_name: str) -> str:
|
|
217
|
+
"""Resolve a policy's actual Graph API policyId from tenant user assignments.
|
|
218
|
+
|
|
219
|
+
The CSV cache stores policy names, but the Graph API assignment endpoint
|
|
220
|
+
requires the actual base64-encoded policyId. This method queries user
|
|
221
|
+
configurations to find the real policyId for a given policy type and name.
|
|
222
|
+
|
|
223
|
+
Args:
|
|
224
|
+
policy_type: The policy type (e.g., "TeamsCallingPolicy").
|
|
225
|
+
policy_name: The policy display name (e.g., "AllowCalling").
|
|
226
|
+
|
|
227
|
+
Returns:
|
|
228
|
+
The actual policyId (base64-encoded string) for use in Graph API calls.
|
|
229
|
+
|
|
230
|
+
Raises:
|
|
231
|
+
NotFoundError: If no user in the tenant has this policy assigned,
|
|
232
|
+
meaning we cannot determine the policyId.
|
|
233
|
+
"""
|
|
234
|
+
policy_type_lower = policy_type.lower()
|
|
235
|
+
policy_name_lower = policy_name.lower()
|
|
236
|
+
|
|
237
|
+
# Query user configurations to find policy assignments
|
|
238
|
+
# We need to iterate through users until we find one with the target policy
|
|
239
|
+
params: dict[str, Any] = {"$top": "100"}
|
|
240
|
+
|
|
241
|
+
async for item in self.graph_client.get_paginated(
|
|
242
|
+
"/admin/teams/userConfigurations",
|
|
243
|
+
params=params,
|
|
244
|
+
max_items=500, # Check up to 500 users
|
|
245
|
+
):
|
|
246
|
+
user = UserConfiguration.model_validate(item)
|
|
247
|
+
for assignment in user.effective_policy_assignments:
|
|
248
|
+
if (
|
|
249
|
+
assignment.policy_type.lower() == policy_type_lower
|
|
250
|
+
and assignment.policy_assignment.display_name
|
|
251
|
+
and assignment.policy_assignment.display_name.lower()
|
|
252
|
+
== policy_name_lower
|
|
253
|
+
):
|
|
254
|
+
return assignment.policy_assignment.policy_id
|
|
255
|
+
|
|
256
|
+
raise NotFoundError(
|
|
257
|
+
f"Could not resolve policyId for '{policy_name}' ({policy_type})",
|
|
258
|
+
remediation=(
|
|
259
|
+
"No user in the tenant currently has this policy assigned.\n"
|
|
260
|
+
"The Graph API requires the actual policyId, which can only be\n"
|
|
261
|
+
"determined from existing user assignments.\n\n"
|
|
262
|
+
"Options:\n"
|
|
263
|
+
"1. Assign this policy to a user via PowerShell first\n"
|
|
264
|
+
"2. Use a different policy that is already assigned to someone"
|
|
265
|
+
),
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
async def assign_policy(
|
|
269
|
+
self, user_id: str, policy_type: str, policy_name: str
|
|
270
|
+
) -> None:
|
|
271
|
+
"""Assign a policy to a user via Graph API.
|
|
272
|
+
|
|
273
|
+
Validates the policy exists in the CSV cache, resolves the actual
|
|
274
|
+
policyId from tenant user assignments, then POSTs to the Graph API
|
|
275
|
+
to assign the policy to the specified user.
|
|
276
|
+
|
|
277
|
+
Args:
|
|
278
|
+
user_id: The user's Entra object ID (GUID).
|
|
279
|
+
policy_type: The policy type (e.g., "TeamsCallingPolicy").
|
|
280
|
+
policy_name: The policy name.
|
|
281
|
+
|
|
282
|
+
Returns:
|
|
283
|
+
None on success (204 No Content response).
|
|
284
|
+
|
|
285
|
+
Raises:
|
|
286
|
+
ValidationError: If policy_type is not supported for batch assignment
|
|
287
|
+
(e.g., TeamsVoicemailPolicy).
|
|
288
|
+
NotFoundError: If the policy doesn't exist in the cache or if we
|
|
289
|
+
cannot resolve the policyId from existing user assignments.
|
|
290
|
+
AuthenticationError: If authentication fails.
|
|
291
|
+
AuthorizationError: If authorization fails.
|
|
292
|
+
"""
|
|
293
|
+
# Validate policy type is supported for batch assignment
|
|
294
|
+
if policy_type == "TeamsVoicemailPolicy":
|
|
295
|
+
raise ValidationError(
|
|
296
|
+
f"Policy type '{policy_type}' is not supported for batch assignment",
|
|
297
|
+
remediation=(
|
|
298
|
+
"Use PowerShell cmdlet Grant-CsTeamsVoicemailPolicy instead:\n"
|
|
299
|
+
f" Grant-CsTeamsVoicemailPolicy -Identity <user> "
|
|
300
|
+
f"-PolicyName '{policy_name}'"
|
|
301
|
+
),
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
# Validate the policy exists in cache (raises NotFoundError if not found)
|
|
305
|
+
self.validate_policy(policy_type, policy_name)
|
|
306
|
+
|
|
307
|
+
# Resolve the actual policyId from tenant user assignments
|
|
308
|
+
# The CSV stores policy names, but Graph API needs the real base64 policyId
|
|
309
|
+
policy_id = await self.resolve_policy_id(policy_type, policy_name)
|
|
310
|
+
|
|
311
|
+
# Build request body per Graph API contract
|
|
312
|
+
body: dict[str, Any] = {
|
|
313
|
+
"value": [
|
|
314
|
+
{
|
|
315
|
+
"@odata.type": (
|
|
316
|
+
"#microsoft.graph.teamsAdministration.teamsPolicyUserAssignment"
|
|
317
|
+
),
|
|
318
|
+
"userId": user_id,
|
|
319
|
+
"policyType": policy_type,
|
|
320
|
+
"policyId": policy_id,
|
|
321
|
+
}
|
|
322
|
+
]
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
# POST to the policy assignment endpoint
|
|
326
|
+
# Response is 204 No Content, so use post_with_response to avoid JSON parse error
|
|
327
|
+
await self.graph_client.post_with_response(
|
|
328
|
+
"/admin/teams/policy/userAssignments/assign",
|
|
329
|
+
body,
|
|
330
|
+
)
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Tenant management service for multi-tenant CLI operations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
import httpx
|
|
9
|
+
|
|
10
|
+
from teams_phone.constants import GRAPH_API_V1_BASE
|
|
11
|
+
from teams_phone.exceptions import ConfigurationError
|
|
12
|
+
from teams_phone.models import ConnectionTestResult, TenantProfile
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from teams_phone.infrastructure import ConfigManager
|
|
17
|
+
from teams_phone.services import AuthService
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class TenantService:
|
|
21
|
+
"""Service for managing tenant profiles.
|
|
22
|
+
|
|
23
|
+
Provides CRUD operations for tenant profiles, delegating persistence
|
|
24
|
+
to ConfigManager. Coordinates with AuthService to check authentication
|
|
25
|
+
status when removing tenants.
|
|
26
|
+
|
|
27
|
+
Attributes:
|
|
28
|
+
config_manager: Manager for tenant configuration persistence.
|
|
29
|
+
auth_service: Service for checking authentication status.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
def __init__(
|
|
33
|
+
self, config_manager: ConfigManager, auth_service: AuthService
|
|
34
|
+
) -> None:
|
|
35
|
+
"""Initialize the TenantService.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
config_manager: ConfigManager instance for tenant persistence.
|
|
39
|
+
auth_service: AuthService instance for authentication checks.
|
|
40
|
+
"""
|
|
41
|
+
self.config_manager = config_manager
|
|
42
|
+
self.auth_service = auth_service
|
|
43
|
+
|
|
44
|
+
def list_tenants(self) -> list[TenantProfile]:
|
|
45
|
+
"""List all configured tenant profiles.
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
List of TenantProfile objects, sorted alphabetically by name.
|
|
49
|
+
"""
|
|
50
|
+
config = self.config_manager.load_config()
|
|
51
|
+
return sorted(config.tenants.values(), key=lambda t: t.name)
|
|
52
|
+
|
|
53
|
+
def get_current_tenant(self) -> TenantProfile:
|
|
54
|
+
"""Get the current default tenant profile.
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
The TenantProfile configured as the default tenant.
|
|
58
|
+
|
|
59
|
+
Raises:
|
|
60
|
+
ConfigurationError: If no default tenant is configured.
|
|
61
|
+
"""
|
|
62
|
+
default_name = self.config_manager.get_default_tenant()
|
|
63
|
+
if default_name is None:
|
|
64
|
+
raise ConfigurationError(
|
|
65
|
+
"No default tenant configured",
|
|
66
|
+
remediation=(
|
|
67
|
+
"Set a default tenant with 'teams-phone tenants switch <name>'.\n"
|
|
68
|
+
"Or add a tenant with 'teams-phone tenants add' which auto-sets "
|
|
69
|
+
"the first tenant as default."
|
|
70
|
+
),
|
|
71
|
+
)
|
|
72
|
+
return self.config_manager.get_tenant(default_name)
|
|
73
|
+
|
|
74
|
+
def add_tenant(self, profile: TenantProfile) -> TenantProfile:
|
|
75
|
+
"""Add a new tenant profile.
|
|
76
|
+
|
|
77
|
+
If this is the first tenant being added, it is automatically set
|
|
78
|
+
as the default tenant.
|
|
79
|
+
|
|
80
|
+
Args:
|
|
81
|
+
profile: The TenantProfile to add.
|
|
82
|
+
|
|
83
|
+
Returns:
|
|
84
|
+
The added TenantProfile (same as input).
|
|
85
|
+
|
|
86
|
+
Raises:
|
|
87
|
+
ValidationError: If profile.name doesn't match the key being set.
|
|
88
|
+
"""
|
|
89
|
+
config = self.config_manager.load_config()
|
|
90
|
+
is_first_tenant = len(config.tenants) == 0
|
|
91
|
+
|
|
92
|
+
self.config_manager.set_tenant(profile.name, profile)
|
|
93
|
+
|
|
94
|
+
if is_first_tenant:
|
|
95
|
+
self.config_manager.set_default_tenant(profile.name)
|
|
96
|
+
|
|
97
|
+
return profile
|
|
98
|
+
|
|
99
|
+
def remove_tenant(self, name: str) -> None:
|
|
100
|
+
"""Remove a tenant profile by name.
|
|
101
|
+
|
|
102
|
+
Checks if the tenant is currently authenticated and logs the status.
|
|
103
|
+
Note: Authentication tokens are not automatically cleared; they will
|
|
104
|
+
expire naturally or can be cleared with 'teams-phone auth logout'.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
name: The name of the tenant profile to remove.
|
|
108
|
+
|
|
109
|
+
Raises:
|
|
110
|
+
NotFoundError: If no tenant with the given name exists.
|
|
111
|
+
"""
|
|
112
|
+
# Check auth status (informational, doesn't block removal)
|
|
113
|
+
# is_authenticated never throws, safe to call
|
|
114
|
+
_is_authenticated = self.auth_service.is_authenticated(name)
|
|
115
|
+
|
|
116
|
+
# Delegate to ConfigManager which handles:
|
|
117
|
+
# - NotFoundError if tenant doesn't exist
|
|
118
|
+
# - Clearing default_tenant if this was the default
|
|
119
|
+
self.config_manager.remove_tenant(name)
|
|
120
|
+
|
|
121
|
+
def switch_tenant(self, name: str, *, auto_login: bool = True) -> TenantProfile:
|
|
122
|
+
"""Switch to a different tenant profile.
|
|
123
|
+
|
|
124
|
+
Validates the tenant exists, checks authentication status,
|
|
125
|
+
optionally performs auto-login, and updates the default tenant.
|
|
126
|
+
|
|
127
|
+
Args:
|
|
128
|
+
name: Name of the tenant profile to switch to.
|
|
129
|
+
auto_login: If True (default), automatically login if not authenticated.
|
|
130
|
+
|
|
131
|
+
Returns:
|
|
132
|
+
The TenantProfile that was switched to.
|
|
133
|
+
|
|
134
|
+
Raises:
|
|
135
|
+
NotFoundError: If the tenant does not exist.
|
|
136
|
+
AuthenticationError: If auto_login is True and login fails.
|
|
137
|
+
"""
|
|
138
|
+
# Validate tenant exists (raises NotFoundError if not)
|
|
139
|
+
profile = self.config_manager.get_tenant(name)
|
|
140
|
+
|
|
141
|
+
# Check authentication status (is_authenticated never throws)
|
|
142
|
+
# Attempt login - let AuthenticationError propagate if it fails
|
|
143
|
+
# This ensures default_tenant is NOT updated on auth failure
|
|
144
|
+
if not self.auth_service.is_authenticated(name) and auto_login:
|
|
145
|
+
self.auth_service.login(name)
|
|
146
|
+
|
|
147
|
+
# Only update default after successful auth check/login
|
|
148
|
+
self.config_manager.set_default_tenant(name)
|
|
149
|
+
|
|
150
|
+
return profile
|
|
151
|
+
|
|
152
|
+
def test_connection(self, name: str) -> ConnectionTestResult:
|
|
153
|
+
"""Test connectivity to Microsoft Graph API for a tenant.
|
|
154
|
+
|
|
155
|
+
Attempts token acquisition and makes a test API call to validate
|
|
156
|
+
that the tenant configuration works correctly. This method never
|
|
157
|
+
raises exceptions - all errors are captured in the result.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
name: Name of the tenant profile to test.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
ConnectionTestResult with success status, latency, permissions,
|
|
164
|
+
and error message if the test failed.
|
|
165
|
+
"""
|
|
166
|
+
start_time = time.perf_counter()
|
|
167
|
+
|
|
168
|
+
try:
|
|
169
|
+
# Validate tenant exists (raises NotFoundError if not)
|
|
170
|
+
self.config_manager.get_tenant(name)
|
|
171
|
+
|
|
172
|
+
# Attempt token acquisition with force=True for fresh token
|
|
173
|
+
cached_token = self.auth_service.login(name, force=True)
|
|
174
|
+
|
|
175
|
+
# Make test Graph API call to /organization endpoint
|
|
176
|
+
# Using sync httpx client since TenantService is synchronous
|
|
177
|
+
headers = {"Authorization": f"Bearer {cached_token.access_token}"}
|
|
178
|
+
with httpx.Client(timeout=30.0) as client:
|
|
179
|
+
response = client.get(
|
|
180
|
+
f"{GRAPH_API_V1_BASE}/organization",
|
|
181
|
+
headers=headers,
|
|
182
|
+
)
|
|
183
|
+
response.raise_for_status()
|
|
184
|
+
|
|
185
|
+
# Calculate latency
|
|
186
|
+
elapsed_ms = int((time.perf_counter() - start_time) * 1000)
|
|
187
|
+
|
|
188
|
+
# Return scopes from the token as permissions
|
|
189
|
+
return ConnectionTestResult(
|
|
190
|
+
success=True,
|
|
191
|
+
tenant_name=name,
|
|
192
|
+
latency_ms=elapsed_ms,
|
|
193
|
+
permissions=cached_token.scopes,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
except Exception as e: # noqa: BLE001
|
|
197
|
+
# Calculate latency even on failure (if we got past initial validation)
|
|
198
|
+
elapsed_ms = int((time.perf_counter() - start_time) * 1000)
|
|
199
|
+
|
|
200
|
+
return ConnectionTestResult(
|
|
201
|
+
success=False,
|
|
202
|
+
tenant_name=name,
|
|
203
|
+
latency_ms=elapsed_ms if elapsed_ms > 0 else None,
|
|
204
|
+
error_message=str(e),
|
|
205
|
+
)
|