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