# Task Recon: Implement assign_policy method with Graph API POST and policy validation

**Task ID:** 24.3
**Status:** pending
**Generated:** 2026-01-29T21:50:00Z
**Agent:** claude-opus-4-5-20251101

---

## Task Summary

Add async `assign_policy` method to PolicyService that validates the policy exists in CSV cache, then POSTs to the Graph API `/admin/teams/policy/userAssignments/assign` endpoint to assign the policy to a user. The method should validate policy types, reject unsupported types (TeamsVoicemailPolicy), and return None on success (204 No Content response).

### Parent Task Context

**Parent Task:** Implement PolicyService (ID: 24)
**Parent Goal:** Create the PolicyService for listing policies from CSV, showing user policy assignments, and assigning policies via Graph API.
**This Subtask's Role:** This is the final subtask (3 of 3) that adds the write operation capability to PolicyService. Subtasks 24.1 and 24.2 implemented read operations (list_policies, validate_policy, get_user_policies). This subtask completes the service by enabling policy assignment to users.

### Dependencies
- Depends on: 24.1 (list_policies, validate_policy - DONE), 24.2 (get_user_policies - DONE)

### Acceptance Criteria
1. `assign_policy(user_id, policy_type, policy_name)` method validates policy via `validate_policy()` first
2. Method constructs correct request body per Graph API contract
3. POSTs to `/admin/teams/policy/userAssignments/assign`
4. Returns `None` on 204 No Content success
5. Raises `ValidationError` for unsupported policy types (especially TeamsVoicemailPolicy)
6. Raises `NotFoundError` if policy doesn't exist in cache
7. Graph API errors propagate from GraphClient

---

## Insights from Previous Tasks

### Dependency Task Notes

| Task ID | Title | Key Insight |
|---------|-------|-------------|
| 24.1 | Implement list_policies and validate_policy | `validate_policy(policy_type, policy_name)` is already implemented and returns Policy object with policy_id needed for API call |
| 24.2 | Implement get_user_policies | Established pattern for async methods using `graph_client.get()` directly; method returns immediately without polling |

### Reusable Patterns from Dependencies
- **Policy validation pattern**: `validate_policy()` returns a `Policy` object containing `policy_id` - see `policy_service.py:84-116`
- **Async method pattern**: Use `async def` with `await self.graph_client.post()` - see `policy_service.py:118-151`
- **SUPPORTED_POLICY_TYPES constant**: Already defined at `policy_service.py:21-29`

### Warnings and Gotchas
- **TeamsVoicemailPolicy is NOT supported** for batch assignment per Graph API docs - must raise `ValidationError` with remediation suggesting PowerShell
- **Policy.policy_id field** is what goes into the API request, NOT policy_name
- **Response is 204 No Content** - not JSON, so use `post_with_response()` or handle empty response from `post()`

### Open Items Inherited
- [x] Subtask 24.1: Implement list_policies and validate_policy - DONE
- [x] Subtask 24.2: Implement get_user_policies - DONE

---

## Architecture Context

### Relevant Design Decisions
- **Services abstract Graph API + CSV cache access** (repository pattern)
- **Pydantic strict mode** for fail-fast on API contract changes
- **Exception hierarchy** maps to CLI exit codes (ValidationError = exit code 5)

### Module Structure
```
src/teams_phone/
├── services/
│   └── policy_service.py  <-- Target file to modify
├── exceptions.py          <-- ValidationError, NotFoundError definitions
├── models/
│   └── policy.py          <-- Policy model with policy_id field
└── infrastructure/
    └── graph_client.py    <-- post() and post_with_response() methods
```

### Conventions to Follow
- All service methods that call Graph API are `async def`
- Use `ValidationError` for unsupported inputs (with remediation message)
- Use `NotFoundError` for missing resources
- Input validation happens BEFORE API calls
- Method signature: `async def method(self, params) -> ReturnType`

---

## Related Code References

### Primary References (Must Read)

| File | Lines | Purpose |
|------|-------|---------|
| `src/teams_phone/services/policy_service.py` | 84-116 | `validate_policy()` method - call first to get Policy object with policy_id |
| `src/teams_phone/services/policy_service.py` | 21-29 | `SUPPORTED_POLICY_TYPES` constant - use for validation |
| `src/teams_phone/services/policy_service.py` | 118-151 | `get_user_policies()` - pattern for async method using graph_client |
| `src/teams_phone/services/number_service.py` | 282-352 | `assign_number()` - pattern for POST with validation before API call |
| `src/teams_phone/infrastructure/graph_client.py` | 397-424 | `post()` method - returns dict from JSON response |
| `.taskmaster/docs/graph-api-contracts/3-policy-operations-partially-verified.md` | 65-110 | API contract for policy assignment endpoint |

### Secondary References (Helpful Context)

| File | Lines | Purpose |
|------|-------|---------|
| `src/teams_phone/exceptions.py` | 85-95 | `ValidationError` class definition |
| `src/teams_phone/exceptions.py` | 72-82 | `NotFoundError` class definition |
| `src/teams_phone/models/policy.py` | 6-26 | `Policy` model with `policy_id` field |

### Code Patterns to Follow

```python
# Pattern 1: Validation before API call (from number_service.py:312-317)
if number_type == NumberType.CALLING_PLAN and not location_id:
    raise ValidationError(
        f"Emergency location required for Calling Plan number {phone_number}",
        remediation="Use --location flag or set default_location in tenant config",
    )

# Pattern 2: Async method with graph_client.post (conceptual based on existing patterns)
async def assign_policy(
    self, user_id: str, policy_type: str, policy_name: str
) -> None:
    # 1. Validate policy_type is supported
    # 2. Call validate_policy to get Policy object
    # 3. Build request body
    # 4. POST to endpoint
    # 5. Return None (204 No Content)
    pass

# Pattern 3: Request body structure (from API contract)
body = {
    "value": [{
        "@odata.type": "#microsoft.graph.teamsAdministration.teamsPolicyUserAssignment",
        "userId": user_id,
        "policyType": policy_type,
        "policyId": policy.policy_id,  # From validated Policy object
    }]
}
```

---

## Dependencies

### Internal Modules
- `teams_phone.exceptions.ValidationError` - for unsupported policy type
- `teams_phone.exceptions.NotFoundError` - propagated from validate_policy
- `teams_phone.models.Policy` - returned by validate_policy, contains policy_id
- `teams_phone.infrastructure.GraphClient` - for POST request

### External Packages
- None required (uses existing httpx via GraphClient)

### New Dependencies Needed
- None required

---

## Testing Guidance

### Test File Location
`tests/unit/test_policy_service.py` (add to existing file)

### Test Patterns in Use

```python
# Pattern from test_policy_service.py:363-446
class TestPolicyServiceGetUserPolicies:
    @pytest.fixture
    def mock_graph_client(self) -> MagicMock:
        return MagicMock()

    @pytest.mark.unit
    @pytest.mark.asyncio
    async def test_get_user_policies_returns_effective_assignments(
        self, mock_graph_client: MagicMock, tmp_path: Path
    ) -> None:
        # Setup mocks
        mock_graph_client.get = AsyncMock(return_value=user_data)
        cache_manager = CacheManager(cache_dir=str(tmp_path))
        service = PolicyService(graph_client=mock_graph_client, cache_manager=cache_manager)

        # Call method
        result = await service.get_user_policies("user-123")

        # Assert
        assert len(result) == 2

# Pattern from test_number_service.py:1288-1306 - ValidationError test
@pytest.mark.unit
@pytest.mark.asyncio
async def test_calling_plan_without_location_raises_validation_error(
    self, mock_graph_client: MagicMock
) -> None:
    service = NumberService(mock_graph_client)

    with pytest.raises(ValidationError) as exc_info:
        await service.assign_number(...)

    assert "Emergency location required" in str(exc_info.value)
    assert exc_info.value.remediation is not None
```

### Fixtures Available
- `tmp_path` - pytest built-in for temporary directory
- `mock_graph_client` fixture - MagicMock with AsyncMock for async methods

### Test Utilities
- `tests/unit/test_policy_service.py:300-327` - `make_user_config_dict()` helper
- `tests/unit/test_policy_service.py:330-360` - `make_policy_assignment_dict()` helper
- Use `POLICIES_CSV` constant (line 16-20) for setting up CSV cache

---

## Risks and Blockers

### Technical Risks
- [ ] **204 No Content response handling**: `GraphClient.post()` expects JSON response and calls `response.json()`. For 204 No Content, this may raise an error. May need to use `post_with_response()` and check status code, or modify approach.
  - Mitigation: Check GraphClient behavior for 204; may need to catch JSON decode error or use different method

### Dependency Risks
- None - all dependencies (24.1, 24.2) are complete

### Open Questions
- [ ] Does `GraphClient.post()` handle 204 No Content gracefully, or should we use `post_with_response()` and check the status code?
- [ ] Should we validate policy_type against `SUPPORTED_POLICY_TYPES` (enforce) or just reject TeamsVoicemailPolicy specifically?

---

## Implementation Recommendations

### Suggested Approach
1. Add constant for assignable policy types (exclude TeamsVoicemailPolicy from SUPPORTED_POLICY_TYPES for assignment)
2. Implement input validation:
   - Check policy_type is not "TeamsVoicemailPolicy" (raise ValidationError with PowerShell remediation)
   - Optionally validate against supported types list
3. Call `self.validate_policy(policy_type, policy_name)` to get Policy object with policy_id
4. Build request body per API contract (with `@odata.type`, `value` array wrapper)
5. POST to `/admin/teams/policy/userAssignments/assign`
6. Handle 204 response (return None)
7. Write comprehensive unit tests

### Files to Create/Modify

| Action | File | Reason |
|--------|------|--------|
| Modify | `src/teams_phone/services/policy_service.py` | Add `assign_policy()` method and `ASSIGNABLE_POLICY_TYPES` constant |
| Modify | `tests/unit/test_policy_service.py` | Add test class for assign_policy |

### Quick Wins
- Start by implementing the ValidationError for TeamsVoicemailPolicy - this is a simple check with clear expected behavior

---

## Cheat Sheet

### Key Imports
```python
# Already in policy_service.py
from teams_phone.exceptions import NotFoundError
from teams_phone.models import Policy

# Add this import
from teams_phone.exceptions import ValidationError
```

### Key Functions to Use
- `self.validate_policy(policy_type, policy_name) -> Policy` - returns Policy with policy_id
- `await self.graph_client.post(endpoint, body) -> dict[str, Any]` - POST request
- `await self.graph_client.post_with_response(endpoint, body) -> httpx.Response` - if 204 handling needed

### Common Patterns
- **Validation pattern**: See `number_service.py:312-317`
- **Async POST pattern**: See `number_service.py:329-332`
- **Request body building**: See `number_service.py:319-326`

### API Contract Quick Reference
```
Endpoint: POST /admin/teams/policy/userAssignments/assign
Permission: TeamsPolicyUserAssign.ReadWrite.All
Response: 204 No Content

Request Body:
{
  "value": [{
    "@odata.type": "#microsoft.graph.teamsAdministration.teamsPolicyUserAssignment",
    "userId": "<user_id>",
    "policyType": "<policy_type>",
    "policyId": "<policy.policy_id>"
  }]
}

Unsupported for batch assignment: TeamsVoicemailPolicy
```

---

*Generated by task-recon (claude-opus-4-5-20251101) | Ready for implementation*
