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,562 @@
1
+ """Bulk operations for Teams Phone CLI.
2
+
3
+ This module provides utilities for parsing and validating bulk operation
4
+ CSV files, such as bulk phone number assignments.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import csv
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+ from typing import TYPE_CHECKING
13
+
14
+ from teams_phone.exceptions import ConfigurationError, NotFoundError, ValidationError
15
+ from teams_phone.models import AssignmentStatus, NumberType
16
+
17
+
18
+ if TYPE_CHECKING:
19
+ from teams_phone.services.location_service import LocationService
20
+ from teams_phone.services.number_service import NumberService
21
+ from teams_phone.services.user_service import UserService
22
+
23
+
24
+ @dataclass
25
+ class BulkAssignRow:
26
+ """A single row from a bulk assignment CSV file.
27
+
28
+ Attributes:
29
+ row_number: 1-indexed row number for user-facing display.
30
+ user: User identifier (UPN or ID).
31
+ phone_number: Phone number to assign.
32
+ location: Optional emergency location ID.
33
+ errors: List of validation errors for this row.
34
+ warnings: List of validation warnings for this row.
35
+ """
36
+
37
+ row_number: int
38
+ user: str
39
+ phone_number: str
40
+ location: str | None
41
+ errors: list[str] = field(default_factory=list)
42
+ warnings: list[str] = field(default_factory=list)
43
+
44
+
45
+ @dataclass
46
+ class BulkValidationResult:
47
+ """Result of validating a bulk operation CSV file.
48
+
49
+ Attributes:
50
+ rows: List of parsed and validated rows.
51
+ valid_count: Number of rows without errors.
52
+ error_count: Number of rows with at least one error.
53
+ warning_count: Number of rows with at least one warning.
54
+ """
55
+
56
+ rows: list[BulkAssignRow]
57
+ valid_count: int
58
+ error_count: int
59
+ warning_count: int
60
+
61
+
62
+ @dataclass
63
+ class BulkAssignResult:
64
+ """Result of a single bulk assignment operation.
65
+
66
+ Attributes:
67
+ row_number: 1-indexed row number from the input CSV.
68
+ user: User identifier from the input CSV.
69
+ phone_number: Phone number from the input CSV.
70
+ status: Operation result ('success', 'failed', 'skipped').
71
+ error: Error message(s) if operation failed or was skipped.
72
+ """
73
+
74
+ row_number: int
75
+ user: str
76
+ phone_number: str
77
+ status: str # 'success' | 'failed' | 'skipped'
78
+ error: str | None = None
79
+
80
+
81
+ @dataclass
82
+ class BulkPolicyRow:
83
+ """A single row from a bulk policy assignment CSV file.
84
+
85
+ Attributes:
86
+ row_number: 1-indexed row number for user-facing display.
87
+ user: User identifier (UPN or ID).
88
+ policy_type: Policy type (resolved to API name).
89
+ policy_name: Policy name.
90
+ errors: List of validation errors for this row.
91
+ warnings: List of validation warnings for this row.
92
+ """
93
+
94
+ row_number: int
95
+ user: str
96
+ policy_type: str
97
+ policy_name: str
98
+ errors: list[str] = field(default_factory=list)
99
+ warnings: list[str] = field(default_factory=list)
100
+
101
+
102
+ @dataclass
103
+ class BulkPolicyValidationResult:
104
+ """Result of validating a bulk policy operation CSV file.
105
+
106
+ Attributes:
107
+ rows: List of parsed and validated rows.
108
+ valid_count: Number of rows without errors.
109
+ error_count: Number of rows with at least one error.
110
+ warning_count: Number of rows with at least one warning.
111
+ """
112
+
113
+ rows: list[BulkPolicyRow]
114
+ valid_count: int
115
+ error_count: int
116
+ warning_count: int
117
+
118
+
119
+ @dataclass
120
+ class BulkPolicyResult:
121
+ """Result of a single bulk policy assignment operation.
122
+
123
+ Attributes:
124
+ row_number: 1-indexed row number from the input CSV.
125
+ user: User identifier from the input CSV.
126
+ policy_type: Policy type from the input CSV.
127
+ policy_name: Policy name from the input CSV.
128
+ status: Operation result ('success', 'failed', 'skipped').
129
+ error: Error message(s) if operation failed or was skipped.
130
+ """
131
+
132
+ row_number: int
133
+ user: str
134
+ policy_type: str
135
+ policy_name: str
136
+ status: str # 'success' | 'failed' | 'skipped'
137
+ error: str | None = None
138
+
139
+
140
+ def parse_assign_csv(file_path: Path) -> list[BulkAssignRow]:
141
+ """Parse a bulk assignment CSV file.
142
+
143
+ Reads a CSV file containing user/phone number/location data for bulk
144
+ phone number assignments. Validates required columns exist and collects
145
+ parsing errors and warnings per row.
146
+
147
+ Args:
148
+ file_path: Path to the CSV file to parse.
149
+
150
+ Returns:
151
+ List of BulkAssignRow objects, one per data row. Each row includes
152
+ any errors or warnings encountered during parsing.
153
+
154
+ Raises:
155
+ ValidationError: If required columns ('user', 'phone_number') are
156
+ missing from the CSV header.
157
+
158
+ Note:
159
+ Uses UTF-8 with BOM support (utf-8-sig encoding) to handle
160
+ PowerShell exports which may include a byte order mark.
161
+ """
162
+ required_columns = {"user", "phone_number"}
163
+
164
+ if not file_path.exists():
165
+ raise ValidationError(
166
+ f"CSV file not found: {file_path}",
167
+ remediation="Ensure the file path is correct and the file exists.",
168
+ )
169
+
170
+ try:
171
+ with file_path.open(encoding="utf-8-sig") as f:
172
+ reader = csv.DictReader(f)
173
+
174
+ # Validate header has required columns
175
+ if reader.fieldnames is None:
176
+ raise ValidationError(
177
+ "CSV file is empty or has no header row",
178
+ remediation="Provide a CSV file with a header row containing "
179
+ "'user' and 'phone_number' columns.",
180
+ )
181
+
182
+ header_columns = set(reader.fieldnames)
183
+ missing_columns = required_columns - header_columns
184
+
185
+ if missing_columns:
186
+ raise ValidationError(
187
+ f"CSV missing required columns: {', '.join(sorted(missing_columns))}",
188
+ remediation="Ensure the CSV header includes 'user' and "
189
+ "'phone_number' columns.",
190
+ )
191
+
192
+ has_location_column = "location" in header_columns
193
+ rows: list[BulkAssignRow] = []
194
+
195
+ # Row number starts at 2 (1-indexed, accounting for header row)
196
+ for row_number, row_data in enumerate(reader, start=2):
197
+ errors: list[str] = []
198
+ warnings: list[str] = []
199
+
200
+ # Extract and validate required fields
201
+ user = row_data.get("user", "").strip()
202
+ phone_number = row_data.get("phone_number", "").strip()
203
+
204
+ if not user:
205
+ errors.append("Missing required field: user")
206
+ if not phone_number:
207
+ errors.append("Missing required field: phone_number")
208
+
209
+ # Extract optional location field
210
+ location: str | None = None
211
+ if has_location_column:
212
+ location_value = row_data.get("location", "").strip()
213
+ if location_value:
214
+ location = location_value
215
+ else:
216
+ warnings.append("Empty optional field: location")
217
+
218
+ rows.append(
219
+ BulkAssignRow(
220
+ row_number=row_number,
221
+ user=user,
222
+ phone_number=phone_number,
223
+ location=location,
224
+ errors=errors,
225
+ warnings=warnings,
226
+ )
227
+ )
228
+
229
+ return rows
230
+
231
+ except csv.Error as e:
232
+ raise ValidationError(
233
+ f"CSV parsing error: {e}",
234
+ remediation="Check the CSV file for formatting issues such as "
235
+ "unbalanced quotes or invalid characters.",
236
+ ) from e
237
+
238
+
239
+ def parse_policy_csv(file_path: Path) -> list[BulkPolicyRow]:
240
+ """Parse a bulk policy assignment CSV file.
241
+
242
+ Reads a CSV file containing user/policy_type/policy_name data for bulk
243
+ policy assignments. Validates required columns exist and collects
244
+ parsing errors and warnings per row.
245
+
246
+ Args:
247
+ file_path: Path to the CSV file to parse.
248
+
249
+ Returns:
250
+ List of BulkPolicyRow objects, one per data row. Each row includes
251
+ any errors or warnings encountered during parsing.
252
+
253
+ Raises:
254
+ ValidationError: If required columns ('user', 'policy_type', 'policy_name')
255
+ are missing from the CSV header.
256
+
257
+ Note:
258
+ Uses UTF-8 with BOM support (utf-8-sig encoding) to handle
259
+ PowerShell exports which may include a byte order mark.
260
+ """
261
+ required_columns = {"user", "policy_type", "policy_name"}
262
+
263
+ if not file_path.exists():
264
+ raise ValidationError(
265
+ f"CSV file not found: {file_path}",
266
+ remediation="Ensure the file path is correct and the file exists.",
267
+ )
268
+
269
+ try:
270
+ with file_path.open(encoding="utf-8-sig") as f:
271
+ reader = csv.DictReader(f)
272
+
273
+ # Validate header has required columns
274
+ if reader.fieldnames is None:
275
+ raise ValidationError(
276
+ "CSV file is empty or has no header row",
277
+ remediation="Provide a CSV file with a header row containing "
278
+ "'user', 'policy_type', and 'policy_name' columns.",
279
+ )
280
+
281
+ header_columns = set(reader.fieldnames)
282
+ missing_columns = required_columns - header_columns
283
+
284
+ if missing_columns:
285
+ raise ValidationError(
286
+ f"CSV missing required columns: {', '.join(sorted(missing_columns))}",
287
+ remediation="Ensure the CSV header includes 'user', "
288
+ "'policy_type', and 'policy_name' columns.",
289
+ )
290
+
291
+ rows: list[BulkPolicyRow] = []
292
+
293
+ # Row number starts at 2 (1-indexed, accounting for header row)
294
+ for row_number, row_data in enumerate(reader, start=2):
295
+ errors: list[str] = []
296
+ warnings: list[str] = []
297
+
298
+ # Extract and validate required fields
299
+ user = row_data.get("user", "").strip()
300
+ policy_type = row_data.get("policy_type", "").strip()
301
+ policy_name = row_data.get("policy_name", "").strip()
302
+
303
+ if not user:
304
+ errors.append("Missing required field: user")
305
+ if not policy_type:
306
+ errors.append("Missing required field: policy_type")
307
+ if not policy_name:
308
+ errors.append("Missing required field: policy_name")
309
+
310
+ rows.append(
311
+ BulkPolicyRow(
312
+ row_number=row_number,
313
+ user=user,
314
+ policy_type=policy_type,
315
+ policy_name=policy_name,
316
+ errors=errors,
317
+ warnings=warnings,
318
+ )
319
+ )
320
+
321
+ return rows
322
+
323
+ except csv.Error as e:
324
+ raise ValidationError(
325
+ f"CSV parsing error: {e}",
326
+ remediation="Check the CSV file for formatting issues such as "
327
+ "unbalanced quotes or invalid characters.",
328
+ ) from e
329
+
330
+
331
+ def write_policy_results_csv(
332
+ results: list[BulkPolicyResult], output_path: Path
333
+ ) -> None:
334
+ """Write bulk policy operation results to a CSV file.
335
+
336
+ Creates a CSV file containing the results of a bulk policy operation, with
337
+ columns for row number, user, policy type, policy name, status, and any error messages.
338
+
339
+ Args:
340
+ results: List of BulkPolicyResult objects to write.
341
+ output_path: Path where the results CSV should be written.
342
+
343
+ Raises:
344
+ ConfigurationError: If the file cannot be written due to permission
345
+ issues or if the parent directory cannot be created.
346
+
347
+ Note:
348
+ Uses UTF-8 encoding without BOM for output. Multiple error messages
349
+ are concatenated with semicolon delimiter.
350
+ """
351
+ # Ensure parent directory exists
352
+ try:
353
+ output_path.parent.mkdir(parents=True, exist_ok=True)
354
+ except PermissionError as e:
355
+ raise ConfigurationError(
356
+ f"Cannot create output directory {output_path.parent}: {e}",
357
+ remediation=(
358
+ f"Check that you have write permissions to {output_path.parent}.\n"
359
+ "You may need to create the directory manually or run with "
360
+ "elevated privileges."
361
+ ),
362
+ ) from e
363
+
364
+ # Write results to CSV
365
+ fieldnames = ["row", "user", "policy_type", "policy_name", "status", "error"]
366
+
367
+ try:
368
+ with output_path.open("w", encoding="utf-8", newline="") as f:
369
+ writer = csv.DictWriter(f, fieldnames=fieldnames)
370
+ writer.writeheader()
371
+
372
+ for result in results:
373
+ writer.writerow(
374
+ {
375
+ "row": result.row_number,
376
+ "user": result.user,
377
+ "policy_type": result.policy_type,
378
+ "policy_name": result.policy_name,
379
+ "status": result.status,
380
+ "error": result.error or "",
381
+ }
382
+ )
383
+ except PermissionError as e:
384
+ raise ConfigurationError(
385
+ f"Cannot write to output file {output_path}: {e}",
386
+ remediation=(
387
+ f"Check that you have write permissions to {output_path}.\n"
388
+ "The file may be open in another application or you may need "
389
+ "elevated privileges."
390
+ ),
391
+ ) from e
392
+
393
+
394
+ def write_results_csv(results: list[BulkAssignResult], output_path: Path) -> None:
395
+ """Write bulk operation results to a CSV file.
396
+
397
+ Creates a CSV file containing the results of a bulk operation, with
398
+ columns for row number, user, phone number, status, and any error messages.
399
+
400
+ Args:
401
+ results: List of BulkAssignResult objects to write.
402
+ output_path: Path where the results CSV should be written.
403
+
404
+ Raises:
405
+ ConfigurationError: If the file cannot be written due to permission
406
+ issues or if the parent directory cannot be created.
407
+
408
+ Note:
409
+ Uses UTF-8 encoding without BOM for output. Multiple error messages
410
+ are concatenated with semicolon delimiter.
411
+ """
412
+ # Ensure parent directory exists
413
+ try:
414
+ output_path.parent.mkdir(parents=True, exist_ok=True)
415
+ except PermissionError as e:
416
+ raise ConfigurationError(
417
+ f"Cannot create output directory {output_path.parent}: {e}",
418
+ remediation=(
419
+ f"Check that you have write permissions to {output_path.parent}.\n"
420
+ "You may need to create the directory manually or run with "
421
+ "elevated privileges."
422
+ ),
423
+ ) from e
424
+
425
+ # Write results to CSV
426
+ fieldnames = ["row", "user", "phone_number", "status", "error"]
427
+
428
+ try:
429
+ with output_path.open("w", encoding="utf-8", newline="") as f:
430
+ writer = csv.DictWriter(f, fieldnames=fieldnames)
431
+ writer.writeheader()
432
+
433
+ for result in results:
434
+ writer.writerow(
435
+ {
436
+ "row": result.row_number,
437
+ "user": result.user,
438
+ "phone_number": result.phone_number,
439
+ "status": result.status,
440
+ "error": result.error or "",
441
+ }
442
+ )
443
+ except PermissionError as e:
444
+ raise ConfigurationError(
445
+ f"Cannot write to output file {output_path}: {e}",
446
+ remediation=(
447
+ f"Check that you have write permissions to {output_path}.\n"
448
+ "The file may be open in another application or you may need "
449
+ "elevated privileges."
450
+ ),
451
+ ) from e
452
+
453
+
454
+ class BulkOperations:
455
+ """Bulk operations for phone number management.
456
+
457
+ Provides validation and execution of bulk operations such as
458
+ phone number assignments from CSV files.
459
+
460
+ Attributes:
461
+ user_service: Service for user resolution and lookup.
462
+ number_service: Service for phone number operations.
463
+ location_service: Service for emergency location validation.
464
+ """
465
+
466
+ def __init__(
467
+ self,
468
+ user_service: UserService,
469
+ number_service: NumberService,
470
+ location_service: LocationService,
471
+ ) -> None:
472
+ """Initialize BulkOperations with required services.
473
+
474
+ Args:
475
+ user_service: Service for resolving users by UPN, name, or ID.
476
+ number_service: Service for phone number lookup and operations.
477
+ location_service: Service for emergency location validation.
478
+ """
479
+ self.user_service = user_service
480
+ self.number_service = number_service
481
+ self.location_service = location_service
482
+
483
+ async def validate_assign_csv( # noqa: C901 - Multi-step validation with error aggregation
484
+ self, rows: list[BulkAssignRow]
485
+ ) -> BulkValidationResult:
486
+ """Validate bulk assignment rows against live services.
487
+
488
+ Validates each row by checking:
489
+ - User exists and can be resolved
490
+ - Phone number is in inventory and unassigned
491
+ - Location is valid (if provided)
492
+
493
+ Also generates warnings for:
494
+ - Stale location cache
495
+ - Calling Plan numbers without emergency location
496
+
497
+ Args:
498
+ rows: List of BulkAssignRow objects to validate.
499
+
500
+ Returns:
501
+ BulkValidationResult with validated rows and counts.
502
+ """
503
+ # Check cache staleness once at start
504
+ cache_stale = self.location_service.is_cache_stale()
505
+
506
+ for row in rows:
507
+ # Skip validation if row already has errors from parsing
508
+ # (missing user or phone_number)
509
+ if row.errors:
510
+ continue
511
+
512
+ # Add stale cache warning to each row
513
+ if cache_stale:
514
+ row.warnings.append(
515
+ "Location cache is stale; location validation may be inaccurate"
516
+ )
517
+
518
+ # Validate user existence
519
+ try:
520
+ await self.user_service.resolve_user(row.user)
521
+ except NotFoundError:
522
+ row.errors.append(f"User '{row.user}' not found")
523
+ except ValidationError as e:
524
+ row.errors.append(f"Ambiguous user: {e.message}")
525
+
526
+ # Validate phone number availability
527
+ number = None
528
+ try:
529
+ number = await self.number_service.get_number(row.phone_number)
530
+ if number.assignment_status != AssignmentStatus.UNASSIGNED:
531
+ row.errors.append(
532
+ f"Number '{row.phone_number}' is already assigned"
533
+ )
534
+ except NotFoundError:
535
+ row.errors.append(f"Number '{row.phone_number}' not in inventory")
536
+
537
+ # Validate location (if provided)
538
+ if row.location:
539
+ try:
540
+ self.location_service.validate_location(row.location)
541
+ except NotFoundError:
542
+ row.errors.append(f"Location '{row.location}' not found")
543
+
544
+ # Warn about Calling Plan numbers without location
545
+ if (
546
+ number is not None
547
+ and number.number_type == NumberType.CALLING_PLAN
548
+ and not row.location
549
+ ):
550
+ row.warnings.append("Calling Plan number without emergency location")
551
+
552
+ # Compute counts
553
+ valid_count = sum(1 for r in rows if not r.errors)
554
+ error_count = sum(1 for r in rows if r.errors)
555
+ warning_count = sum(1 for r in rows if r.warnings)
556
+
557
+ return BulkValidationResult(
558
+ rows=rows,
559
+ valid_count=valid_count,
560
+ error_count=error_count,
561
+ warning_count=warning_count,
562
+ )