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