# Task ID: 24

**Title:** Implement PolicyService

**Status:** pending

**Dependencies:** 5, 7, 9

**Priority:** medium

**Description:** Create the PolicyService for listing policies from CSV, showing user policy assignments, and assigning policies via Graph API.

**Details:**

Create `src/teams_phone/services/policy_service.py`:
```python
class PolicyService:
    def __init__(self, graph_client: GraphClient, cache_manager: CacheManager): ...
    
    def list_policies(self, policy_type: str | None = None) -> list[Policy]:
        """List available policies from CSV cache."""
        ...
    
    async def get_user_policies(self, user_id: str) -> list[PolicyAssignment]:
        """Get user's effective policy assignments (via UserService data)."""
        ...
    
    async def assign_policy(
        self,
        user_id: str,
        policy_type: str,
        policy_name: str,
    ) -> None:
        """Assign policy to user via Graph API."""
        # Validate policy exists in CSV cache
        # POST to /admin/teams/policy/userAssignments/assign
        ...
    
    def validate_policy(self, policy_type: str, policy_name: str) -> Policy:
        """Validate policy exists in cache. Raise NotFoundError if not."""
        ...
```

Supported policy types: TeamsCallingPolicy, TeamsVoicemailPolicy, CallingLineIdentity, TenantDialPlan, OnlineVoiceRoutingPolicy, TeamsEmergencyCallingPolicy, TeamsEmergencyCallRoutingPolicy.

**Test Strategy:**

Unit tests: list/validate from CSV fixtures, assign API call format, user policy retrieval, validation errors for unknown policies.

---

## Subtask 24.1 Implementation Notes

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

**Status:** Implemented

#### What Was Done
- Created `src/teams_phone/services/policy_service.py` with PolicyService class
- Implemented `__init__(graph_client, cache_manager)` constructor storing both dependencies
- Implemented `list_policies(policy_type=None)` with:
  - CSV reading via `cache_manager.read_policies_csv()`
  - Boolean conversion for `isGlobal` field (string "True"/"False" → bool)
  - Pydantic validation via `Policy.model_validate()`
  - Optional case-insensitive filtering by `policy_type`
  - Graceful degradation (empty list if CSV missing)
- Implemented `validate_policy(policy_type, policy_name)` with:
  - Case-insensitive matching on both type AND name
  - NotFoundError with remediation message referencing `teams-phone policies list`
- Implemented cache staleness methods: `get_cache_age()`, `is_cache_stale()`, `get_staleness_warning()`
- Added `SUPPORTED_POLICY_TYPES` constant for documentation
- Updated `services/__init__.py` to export PolicyService
- Created comprehensive unit tests (21 tests) in `tests/unit/test_policy_service.py`

#### Key Decisions Made
- **Constructor accepts both graph_client and cache_manager**: Required for future subtasks (24.2, 24.3) even though graph_client is not used in this subtask
- **SUPPORTED_POLICY_TYPES is not enforced**: List is for documentation only; list_policies doesn't reject unknown types
- **validate_policy requires both type AND name**: Unlike LocationService which accepts ID OR description, PolicyService requires explicit type+name match per recon recommendation

#### Patterns Discovered
- **Boolean conversion from CSV**: `row.get("isGlobal", "").lower() == "true"` - see `policy_service.py:71`
- **Case-insensitive filtering**: Apply filter after Pydantic validation, not during CSV read - see `policy_service.py:76-79`
- **Policy model has empty string for missing description**: Unlike LocationService's None, CSV empty cells become empty strings

#### Open Items for Next Agent
- [x] Subtask 24.2: Implement `get_user_policies()` method - DONE
- [ ] Subtask 24.3: Implement `assign_policy()` method with Graph API call

#### Gotchas and Warnings
- **Policy.description can be empty string**: The Policy model accepts empty string for description (not None) when CSV cell is empty
- **CacheManager.read_policies_csv() returns dict[str, str]**: All values are strings, must convert booleans before Pydantic validation
- **PolicyService differs from LocationService constructor**: LocationService only takes cache_manager; PolicyService takes both graph_client and cache_manager

---

## Subtask 24.2 Implementation Notes

### Session: 2026-01-29 21:18 - Agent: claude-sonnet-4-5-20250929

**Status:** Implemented

#### What Was Done
- Added `get_user_policies(user_id, policy_type=None)` method to PolicyService
- Added imports for `EffectivePolicyAssignment` and `UserConfiguration` from teams_phone.models
- Method fetches `/admin/teams/userConfigurations/{user_id}` via GraphClient
- Validates response with `UserConfiguration.model_validate(response)`
- Returns `effective_policy_assignments` field (list[EffectivePolicyAssignment])
- Implemented optional `policy_type` filter parameter (case-insensitive)
- Added 8 new unit tests for comprehensive coverage:
  - Returns EffectivePolicyAssignment objects
  - Returns empty list when no assignments
  - Propagates NotFoundError
  - Uses correct API endpoint
  - Filters by policy_type
  - Case-insensitive filter
  - No filter returns all policies
  - Unknown type filter returns empty list
- Created test helper functions: `make_user_config_dict()`, `make_policy_assignment_dict()`

#### Key Decisions Made
- **Added optional policy_type filter**: Task details said "Consider adding optional policy_type filter parameter" - implemented for completeness since it's a simple list comprehension
- **Followed UserService.get_user() pattern exactly**: Same endpoint, same validation approach, consistent with codebase conventions
- **Used GraphClient directly, not UserService**: Avoids circular dependencies as noted in task requirements

#### Patterns Discovered
- **EffectivePolicyAssignment is already nested in UserConfiguration**: No need for separate API call, policy assignments come with user config
- **Filtering pattern consistent with list_policies()**: Case-insensitive via `policy_type.lower()` comparison

#### Open Items for Next Agent
- [ ] Subtask 24.3: Implement `assign_policy()` method with Graph API POST call

#### Gotchas and Warnings
- **effective_policy_assignments can be empty**: Field defaults to empty list, no error for users without policy assignments
- **Return type is list[EffectivePolicyAssignment], not list[PolicyAssignment]**: The models differ; EffectivePolicyAssignment wraps policy_type + PolicyAssignmentDetail

---

## Subtask 24.3 Implementation Notes

### Session: 2026-01-29 21:50 - Agent: claude-sonnet-4-5-20250929

**Status:** Implemented

#### What Was Done
- Added `ValidationError` import to policy_service.py
- Added `ASSIGNABLE_POLICY_TYPES` constant (excludes TeamsVoicemailPolicy which is NOT supported for batch assignment via Graph API)
- Implemented `assign_policy(user_id, policy_type, policy_name)` method:
  - Validates TeamsVoicemailPolicy is rejected with ValidationError and PowerShell remediation
  - Calls `validate_policy()` to verify policy exists in cache and get policy_id
  - Builds request body per Graph API contract with `@odata.type`, `userId`, `policyType`, `policyId` in a `value` array
  - POSTs to `/admin/teams/policy/userAssignments/assign`
  - Uses `post_with_response()` instead of `post()` because response is 204 No Content (no JSON body)
- Added 11 new unit tests for comprehensive coverage:
  - Posts to correct endpoint
  - Builds correct request body structure
  - Uses policy_id from cache (not policy_name)
  - Raises ValidationError for TeamsVoicemailPolicy
  - Raises NotFoundError for unknown policy
  - Propagates Graph errors
  - Validates before API call
  - Returns None on success
  - ASSIGNABLE_POLICY_TYPES constant tests (3 tests)

#### Key Decisions Made
- **Use post_with_response() instead of post()**: The response is 204 No Content with no body. Using `post()` would fail trying to parse JSON from an empty response.
- **Validate policy_type before validate_policy()**: Check for TeamsVoicemailPolicy first to fail fast with a clear error message before even checking if the policy exists
- **Include PowerShell command in remediation**: Provides actionable guidance for users encountering the TeamsVoicemailPolicy limitation

#### Patterns Discovered
- **204 No Content response handling**: Use `post_with_response()` when Graph API returns no body - see `policy_service.py:261-264`
- **Validation before API call pattern**: Check invalid types before validating policy exists - see `policy_service.py:236-244`
- **Policy.policy_id is what goes to API**: The cache contains the opaque policy_id string, NOT policy_name - see `policy_service.py:257`

#### Open Items for Next Agent
- [x] All PolicyService subtasks complete (24.1, 24.2, 24.3)
- [ ] Task 24 ready to be marked done after this commit

#### Gotchas and Warnings
- **TeamsVoicemailPolicy is NOT supported**: Must use PowerShell `Grant-CsTeamsVoicemailPolicy` cmdlet instead
- **Response is 204 No Content**: Don't expect JSON response from this endpoint
- **ASSIGNABLE_POLICY_TYPES differs from SUPPORTED_POLICY_TYPES**: Assignable types exclude TeamsVoicemailPolicy