# Task ID: 31

**Title:** Implement Policies Bulk-Assign Command

**Status:** pending

**Dependencies:** 24, 25, 26

**Priority:** low

**Description:** Add bulk-assign command to policies CLI for assigning policies to multiple users from CSV.

**Details:**

Extend `src/teams_phone/cli/policies.py`:
```python
@policies_app.command("bulk-assign")
def bulk_assign(
    ctx: typer.Context,
    csv_file: Path = typer.Argument(..., help="CSV with user, policy_type, policy_name columns"),
    dry_run: bool = typer.Option(False, "--dry-run"),
    continue_on_error: bool = typer.Option(False, "--continue-on-error"),
    output: Optional[Path] = typer.Option(None, "--output", "-o"),
    yes: bool = typer.Option(False, "--yes", "-y"),
):
    """Bulk assign policies from CSV file."""
    ...
```

CSV columns: user, policy_type, policy_name
Validation: user exists, policy type valid, policy name exists in cache.

**Test Strategy:**

CLI tests: CSV parsing, validation errors, progress display, results file, continue-on-error behavior.

---

## Implementation Notes

### Session: 2026-01-30 05:30 - Agent: claude-sonnet-4-5-20250929

**Status:** Subtask 31.1 Implemented (CSV parsing and validation phase)

#### What Was Done (Subtask 31.1)

1. **Created dataclasses for policy bulk operations** in `bulk_operations.py:80-133`:
   - `BulkPolicyRow`: CSV row with `user`, `policy_type`, `policy_name`, errors, warnings
   - `BulkPolicyValidationResult`: Validation result with counts
   - `BulkPolicyResult`: Execution result for output CSV

2. **Created `parse_policy_csv()` function** in `bulk_operations.py:239-323`:
   - Validates required columns: `user`, `policy_type`, `policy_name`
   - Uses UTF-8-sig encoding for BOM handling
   - Row numbers 1-indexed starting at 2 (after header)
   - Collects per-row errors for missing required fields

3. **Created `write_policy_results_csv()` function** in `bulk_operations.py:326-386`:
   - Writes results CSV with columns: row, user, policy_type, policy_name, status, error
   - Creates parent directories if needed

4. **Added `bulk-assign` command** to `policies.py:526-694`:
   - CSV parsing with `parse_policy_csv()`
   - Policy type resolution using `_resolve_policy_type()`
   - Validation via `_validate_policy_rows()` helper
   - TeamsVoicemailPolicy rejection with clear error message
   - Dry-run mode with validation summary
   - Confirmation prompt (unless --yes or --json)
   - Placeholder for execution (subtask 31.2)

5. **Created `_validate_policy_rows()` helper** in `policies.py:458-523`:
   - Resolves CLI aliases to API names
   - Validates policy type is known (rejects unknown types like "invalid-type")
   - Rejects TeamsVoicemailPolicy with PowerShell cmdlet suggestion
   - Validates policy exists in cache via `PolicyService.validate_policy()`

6. **Added 14 tests for bulk-assign command** in `test_cli_policies.py:778-1152`:
   - Help text display
   - Dry-run with valid CSV
   - JSON output format
   - Validation error display
   - Voicemail policy rejection
   - Invalid policy type handling
   - Policy type alias resolution
   - Empty CSV handling
   - Missing columns error
   - Staleness warning display
   - Continue-on-error behavior
   - Missing required fields in rows

7. **Added tests for policy CSV parsing** in `test_bulk_operations.py`:
   - TestBulkPolicyRow: dataclass creation, defaults, mutable independence
   - TestBulkPolicyValidationResult: dataclass creation
   - TestParsePolicyCsv: parsing, BOM handling, errors, missing columns, whitespace
   - TestBulkPolicyResult: dataclass with different statuses
   - TestWritePolicyResultsCsv: file writing, UTF-8, directories, empty list

#### Key Decisions Made

1. **Unknown policy type handling**: Changed `_validate_policy_rows()` to check if resolved type is in `POLICY_TYPE_MAP.values()`, not just `None`. This catches cases where `_resolve_policy_type()` returns the original unknown type as-is.

2. **Placeholder for execution**: The command validates and shows confirmation but marks valid rows as "pending" with a note about subtask 31.2. This allows the validation phase to be fully tested independently.

3. **Staleness warning display**: Calls `_check_staleness()` before validation to warn users if the policy cache is old.

#### Patterns Discovered

- **Policy type validation**: Use `POLICY_TYPE_MAP.values()` set for O(1) lookup of valid API names
- **CLI test assertions**: Use partial string matches (`"Errors:" in result.stdout`) instead of exact matches to handle ANSI escape codes from Rich formatting
- **Circular import avoidance**: CLI tests work because they import through `teams_phone.cli.main`, not directly from services

#### Open Items for Next Agent (Subtask 31.2)

- [ ] Implement execution phase with user resolution via `UserService.resolve_user()`
- [ ] Add progress bar using `formatter.progress()` context manager
- [ ] Call `PolicyService.assign_policy()` for each valid row
- [ ] Track success/fail/skipped counts during execution
- [ ] Write results CSV using `write_policy_results_csv()`
- [ ] Handle KeyboardInterrupt for graceful cancellation
- [ ] Display execution summary with counts

#### Gotchas and Warnings

- **TeamsVoicemailPolicy**: Not supported for batch assignment via Graph API. Must use PowerShell cmdlet `Grant-CsTeamsVoicemailPolicy`. Our validation rejects this upfront with a clear error message.

- **`_resolve_policy_type()` behavior**: Returns unknown types as-is (not `None`), so validation must explicitly check against known API names.

- **Circular import in tests**: Importing directly from `teams_phone.services.bulk_operations` causes circular import issues in `test_bulk_operations.py` via `services.__init__`. The CLI tests work because they import through `teams_phone.cli.main`.

---

### Session: 2026-01-30 06:15 - Agent: claude-sonnet-4-5-20250929

**Status:** Subtask 31.2 Implemented (Execution phase with progress display and results output)

#### What Was Done (Subtask 31.2)

1. **Added required imports** to `policies.py`:
   - `datetime` for timestamp-based output filenames
   - `ConfigurationError` for error handling during results file writing
   - `write_policy_results_csv` for writing results to CSV

2. **Replaced placeholder code** in `policies.py:693-821` with full execution loop:
   - Initializes success/fail counters and results list
   - Adds skipped results for rows with validation errors
   - Uses `formatter.progress()` context manager for Rich progress bar
   - Inner async function `_execute_with_progress()` processes valid rows
   - Resolves user via `user_service.resolve_user(row.user)`
   - Calls `policy_service.assign_policy(user_id, policy_type, policy_name)`
   - Tracks results with `BulkPolicyResult` dataclass (status: success/failed/skipped)
   - Respects `--continue-on-error` flag for error handling
   - Marks remaining rows as skipped if stopped early

3. **Added KeyboardInterrupt handling** at `policies.py:795-807`:
   - Catches Ctrl+C gracefully
   - Displays "Interrupted. Processing partial results..." message
   - Marks unprocessed rows as "Cancelled by user"

4. **Added results file writing** at `policies.py:809-822`:
   - Uses `--output` path if provided, else generates timestamp-based name: `bulk-policy-results-YYYYMMDD-HHMMSS.csv`
   - Calls `write_policy_results_csv()` with proper error handling
   - Warns (but doesn't fail) if results file cannot be written

5. **Added execution summary display** at `policies.py:824-856`:
   - Shows success/fail/skipped counts
   - JSON mode: structured output with full results array
   - Text mode: formatted summary with results file path
   - Exits with code 1 if any failures, else 0

6. **Updated existing test** `test_bulk_assign_continue_on_error_flag`:
   - Added mocks for GraphClient, PolicyService, UserService for actual execution
   - Updated assertion from "ready for assignment" to "Execution Summary:"

7. **Added 6 new execution tests** in `test_cli_policies.py:1177-1406`:
   - `test_bulk_assign_execution_success`: Verifies successful assignment flow
   - `test_bulk_assign_execution_json_output`: Validates JSON structure with results
   - `test_bulk_assign_results_csv_written`: Confirms results file creation
   - `test_bulk_assign_continue_on_error_execution`: Tests processing all rows despite failures
   - `test_bulk_assign_stop_on_first_error`: Verifies early termination without flag
   - `test_bulk_assign_summary_counts_displayed`: Validates summary output

#### Key Decisions Made

1. **Results file naming**: Used `bulk-policy-results-` prefix (not `bulk-assign-results-` like numbers) to distinguish policy results from number results.

2. **Policy assignment is synchronous**: Unlike `NumberService.assign_number()` which returns 202 and requires polling, `PolicyService.assign_policy()` completes immediately. No operation status checking needed.

3. **Mock pattern for tests**: Mocked infrastructure classes directly (GraphClient, PolicyService, UserService) rather than `_run_async`, following the established pattern in `test_cli_numbers.py`.

#### Patterns Discovered

- **Progress bar update pattern**: `progress.update(task_id, description=desc, advance=1)` - must get `task_id` from `list(progress.task_ids)[0]`
- **Async inner function with nonlocal**: Use `nonlocal success_count, fail_count` to modify outer scope counters from within async function
- **Test mock structure**: Use `AsyncMock` for async methods, `MagicMock` for sync methods; mock GraphClient's `__aenter__` and `__aexit__` for context manager

#### Open Items for Next Agent

- [x] All subtasks complete - Task 31 is fully implemented

#### Gotchas and Warnings

- **Policy assignment simplicity**: Unlike number assignment, policy assignment doesn't need polling or operation status checks. This makes the execution loop simpler than `numbers bulk-assign`.

- **Test mock setup**: Tests must mock all of: `_get_sync_service` (for validation), `CacheManager`, `AuthService`, `GraphClient`, `PolicyService`, `UserService`. Missing any will cause the test to hit real infrastructure.

---
