diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..dbe51ca --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,33 @@ +name: Tests + +permissions: + contents: read + +on: + push: + branches: [master, main] + pull_request: + branches: [master, main] + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v4 + + - name: Set up Python + run: uv python install 3.11 + + - name: Install dependencies + run: uv sync --all-extras --dev + + - name: Run tests (mocked only) + run: uv run pytest -m "not uses_real_data" -n auto + + - name: Check code quality + run: | + uv run ruff check . + uv run ruff format --check . diff --git a/README.md b/README.md index 78db114..fa1de13 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,46 @@ The models are based on the Pydantic BaseModel, so it is possible to build an en - **Translation**: Multi-language translation support - **OFSAPIError**: Standardized API error responses +## Testing + +pyOFSC includes a comprehensive test suite with 500+ tests. Tests run in parallel by default for faster execution. + +### Running Tests + +```bash +# Run all tests in parallel (auto-detect CPU cores) +uv run pytest -n auto + +# Run tests with specific number of workers +uv run pytest -n 4 + +# Run only mocked tests (no API credentials needed) +uv run pytest -m "not uses_real_data" -n auto + +# Run tests sequentially (if needed) +uv run pytest -n 0 + +# Run specific test file +uv run pytest tests/async/test_async_workzones.py -n auto +``` + +### Test Requirements + +- **Mocked tests**: No special requirements, use saved API responses +- **Live tests** (marked with `@pytest.mark.uses_real_data`): Require API credentials in `.env` file: + ``` + OFSC_CLIENT_ID=your_client_id + OFSC_COMPANY=your_company + OFSC_CLIENT_SECRET=your_secret + ``` + +### Test Markers + +- `@pytest.mark.uses_real_data` - Tests that require API credentials +- `@pytest.mark.serial` - Tests that must run sequentially (modify shared API state) +- `@pytest.mark.slow` - Slow-running tests +- `@pytest.mark.integration` - Integration tests + ## Functions implemented diff --git a/docs/ENDPOINTS.md b/docs/ENDPOINTS.md index 53be324..27d63ed 100644 --- a/docs/ENDPOINTS.md +++ b/docs/ENDPOINTS.md @@ -1,7 +1,7 @@ # OFSC API Endpoints Reference **Version:** 2.21.0 -**Last Updated:** 2025-12-29 +**Last Updated:** 2025-12-31 This document provides a comprehensive reference of all Oracle Field Service Cloud (OFSC) API endpoints and their implementation status in pyOFSC. @@ -121,31 +121,31 @@ This document provides a comprehensive reference of all Oracle Field Service Clo |CB005G|`/rest/ofscCollaboration/v1/chats/{chatId}/participants` |collaboration|GET |- | |CB006P|`/rest/ofscCollaboration/v1/chats/{chatId}/participants/invite` |collaboration|POST |- | |CO001P|`/rest/ofscCore/v1/activities` |core |POST |- | -|CO001G|`/rest/ofscCore/v1/activities` |core |GET |sync | +|CO001G|`/rest/ofscCore/v1/activities` |core |GET |both | |CO002A|`/rest/ofscCore/v1/activities/{activityId}` |core |PATCH |sync | |CO002D|`/rest/ofscCore/v1/activities/{activityId}` |core |DELETE|sync | -|CO002G|`/rest/ofscCore/v1/activities/{activityId}` |core |GET |sync | +|CO002G|`/rest/ofscCore/v1/activities/{activityId}` |core |GET |both | |CO003G|`/rest/ofscCore/v1/activities/{activityId}/multidaySegments` |core |GET |- | |CO004U|`/rest/ofscCore/v1/activities/{activityId}/{propertyLabel}` |core |PUT |- | -|CO004G|`/rest/ofscCore/v1/activities/{activityId}/{propertyLabel}` |core |GET |sync | +|CO004G|`/rest/ofscCore/v1/activities/{activityId}/{propertyLabel}` |core |GET |both | |CO004D|`/rest/ofscCore/v1/activities/{activityId}/{propertyLabel}` |core |DELETE|- | -|CO005G|`/rest/ofscCore/v1/activities/{activityId}/submittedForms` |core |GET |- | +|CO005G|`/rest/ofscCore/v1/activities/{activityId}/submittedForms` |core |GET |async | |CO006U|`/rest/ofscCore/v1/activities/{activityId}/resourcePreferences` |core |PUT |- | -|CO006G|`/rest/ofscCore/v1/activities/{activityId}/resourcePreferences` |core |GET |- | +|CO006G|`/rest/ofscCore/v1/activities/{activityId}/resourcePreferences` |core |GET |async | |CO006D|`/rest/ofscCore/v1/activities/{activityId}/resourcePreferences` |core |DELETE|- | |CO007U|`/rest/ofscCore/v1/activities/{activityId}/requiredInventories` |core |PUT |- | -|CO007G|`/rest/ofscCore/v1/activities/{activityId}/requiredInventories` |core |GET |- | +|CO007G|`/rest/ofscCore/v1/activities/{activityId}/requiredInventories` |core |GET |async | |CO007D|`/rest/ofscCore/v1/activities/{activityId}/requiredInventories` |core |DELETE|- | |CO008P|`/rest/ofscCore/v1/activities/{activityId}/customerInventories` |core |POST |- | -|CO008G|`/rest/ofscCore/v1/activities/{activityId}/customerInventories` |core |GET |- | -|CO009G|`/rest/ofscCore/v1/activities/{activityId}/installedInventories` |core |GET |- | -|CO010G|`/rest/ofscCore/v1/activities/{activityId}/deinstalledInventories` |core |GET |- | -|CO011G|`/rest/ofscCore/v1/activities/{activityId}/linkedActivities` |core |GET |- | +|CO008G|`/rest/ofscCore/v1/activities/{activityId}/customerInventories` |core |GET |async | +|CO009G|`/rest/ofscCore/v1/activities/{activityId}/installedInventories` |core |GET |async | +|CO010G|`/rest/ofscCore/v1/activities/{activityId}/deinstalledInventories` |core |GET |async | +|CO011G|`/rest/ofscCore/v1/activities/{activityId}/linkedActivities` |core |GET |async | |CO011D|`/rest/ofscCore/v1/activities/{activityId}/linkedActivities` |core |DELETE|- | |CO011P|`/rest/ofscCore/v1/activities/{activityId}/linkedActivities` |core |POST |- | -|CO012G|`/rest/ofscCore/v1/activities/{activityId}/capacityCategories` |core |GET |- | +|CO012G|`/rest/ofscCore/v1/activities/{activityId}/capacityCategories` |core |GET |async | |CO013D|`/rest/ofscCore/v1/activities/{activityId}/linkedActivities/{linkedActivityId}/linkTypes/{linkType}` |core |DELETE|- | -|CO013G|`/rest/ofscCore/v1/activities/{activityId}/linkedActivities/{linkedActivityId}/linkTypes/{linkType}` |core |GET |- | +|CO013G|`/rest/ofscCore/v1/activities/{activityId}/linkedActivities/{linkedActivityId}/linkTypes/{linkType}` |core |GET |async | |CO013U|`/rest/ofscCore/v1/activities/{activityId}/linkedActivities/{linkedActivityId}/linkTypes/{linkType}` |core |PUT |- | |CO014G|`/rest/ofscCore/v1/activities/custom-actions/search` |core |GET |sync | |CO015P|`/rest/ofscCore/v1/activities/custom-actions/bulkUpdate` |core |POST |sync | @@ -266,12 +266,12 @@ This document provides a comprehensive reference of all Oracle Field Service Clo ## Implementation Summary -- **Sync only**: 48 endpoints -- **Async only**: 12 endpoints -- **Both**: 41 endpoints -- **Not implemented**: 142 endpoints +- **Sync only**: 45 endpoints +- **Async only**: 21 endpoints +- **Both**: 44 endpoints +- **Not implemented**: 133 endpoints - **Total sync**: 89 endpoints -- **Total async**: 53 endpoints +- **Total async**: 65 endpoints ## Implementation Statistics by Module and Method @@ -293,13 +293,13 @@ This document provides a comprehensive reference of all Oracle Field Service Clo | Module | GET |Write (POST/PUT/PATCH)| DELETE | Total | |-------------|------------------|----------------------|---------------|------------------| |metadata |41/51 (80.4%) |10/30 (33.3%) |2/5 (40.0%) |53/86 (61.6%) | -|core |0/51 (0.0%) |0/56 (0.0%) |0/20 (0.0%) |0/127 (0.0%) | +|core |12/51 (23.5%) |0/56 (0.0%) |0/20 (0.0%) |12/127 (9.4%) | |capacity |0/7 (0.0%) |0/5 (0.0%) |0/0 (0%) |0/12 (0.0%) | |statistics |0/3 (0.0%) |0/3 (0.0%) |0/0 (0%) |0/6 (0.0%) | |partscatalog |0/0 (0%) |0/2 (0.0%) |0/1 (0.0%) |0/3 (0.0%) | |collaboration|0/3 (0.0%) |0/4 (0.0%) |0/0 (0%) |0/7 (0.0%) | |auth |0/0 (0%) |0/2 (0.0%) |0/0 (0%) |0/2 (0.0%) | -|**Total** |**41/115 (35.7%)**|**10/102 (9.8%)** |**2/26 (7.7%)**|**53/243 (21.8%)**| +|**Total** |**53/115 (46.1%)**|**10/102 (9.8%)** |**2/26 (7.7%)**|**65/243 (26.7%)**| ## Endpoint ID Reference diff --git a/ofsc/async_client/core.py b/ofsc/async_client/core.py index 58d88b8..8a31d8c 100644 --- a/ofsc/async_client/core.py +++ b/ofsc/async_client/core.py @@ -2,23 +2,53 @@ from datetime import date from typing import Optional +from urllib.parse import urljoin import httpx +from ..exceptions import ( + OFSCApiError, + OFSCAuthenticationError, + OFSCAuthorizationError, + OFSCConflictError, + OFSCNetworkError, + OFSCNotFoundError, + OFSCRateLimitError, + OFSCServerError, + OFSCValidationError, +) from ..models import ( Activity, + ActivityCapacityCategoriesResponse, + ActivityListResponse, AssignedLocationsResponse, BulkUpdateRequest, CalendarView, + CalendarsListResponse, DailyExtractFiles, DailyExtractFolders, + GetActivitiesParams, + InventoryListResponse, + LinkedActivitiesResponse, + LinkedActivity, Location, LocationListResponse, OFSConfig, OFSResponseList, + PositionHistoryResponse, + RequiredInventoriesResponse, + Resource, + ResourceAssistantsResponse, + ResourceListResponse, + ResourcePlansResponse, + ResourcePreferencesResponse, + ResourceRouteResponse, ResourceUsersListResponse, ResourceWorkScheduleItem, ResourceWorkScheduleResponse, + ResourceWorkskillListResponse, + ResourceWorkzoneListResponse, + SubmittedFormsResponse, ) @@ -49,13 +79,179 @@ def headers(self) -> dict: raise NotImplementedError("Token-based auth not yet implemented for async") return headers + def _parse_error_response(self, response: httpx.Response) -> dict: + """Parse OFSC error response format. + + OFSC API returns errors in the format: + { + "type": "string", + "title": "string", + "detail": "string" + } + + :param response: The httpx Response object + :type response: httpx.Response + :return: Error information with type, title, and detail keys + :rtype: dict + """ + try: + error_data = response.json() + return { + "type": error_data.get("type", "about:blank"), + "title": error_data.get("title", ""), + "detail": error_data.get("detail", response.text), + } + except Exception: + # If response is not JSON or doesn't match format + return { + "type": "about:blank", + "title": f"HTTP {response.status_code}", + "detail": response.text, + } + + def _handle_http_error(self, e: httpx.HTTPStatusError, context: str = "") -> None: + """Convert httpx exceptions to OFSC exceptions with error details. + + :param e: The httpx HTTPStatusError exception + :type e: httpx.HTTPStatusError + :param context: Additional context for the error message + :type context: str + :raises OFSCAuthenticationError: For 401 errors + :raises OFSCAuthorizationError: For 403 errors + :raises OFSCNotFoundError: For 404 errors + :raises OFSCConflictError: For 409 errors + :raises OFSCRateLimitError: For 429 errors + :raises OFSCValidationError: For 400, 422 errors + :raises OFSCServerError: For 5xx errors + :raises OFSCApiError: For other HTTP errors + """ + status = e.response.status_code + error_info = self._parse_error_response(e.response) + + # Build message with detail + message = ( + f"{context}: {error_info['detail']}" if context else error_info["detail"] + ) + + error_map = { + 401: OFSCAuthenticationError, + 403: OFSCAuthorizationError, + 404: OFSCNotFoundError, + 409: OFSCConflictError, + 429: OFSCRateLimitError, + } + + if status in error_map: + raise error_map[status]( + message, + status_code=status, + response=e.response, + error_type=error_info["type"], + title=error_info["title"], + detail=error_info["detail"], + ) from e + elif 400 <= status < 500: + raise OFSCValidationError( + message, + status_code=status, + response=e.response, + error_type=error_info["type"], + title=error_info["title"], + detail=error_info["detail"], + ) from e + elif 500 <= status < 600: + raise OFSCServerError( + message, + status_code=status, + response=e.response, + error_type=error_info["type"], + title=error_info["title"], + detail=error_info["detail"], + ) from e + else: + raise OFSCApiError( + message, + status_code=status, + response=e.response, + error_type=error_info["type"], + title=error_info["title"], + detail=error_info["detail"], + ) from e + # region Activities - async def get_activities(self, params: dict): - raise NotImplementedError("Async method not yet implemented") + async def get_activities( + self, params: GetActivitiesParams | dict, offset: int = 0, limit: int = 100 + ) -> ActivityListResponse: + """Get activities list with filters and pagination. + + :param params: Query parameters (accepts GetActivitiesParams or dict) + :type params: GetActivitiesParams | dict + :param offset: Starting record number (default 0) + :type offset: int + :param limit: Maximum number to return (default 100) + :type limit: int + :return: List of activities with pagination info + :rtype: ActivityListResponse + :raises OFSCAuthenticationError: If authentication fails (401) + :raises OFSCAuthorizationError: If authorization fails (403) + :raises OFSCValidationError: If parameters are invalid (400) + :raises OFSCApiError: For other API errors + :raises OFSCNetworkError: For network/transport errors + """ + # Validate params through model + if isinstance(params, dict): + validated_params = GetActivitiesParams.model_validate(params) + else: + validated_params = params - async def get_activity(self, activity_id: int): - raise NotImplementedError("Async method not yet implemented") + # Convert to API params and add pagination + api_params = validated_params.to_api_params() + api_params["offset"] = offset + api_params["limit"] = limit + + url = urljoin(self.baseUrl, "/rest/ofscCore/v1/activities") + + try: + response = await self._client.get( + url, headers=self.headers, params=api_params + ) + response.raise_for_status() + data = response.json() + + return ActivityListResponse.model_validate(data) + except httpx.HTTPStatusError as e: + self._handle_http_error(e, "Failed to get activities") + raise # This will never execute, but satisfies type checker + except httpx.TransportError as e: + raise OFSCNetworkError(f"Network error: {str(e)}") from e + + async def get_activity(self, activity_id: int) -> Activity: + """Get a single activity by ID. + + :param activity_id: The unique identifier of the activity + :type activity_id: int + :return: Activity details + :rtype: Activity + :raises OFSCAuthenticationError: If authentication fails (401) + :raises OFSCAuthorizationError: If authorization fails (403) + :raises OFSCNotFoundError: If activity not found (404) + :raises OFSCApiError: For other API errors + :raises OFSCNetworkError: For network/transport errors + """ + url = urljoin(self.baseUrl, f"/rest/ofscCore/v1/activities/{activity_id}") + + try: + response = await self._client.get(url, headers=self.headers) + response.raise_for_status() + data = response.json() + + return Activity.model_validate(data) + except httpx.HTTPStatusError as e: + self._handle_http_error(e, f"Failed to get activity {activity_id}") + raise # This will never execute, but satisfies type checker + except httpx.TransportError as e: + raise OFSCNetworkError(f"Network error: {str(e)}") from e async def update_activity(self, activity_id: int, data): raise NotImplementedError("Async method not yet implemented") @@ -72,13 +268,372 @@ async def move_activity(self, activity_id: int, data): async def bulk_update(self, data: BulkUpdateRequest): raise NotImplementedError("Async method not yet implemented") + async def get_capacity_categories( + self, activity_id: int + ) -> ActivityCapacityCategoriesResponse: + """Get capacity categories for an activity. + + :param activity_id: The unique identifier of the activity + :type activity_id: int + :return: Capacity categories with totalResults + :rtype: ActivityCapacityCategoriesResponse + :raises OFSCAuthenticationError: If authentication fails (401) + :raises OFSCAuthorizationError: If authorization fails (403) + :raises OFSCNotFoundError: If activity not found (404) + :raises OFSCApiError: For other API errors + :raises OFSCNetworkError: For network/transport errors + """ + url = urljoin( + self.baseUrl, + f"/rest/ofscCore/v1/activities/{activity_id}/capacityCategories", + ) + + try: + response = await self._client.get(url, headers=self.headers) + response.raise_for_status() + data = response.json() + + return ActivityCapacityCategoriesResponse.model_validate(data) + except httpx.HTTPStatusError as e: + self._handle_http_error( + e, f"Failed to get capacity categories for activity {activity_id}" + ) + raise + except httpx.TransportError as e: + raise OFSCNetworkError(f"Network error: {str(e)}") from e + + async def get_customer_inventories( + self, activity_id: int, offset: int = 0, limit: int = 100 + ) -> InventoryListResponse: + """Get customer inventories for an activity. + + :param activity_id: The unique identifier of the activity + :type activity_id: int + :param offset: Starting record number (default 0) + :type offset: int + :param limit: Maximum number to return (default 100) + :type limit: int + :return: List of customer inventories + :rtype: InventoryListResponse + :raises OFSCAuthenticationError: If authentication fails (401) + :raises OFSCAuthorizationError: If authorization fails (403) + :raises OFSCNotFoundError: If activity not found (404) + :raises OFSCApiError: For other API errors + :raises OFSCNetworkError: For network/transport errors + """ + url = urljoin( + self.baseUrl, + f"/rest/ofscCore/v1/activities/{activity_id}/customerInventories", + ) + params = {"offset": offset, "limit": limit} + + try: + response = await self._client.get(url, headers=self.headers, params=params) + response.raise_for_status() + data = response.json() + + return InventoryListResponse.model_validate(data) + except httpx.HTTPStatusError as e: + self._handle_http_error( + e, f"Failed to get customer inventories for activity {activity_id}" + ) + raise + except httpx.TransportError as e: + raise OFSCNetworkError(f"Network error: {str(e)}") from e + + async def get_deinstalled_inventories( + self, activity_id: int, offset: int = 0, limit: int = 100 + ) -> InventoryListResponse: + """Get deinstalled inventories for an activity. + + :param activity_id: The unique identifier of the activity + :type activity_id: int + :param offset: Starting record number (default 0) + :type offset: int + :param limit: Maximum number to return (default 100) + :type limit: int + :return: List of deinstalled inventories + :rtype: InventoryListResponse + :raises OFSCAuthenticationError: If authentication fails (401) + :raises OFSCAuthorizationError: If authorization fails (403) + :raises OFSCNotFoundError: If activity not found (404) + :raises OFSCApiError: For other API errors + :raises OFSCNetworkError: For network/transport errors + """ + url = urljoin( + self.baseUrl, + f"/rest/ofscCore/v1/activities/{activity_id}/deinstalledInventories", + ) + params = {"offset": offset, "limit": limit} + + try: + response = await self._client.get(url, headers=self.headers, params=params) + response.raise_for_status() + data = response.json() + + return InventoryListResponse.model_validate(data) + except httpx.HTTPStatusError as e: + self._handle_http_error( + e, f"Failed to get deinstalled inventories for activity {activity_id}" + ) + raise + except httpx.TransportError as e: + raise OFSCNetworkError(f"Network error: {str(e)}") from e + async def get_file_property( self, - activityId: int, + activity_id: int, label: str, - mediaType: str = "application/octet-stream", - ): - raise NotImplementedError("Async method not yet implemented") + media_type: str = "application/octet-stream", + ) -> bytes: + """Get file property content for an activity. + + :param activity_id: The unique identifier of the activity + :type activity_id: int + :param label: The label of the file property + :type label: str + :param media_type: MIME type for Accept header (default: application/octet-stream) + :type media_type: str + :return: Binary file content + :rtype: bytes + :raises OFSCAuthenticationError: If authentication fails (401) + :raises OFSCAuthorizationError: If authorization fails (403) + :raises OFSCNotFoundError: If activity or file property not found (404) + :raises OFSCApiError: For other API errors + :raises OFSCNetworkError: For network/transport errors + """ + url = urljoin( + self.baseUrl, f"/rest/ofscCore/v1/activities/{activity_id}/{label}" + ) + headers = {**self.headers, "Accept": media_type} + + try: + response = await self._client.get(url, headers=headers) + response.raise_for_status() + + return response.content + except httpx.HTTPStatusError as e: + self._handle_http_error( + e, f"Failed to get file property {label} for activity {activity_id}" + ) + raise + except httpx.TransportError as e: + raise OFSCNetworkError(f"Network error: {str(e)}") from e + + async def get_installed_inventories( + self, activity_id: int, offset: int = 0, limit: int = 100 + ) -> InventoryListResponse: + """Get installed inventories for an activity. + + :param activity_id: The unique identifier of the activity + :type activity_id: int + :param offset: Starting record number (default 0) + :type offset: int + :param limit: Maximum number to return (default 100) + :type limit: int + :return: List of installed inventories + :rtype: InventoryListResponse + :raises OFSCAuthenticationError: If authentication fails (401) + :raises OFSCAuthorizationError: If authorization fails (403) + :raises OFSCNotFoundError: If activity not found (404) + :raises OFSCApiError: For other API errors + :raises OFSCNetworkError: For network/transport errors + """ + url = urljoin( + self.baseUrl, + f"/rest/ofscCore/v1/activities/{activity_id}/installedInventories", + ) + params = {"offset": offset, "limit": limit} + + try: + response = await self._client.get(url, headers=self.headers, params=params) + response.raise_for_status() + data = response.json() + + return InventoryListResponse.model_validate(data) + except httpx.HTTPStatusError as e: + self._handle_http_error( + e, f"Failed to get installed inventories for activity {activity_id}" + ) + raise + except httpx.TransportError as e: + raise OFSCNetworkError(f"Network error: {str(e)}") from e + + async def get_linked_activities(self, activity_id: int) -> LinkedActivitiesResponse: + """Get linked activities for an activity. + + :param activity_id: The unique identifier of the activity + :type activity_id: int + :return: List of linked activities + :rtype: LinkedActivitiesResponse + :raises OFSCAuthenticationError: If authentication fails (401) + :raises OFSCAuthorizationError: If authorization fails (403) + :raises OFSCNotFoundError: If activity not found (404) + :raises OFSCApiError: For other API errors + :raises OFSCNetworkError: For network/transport errors + """ + url = urljoin( + self.baseUrl, f"/rest/ofscCore/v1/activities/{activity_id}/linkedActivities" + ) + + try: + response = await self._client.get(url, headers=self.headers) + response.raise_for_status() + data = response.json() + + return LinkedActivitiesResponse.model_validate(data) + except httpx.HTTPStatusError as e: + self._handle_http_error( + e, f"Failed to get linked activities for activity {activity_id}" + ) + raise + except httpx.TransportError as e: + raise OFSCNetworkError(f"Network error: {str(e)}") from e + + async def get_activity_link( + self, activity_id: int, linked_activity_id: int, link_type: str + ) -> LinkedActivity: + """Get specific activity link details. + + :param activity_id: The unique identifier of the activity + :type activity_id: int + :param linked_activity_id: The unique identifier of the linked activity + :type linked_activity_id: int + :param link_type: The type of link + :type link_type: str + :return: Activity link details + :rtype: LinkedActivity + :raises OFSCAuthenticationError: If authentication fails (401) + :raises OFSCAuthorizationError: If authorization fails (403) + :raises OFSCNotFoundError: If activity or link not found (404) + :raises OFSCApiError: For other API errors + :raises OFSCNetworkError: For network/transport errors + """ + url = urljoin( + self.baseUrl, + f"/rest/ofscCore/v1/activities/{activity_id}/linkedActivities/{linked_activity_id}/linkTypes/{link_type}", + ) + + try: + response = await self._client.get(url, headers=self.headers) + response.raise_for_status() + data = response.json() + + return LinkedActivity.model_validate(data) + except httpx.HTTPStatusError as e: + self._handle_http_error( + e, + f"Failed to get link {link_type} between activities {activity_id} and {linked_activity_id}", + ) + raise + except httpx.TransportError as e: + raise OFSCNetworkError(f"Network error: {str(e)}") from e + + async def get_required_inventories( + self, activity_id: int + ) -> RequiredInventoriesResponse: + """Get required inventories for an activity. + + :param activity_id: The unique identifier of the activity + :type activity_id: int + :return: List of required inventories + :rtype: RequiredInventoriesResponse + :raises OFSCAuthenticationError: If authentication fails (401) + :raises OFSCAuthorizationError: If authorization fails (403) + :raises OFSCNotFoundError: If activity not found (404) + :raises OFSCApiError: For other API errors + :raises OFSCNetworkError: For network/transport errors + """ + url = urljoin( + self.baseUrl, + f"/rest/ofscCore/v1/activities/{activity_id}/requiredInventories", + ) + + try: + response = await self._client.get(url, headers=self.headers) + response.raise_for_status() + data = response.json() + + return RequiredInventoriesResponse.model_validate(data) + except httpx.HTTPStatusError as e: + self._handle_http_error( + e, f"Failed to get required inventories for activity {activity_id}" + ) + raise + except httpx.TransportError as e: + raise OFSCNetworkError(f"Network error: {str(e)}") from e + + async def get_resource_preferences( + self, activity_id: int + ) -> ResourcePreferencesResponse: + """Get resource preferences for an activity. + + :param activity_id: The unique identifier of the activity + :type activity_id: int + :return: List of resource preferences + :rtype: ResourcePreferencesResponse + :raises OFSCAuthenticationError: If authentication fails (401) + :raises OFSCAuthorizationError: If authorization fails (403) + :raises OFSCNotFoundError: If activity not found (404) + :raises OFSCApiError: For other API errors + :raises OFSCNetworkError: For network/transport errors + """ + url = urljoin( + self.baseUrl, + f"/rest/ofscCore/v1/activities/{activity_id}/resourcePreferences", + ) + + try: + response = await self._client.get(url, headers=self.headers) + response.raise_for_status() + data = response.json() + + return ResourcePreferencesResponse.model_validate(data) + except httpx.HTTPStatusError as e: + self._handle_http_error( + e, f"Failed to get resource preferences for activity {activity_id}" + ) + raise + except httpx.TransportError as e: + raise OFSCNetworkError(f"Network error: {str(e)}") from e + + async def get_submitted_forms( + self, activity_id: int, offset: int = 0, limit: int = 100 + ) -> SubmittedFormsResponse: + """Get submitted forms for an activity. + + :param activity_id: The unique identifier of the activity + :type activity_id: int + :param offset: Starting record number (default 0) + :type offset: int + :param limit: Maximum number to return (default 100) + :type limit: int + :return: List of submitted forms with pagination + :rtype: SubmittedFormsResponse + :raises OFSCAuthenticationError: If authentication fails (401) + :raises OFSCAuthorizationError: If authorization fails (403) + :raises OFSCNotFoundError: If activity not found (404) + :raises OFSCApiError: For other API errors + :raises OFSCNetworkError: For network/transport errors + """ + url = urljoin( + self.baseUrl, f"/rest/ofscCore/v1/activities/{activity_id}/submittedForms" + ) + params = {"offset": offset, "limit": limit} + + try: + response = await self._client.get(url, headers=self.headers, params=params) + response.raise_for_status() + data = response.json() + + return SubmittedFormsResponse.model_validate(data) + except httpx.HTTPStatusError as e: + self._handle_http_error( + e, f"Failed to get submitted forms for activity {activity_id}" + ) + raise + except httpx.TransportError as e: + raise OFSCNetworkError(f"Network error: {str(e)}") from e # endregion @@ -91,101 +646,534 @@ async def get_events(self, params: dict): # region Resources - async def get_resource( + def _build_expand_param( self, - resource_id: str, - inventories: bool = False, - workSkills: bool = False, - workZones: bool = False, - workSchedules: bool = False, - ): - raise NotImplementedError("Async method not yet implemented") - - async def create_resource(self, resourceId: str, data): - raise NotImplementedError("Async method not yet implemented") + inventories: bool, + workskills: bool, + workzones: bool, + workschedules: bool, + ) -> str | None: + """Build expand query parameter for resource requests.""" + parts = [] + if inventories: + parts.append("inventories") + if workskills: + parts.append("workSkills") + if workzones: + parts.append("workZones") + if workschedules: + parts.append("workSchedules") + return ",".join(parts) if parts else None - async def create_resource_from_obj(self, resourceId: str, data: dict): - raise NotImplementedError("Async method not yet implemented") - - async def update_resource( - self, resourceId: str, data: dict, identify_by_internal_id: bool = False - ): - raise NotImplementedError("Async method not yet implemented") - - async def get_position_history(self, resource_id: str, date: str): - raise NotImplementedError("Async method not yet implemented") + async def get_assigned_locations( + self, + resource_id: str, + date_from: date, + date_to: date, + ) -> AssignedLocationsResponse: + """Get assigned locations for a resource.""" + url = urljoin( + self.baseUrl, f"/rest/ofscCore/v1/resources/{resource_id}/assignedLocations" + ) + params = { + "dateFrom": date_from.isoformat(), + "dateTo": date_to.isoformat(), + } + + try: + response = await self._client.get(url, headers=self.headers, params=params) + response.raise_for_status() + data = response.json() + + return AssignedLocationsResponse.model_validate(data) + except httpx.HTTPStatusError as e: + self._handle_http_error( + e, f"Failed to get assigned locations for resource '{resource_id}'" + ) + raise + except httpx.TransportError as e: + raise OFSCNetworkError(f"Network error: {str(e)}") from e + + async def get_calendars(self) -> CalendarsListResponse: + """Get all calendars.""" + url = urljoin(self.baseUrl, "/rest/ofscCore/v1/calendars") + + try: + response = await self._client.get(url, headers=self.headers) + response.raise_for_status() + data = response.json() + + if "links" in data: + del data["links"] + + return CalendarsListResponse.model_validate(data) + except httpx.HTTPStatusError as e: + self._handle_http_error(e, "Failed to get calendars") + raise + except httpx.TransportError as e: + raise OFSCNetworkError(f"Network error: {str(e)}") from e + + async def get_position_history( + self, resource_id: str, position_date: date + ) -> PositionHistoryResponse: + """Get position history for a resource on a specific date.""" + url = urljoin( + self.baseUrl, f"/rest/ofscCore/v1/resources/{resource_id}/positionHistory" + ) + params = {"date": position_date.isoformat()} + + try: + response = await self._client.get(url, headers=self.headers, params=params) + response.raise_for_status() + data = response.json() + + if "links" in data: + del data["links"] + + return PositionHistoryResponse.model_validate(data) + except httpx.HTTPStatusError as e: + self._handle_http_error( + e, f"Failed to get position history for resource '{resource_id}'" + ) + raise + except httpx.TransportError as e: + raise OFSCNetworkError(f"Network error: {str(e)}") from e - async def get_resource_route( + async def get_resource( self, resource_id: str, - date: str, - activityFields: Optional[str] = None, - offset: int = 0, - limit: int = 100, - ): - raise NotImplementedError("Async method not yet implemented") + expand_inventories: bool = False, + expand_workskills: bool = False, + expand_workzones: bool = False, + expand_workschedules: bool = False, + ) -> Resource: + """Get a single resource by ID.""" + url = urljoin(self.baseUrl, f"/rest/ofscCore/v1/resources/{resource_id}") + + params = {} + expand = self._build_expand_param( + expand_inventories, + expand_workskills, + expand_workzones, + expand_workschedules, + ) + if expand: + params["expand"] = expand + + try: + response = await self._client.get( + url, headers=self.headers, params=params if params else None + ) + response.raise_for_status() + data = response.json() + + return Resource.model_validate(data) + except httpx.HTTPStatusError as e: + self._handle_http_error(e, f"Failed to get resource '{resource_id}'") + raise + except httpx.TransportError as e: + raise OFSCNetworkError(f"Network error: {str(e)}") from e + + async def get_resource_assistants( + self, resource_id: str + ) -> ResourceAssistantsResponse: + """Get assistant resources.""" + url = urljoin( + self.baseUrl, f"/rest/ofscCore/v1/resources/{resource_id}/assistants" + ) + + try: + response = await self._client.get(url, headers=self.headers) + response.raise_for_status() + data = response.json() + + if "links" in data: + del data["links"] + + return ResourceAssistantsResponse.model_validate(data) + except httpx.HTTPStatusError as e: + self._handle_http_error( + e, f"Failed to get assistants for resource '{resource_id}'" + ) + raise + except httpx.TransportError as e: + raise OFSCNetworkError(f"Network error: {str(e)}") from e + + async def get_resource_calendar( + self, resource_id: str, date_from: date, date_to: date + ) -> CalendarView: + """Get calendar view for a resource.""" + url = urljoin( + self.baseUrl, + f"/rest/ofscCore/v1/resources/{resource_id}/workSchedules/calendarView", + ) + params = {"dateFrom": date_from.isoformat(), "dateTo": date_to.isoformat()} + + try: + response = await self._client.get(url, headers=self.headers, params=params) + response.raise_for_status() + data = response.json() + + if "links" in data: + del data["links"] + + return CalendarView.model_validate(data) + except httpx.HTTPStatusError as e: + self._handle_http_error( + e, f"Failed to get calendar for resource '{resource_id}'" + ) + raise + except httpx.TransportError as e: + raise OFSCNetworkError(f"Network error: {str(e)}") from e + + async def get_resource_children( + self, resource_id: str, offset: int = 0, limit: int = 100 + ) -> ResourceListResponse: + """Get child resources.""" + url = urljoin( + self.baseUrl, f"/rest/ofscCore/v1/resources/{resource_id}/children" + ) + params = {"offset": offset, "limit": limit} + + try: + response = await self._client.get(url, headers=self.headers, params=params) + response.raise_for_status() + data = response.json() + + if "links" in data: + del data["links"] + + return ResourceListResponse.model_validate(data) + except httpx.HTTPStatusError as e: + self._handle_http_error( + e, f"Failed to get children for resource '{resource_id}'" + ) + raise + except httpx.TransportError as e: + raise OFSCNetworkError(f"Network error: {str(e)}") from e async def get_resource_descendants( self, resource_id: str, - resourceFields: Optional[str] = None, offset: int = 0, limit: int = 100, - inventories: bool = False, - workSkills: bool = False, - workZones: bool = False, - workSchedules: bool = False, - ): - raise NotImplementedError("Async method not yet implemented") + fields: list[str] | None = None, + expand_inventories: bool = False, + expand_workskills: bool = False, + expand_workzones: bool = False, + expand_workschedules: bool = False, + ) -> ResourceListResponse: + """Get descendant resources.""" + url = urljoin( + self.baseUrl, f"/rest/ofscCore/v1/resources/{resource_id}/descendants" + ) + + params = {"offset": offset, "limit": limit} + if fields: + params["resourceFields"] = ",".join(fields) + expand = self._build_expand_param( + expand_inventories, + expand_workskills, + expand_workzones, + expand_workschedules, + ) + if expand: + params["expand"] = expand + + try: + response = await self._client.get(url, headers=self.headers, params=params) + response.raise_for_status() + data = response.json() + + if "links" in data: + del data["links"] + + return ResourceListResponse.model_validate(data) + except httpx.HTTPStatusError as e: + self._handle_http_error( + e, f"Failed to get descendants for resource '{resource_id}'" + ) + raise + except httpx.TransportError as e: + raise OFSCNetworkError(f"Network error: {str(e)}") from e + + async def get_resource_inventories(self, resource_id: str) -> InventoryListResponse: + """Get inventories assigned to a resource.""" + url = urljoin( + self.baseUrl, f"/rest/ofscCore/v1/resources/{resource_id}/inventories" + ) + + try: + response = await self._client.get(url, headers=self.headers) + response.raise_for_status() + data = response.json() + + if "links" in data: + del data["links"] + + return InventoryListResponse.model_validate(data) + except httpx.HTTPStatusError as e: + self._handle_http_error( + e, f"Failed to get inventories for resource '{resource_id}'" + ) + raise + except httpx.TransportError as e: + raise OFSCNetworkError(f"Network error: {str(e)}") from e - async def get_resources( - self, - canBeTeamHolder: Optional[bool] = None, - canParticipateInTeam: Optional[bool] = None, - fields: Optional[list[str]] = None, - offset: int = 0, - limit: int = 100, - inventories: bool = False, - workSkills: bool = False, - workZones: bool = False, - workSchedules: bool = False, - ): - raise NotImplementedError("Async method not yet implemented") + async def get_resource_location( + self, resource_id: str, location_id: int + ) -> Location: + """Get a single location for a resource.""" + url = urljoin( + self.baseUrl, + f"/rest/ofscCore/v1/resources/{resource_id}/locations/{location_id}", + ) + + try: + response = await self._client.get(url, headers=self.headers) + response.raise_for_status() + data = response.json() + + return Location.model_validate(data) + except httpx.HTTPStatusError as e: + self._handle_http_error( + e, + f"Failed to get location {location_id} for resource '{resource_id}'", + ) + raise + except httpx.TransportError as e: + raise OFSCNetworkError(f"Network error: {str(e)}") from e + + async def get_resource_locations(self, resource_id: str) -> LocationListResponse: + """Get locations for a resource.""" + url = urljoin( + self.baseUrl, f"/rest/ofscCore/v1/resources/{resource_id}/locations" + ) + + try: + response = await self._client.get(url, headers=self.headers) + response.raise_for_status() + data = response.json() + + if "links" in data: + del data["links"] + + return LocationListResponse.model_validate(data) + except httpx.HTTPStatusError as e: + self._handle_http_error( + e, f"Failed to get locations for resource '{resource_id}'" + ) + raise + except httpx.TransportError as e: + raise OFSCNetworkError(f"Network error: {str(e)}") from e + + async def get_resource_plans(self, resource_id: str) -> ResourcePlansResponse: + """Get routing plans for a resource.""" + url = urljoin(self.baseUrl, f"/rest/ofscCore/v1/resources/{resource_id}/plans") + + try: + response = await self._client.get(url, headers=self.headers) + response.raise_for_status() + data = response.json() + + if "links" in data: + del data["links"] + + return ResourcePlansResponse.model_validate(data) + except httpx.HTTPStatusError as e: + self._handle_http_error( + e, f"Failed to get plans for resource '{resource_id}'" + ) + raise + except httpx.TransportError as e: + raise OFSCNetworkError(f"Network error: {str(e)}") from e + + async def get_resource_route( + self, resource_id: str, route_date: date, offset: int = 0, limit: int = 100 + ) -> ResourceRouteResponse: + """Get route for a resource on a specific date.""" + url = urljoin( + self.baseUrl, + f"/rest/ofscCore/v1/resources/{resource_id}/routes/{route_date.isoformat()}", + ) + params = {"offset": offset, "limit": limit} + + try: + response = await self._client.get(url, headers=self.headers, params=params) + response.raise_for_status() + data = response.json() + + if "links" in data: + del data["links"] + + return ResourceRouteResponse.model_validate(data) + except httpx.HTTPStatusError as e: + self._handle_http_error( + e, + f"Failed to get route for resource '{resource_id}' on {route_date.isoformat()}", + ) + raise + except httpx.TransportError as e: + raise OFSCNetworkError(f"Network error: {str(e)}") from e async def get_resource_users(self, resource_id: str) -> ResourceUsersListResponse: - raise NotImplementedError("Async method not yet implemented") + """Get users assigned to a resource.""" + url = urljoin(self.baseUrl, f"/rest/ofscCore/v1/resources/{resource_id}/users") - async def set_resource_users(self, *, resource_id: str, users: tuple[str, ...]): - raise NotImplementedError("Async method not yet implemented") + try: + response = await self._client.get(url, headers=self.headers) + response.raise_for_status() + data = response.json() - async def delete_resource_users(self, resource_id: str): - raise NotImplementedError("Async method not yet implemented") + if "links" in data: + del data["links"] + + return ResourceUsersListResponse.model_validate(data) + except httpx.HTTPStatusError as e: + self._handle_http_error( + e, f"Failed to get users for resource '{resource_id}'" + ) + raise + except httpx.TransportError as e: + raise OFSCNetworkError(f"Network error: {str(e)}") from e async def get_resource_workschedules( - self, resource_id: str, actualDate: date + self, resource_id: str, actual_date: date ) -> ResourceWorkScheduleResponse: - raise NotImplementedError("Async method not yet implemented") + """Get workschedules for a resource.""" + url = urljoin( + self.baseUrl, f"/rest/ofscCore/v1/resources/{resource_id}/workSchedules" + ) + params = {"actualDate": actual_date.isoformat()} + + try: + response = await self._client.get(url, headers=self.headers, params=params) + response.raise_for_status() + data = response.json() + + if "links" in data: + del data["links"] + + return ResourceWorkScheduleResponse.model_validate(data) + except httpx.HTTPStatusError as e: + self._handle_http_error( + e, f"Failed to get workschedules for resource '{resource_id}'" + ) + raise + except httpx.TransportError as e: + raise OFSCNetworkError(f"Network error: {str(e)}") from e + + async def get_resource_workskills( + self, resource_id: str + ) -> ResourceWorkskillListResponse: + """Get workskills assigned to a resource.""" + url = urljoin( + self.baseUrl, f"/rest/ofscCore/v1/resources/{resource_id}/workSkills" + ) + + try: + response = await self._client.get(url, headers=self.headers) + response.raise_for_status() + data = response.json() + + if "links" in data: + del data["links"] + + return ResourceWorkskillListResponse.model_validate(data) + except httpx.HTTPStatusError as e: + self._handle_http_error( + e, f"Failed to get workskills for resource '{resource_id}'" + ) + raise + except httpx.TransportError as e: + raise OFSCNetworkError(f"Network error: {str(e)}") from e + + async def get_resource_workzones( + self, resource_id: str + ) -> ResourceWorkzoneListResponse: + """Get workzones assigned to a resource.""" + url = urljoin( + self.baseUrl, f"/rest/ofscCore/v1/resources/{resource_id}/workZones" + ) + + try: + response = await self._client.get(url, headers=self.headers) + response.raise_for_status() + data = response.json() + + if "links" in data: + del data["links"] + + return ResourceWorkzoneListResponse.model_validate(data) + except httpx.HTTPStatusError as e: + self._handle_http_error( + e, f"Failed to get workzones for resource '{resource_id}'" + ) + raise + except httpx.TransportError as e: + raise OFSCNetworkError(f"Network error: {str(e)}") from e - async def set_resource_workschedules( - self, resource_id: str, data: ResourceWorkScheduleItem - ) -> ResourceWorkScheduleResponse: + async def get_resources( + self, + offset: int = 0, + limit: int = 100, + fields: list[str] | None = None, + expand_inventories: bool = False, + expand_workskills: bool = False, + expand_workzones: bool = False, + expand_workschedules: bool = False, + ) -> ResourceListResponse: + """Get all resources with pagination.""" + url = urljoin(self.baseUrl, "/rest/ofscCore/v1/resources") + + params = {"offset": offset, "limit": limit} + if fields: + params["fields"] = ",".join(fields) + expand = self._build_expand_param( + expand_inventories, + expand_workskills, + expand_workzones, + expand_workschedules, + ) + if expand: + params["expand"] = expand + + try: + response = await self._client.get(url, headers=self.headers, params=params) + response.raise_for_status() + data = response.json() + + if "links" in data: + del data["links"] + + return ResourceListResponse.model_validate(data) + except httpx.HTTPStatusError as e: + self._handle_http_error(e, "Failed to get resources") + raise + except httpx.TransportError as e: + raise OFSCNetworkError(f"Network error: {str(e)}") from e + + # TODO: Implement remaining resource write operations (create, update, delete) + async def create_resource(self, resourceId: str, data): raise NotImplementedError("Async method not yet implemented") - async def get_resource_calendar( - self, resource_id: str, dateFrom: date, dateTo: date - ) -> CalendarView: + async def create_resource_from_obj(self, resourceId: str, data: dict): raise NotImplementedError("Async method not yet implemented") - async def get_resource_inventories(self, resource_id: str): + async def update_resource( + self, resourceId: str, data: dict, identify_by_internal_id: bool = False + ): raise NotImplementedError("Async method not yet implemented") - async def get_resource_assigned_locations(self, resource_id: str): + async def set_resource_users(self, *, resource_id: str, users: tuple[str, ...]): raise NotImplementedError("Async method not yet implemented") - async def get_resource_workzones(self, resource_id: str): + async def delete_resource_users(self, resource_id: str): raise NotImplementedError("Async method not yet implemented") - async def get_resource_workskills(self, resource_id: str): + async def set_resource_workschedules( + self, resource_id: str, data: ResourceWorkScheduleItem + ) -> ResourceWorkScheduleResponse: raise NotImplementedError("Async method not yet implemented") async def bulk_update_resource_workzones(self, *, data): @@ -197,9 +1185,6 @@ async def bulk_update_resource_workskills(self, *, data): async def bulk_update_resource_workschedules(self, *, data): raise NotImplementedError("Async method not yet implemented") - async def get_resource_locations(self, resource_id: str) -> LocationListResponse: - raise NotImplementedError("Async method not yet implemented") - async def create_resource_location( self, resource_id: str, *, location: Location ) -> Location: @@ -208,15 +1193,6 @@ async def create_resource_location( async def delete_resource_location(self, resource_id: str, location_id: int): raise NotImplementedError("Async method not yet implemented") - async def get_assigned_locations( - self, - resource_id: str, - *, - dateFrom: date = None, - dateTo: date = None, - ) -> AssignedLocationsResponse: - raise NotImplementedError("Async method not yet implemented") - async def set_assigned_locations( self, resource_id: str, data: AssignedLocationsResponse ) -> AssignedLocationsResponse: diff --git a/ofsc/models.py b/ofsc/models.py index 8969951..78e79ae 100644 --- a/ofsc/models.py +++ b/ofsc/models.py @@ -2,7 +2,7 @@ import logging from datetime import date, time from enum import Enum -from typing import Any, Dict, Generic, Optional, TypeVar, Union +from typing import Any, Dict, Generic, Literal, Optional, TypeVar, Union from urllib.parse import urljoin import requests @@ -22,6 +22,8 @@ from ofsc.common import FULL_RESPONSE, wrap_return +# region Generic Models + T = TypeVar("T") @@ -283,48 +285,11 @@ def map(self): return {translation.language: translation for translation in self.root} -class Resource(BaseModel): - resourceId: Optional[str] = None - parentResourceId: Optional[str] = None - resourceType: str - name: str - status: str = "active" - organization: str = "default" - language: str - languageISO: Optional[str] = None - timeZone: str - timeFormat: str = "24-hour" - dateFormat: str = "mm/dd/yy" - email: Optional[str] = None - phone: Optional[str] = None - model_config = ConfigDict(extra="allow") - - -class ResourceList(RootModel[list[Resource]]): - def __iter__(self): # type: ignore[override] - return iter(self.root) - - def __getitem__(self, item): - return self.root[item] - - -class ResourceType(BaseModel): - label: str - name: str - active: bool - role: str # TODO: change to enum - model_config = ConfigDict(extra="allow") - - -class ResourceTypeList(RootModel[list[ResourceType]]): - def __iter__(self): # type: ignore[override] - return iter(self.root) +# endregion Generic Models - def __getitem__(self, item): - return self.root[item] +# region Core / Activities -# Core / Activities class Activity(BaseModel): activityId: Optional[int] = None activityType: Optional[str] = None @@ -332,30 +297,65 @@ class Activity(BaseModel): model_config = ConfigDict(extra="allow") -class GetActivityRequest(BaseModel): +class GetActivitiesParams(BaseModel): + """Parameters for get_activities API endpoint. + + Note: offset and limit are handled separately as method parameters. + """ + + resources: Optional[list[str]] = None + includeChildren: Optional[Literal["none", "immediate", "all"]] = "all" + q: Optional[str] = None dateFrom: Optional[date] = None dateTo: Optional[date] = None fields: Optional[list[str]] = None - includeChildren: Optional[str] = "all" - offset: Optional[int] = 0 includeNonScheduled: Optional[bool] = False - limit: Optional[int] = 5000 - q: Optional[str] = None - model_config = ConfigDict(extra="allow") - resources: list[str] + svcWorkOrderId: Optional[int] = None + + model_config = ConfigDict(extra="forbid") @model_validator(mode="after") - def check_date_range(self): + def validate_date_requirements(self): + # dateFrom and dateTo must both be specified or both be None + if (self.dateFrom is None) != (self.dateTo is None): + raise ValueError( + "dateFrom and dateTo must both be specified or both omitted" + ) + + # Check date range is valid if self.dateFrom and self.dateTo and self.dateFrom > self.dateTo: - raise ValueError("dateFrom must be before dateTo") - return self - if not self.includeNonScheduled: - if self.dateFrom is None or self.dateTo is None: + raise ValueError("dateFrom must be before or equal to dateTo") + + # If no dates and no svcWorkOrderId, must have includeNonScheduled=True + if self.dateFrom is None and self.svcWorkOrderId is None: + if not self.includeNonScheduled: raise ValueError( - "dateFrom and dateTo are required when includeNonScheduled is False" + "Either dateFrom/dateTo, svcWorkOrderId, or includeNonScheduled=True is required" ) + return self + def to_api_params(self) -> dict: + """Convert to API query parameters.""" + params = {} + if self.resources: + params["resources"] = ",".join(self.resources) + if self.includeChildren: + params["includeChildren"] = self.includeChildren + if self.q: + params["q"] = self.q + if self.dateFrom: + params["dateFrom"] = self.dateFrom.isoformat() + if self.dateTo: + params["dateTo"] = self.dateTo.isoformat() + if self.fields: + params["fields"] = ",".join(self.fields) + if self.includeNonScheduled: + params["includeNonScheduled"] = "true" + if self.svcWorkOrderId: + params["svcWorkOrderId"] = self.svcWorkOrderId + return params + class BulkUpdateActivityItem(Activity): activityId: Optional[int] = None @@ -408,7 +408,171 @@ class BulkUpdateResponse(BaseModel): results: Optional[list[BulkUpdateResult]] = None -# region Users +# Core / Activities - List Responses and Nested Models + + +class ActivityListResponse(OFSResponseList[Activity]): + """List response for activities with pagination.""" + + pass + + +# Core / Activities - Submitted Forms + + +class FormIdentifier(BaseModel): + """Form identifier with submit ID and label.""" + + formSubmitId: Optional[int] = None + formLabel: Optional[str] = None + + +class SubmittedForm(BaseModel): + """Submitted form associated with an activity.""" + + formIdentifier: Optional[FormIdentifier] = None + user: Optional[str] = None + time: Optional[str] = None + model_config = ConfigDict(extra="allow") + + +class SubmittedFormsResponse(BaseModel): + """Response for submitted forms with pagination.""" + + items: list[SubmittedForm] = [] + offset: Optional[int] = None + limit: Optional[int] = None + totalResults: Optional[int] = None + hasMore: Optional[bool] = None + + +# Core / Activities - Resource Preferences + + +class ResourcePreference(BaseModel): + """Resource preference for an activity.""" + + resourceId: Optional[str] = None + resourceInternalId: Optional[int] = None + preferenceType: Optional[str] = None # required, preferred, forbidden + + +class ResourcePreferencesResponse(BaseModel): + """Response for resource preferences (no pagination).""" + + items: list[ResourcePreference] = [] + + +# Core / Activities - Required Inventories + + +class RequiredInventory(BaseModel): + """Required inventory item for an activity.""" + + inventoryType: str + model: str + quantity: float + + +class RequiredInventoriesResponse(BaseModel): + """Response for required inventories.""" + + items: list[RequiredInventory] = [] + offset: Optional[int] = None + limit: Optional[int] = None + totalResults: Optional[int] = None + + +# Core / Activities - Inventories (Common for customer/installed/deinstalled) + + +class Inventory(BaseModel): + """Inventory item (customer, installed, or deinstalled).""" + + inventoryId: Optional[int] = None + activityId: Optional[int] = None + inventoryType: Optional[str] = None + status: Optional[str] = None # customer, resource, installed, deinstalled + quantity: Optional[float] = None + serialNumber: Optional[str] = None + model_config = ConfigDict(extra="allow") + + +class InventoryListResponse(BaseModel): + """Response for inventory lists.""" + + items: list[Inventory] = [] + offset: Optional[str | int] = None # Can be string or int from API + limit: Optional[str | int] = None # Can be string or int from API + totalResults: Optional[int] = None + + +# Core / Activities - Linked Activities + + +class LinkedActivity(BaseModel): + """Linked activity relationship.""" + + fromActivityId: int + toActivityId: int + linkType: str + minIntervalValue: Optional[int] = None + alerts: Optional[int] = None + + +class LinkedActivitiesResponse(BaseModel): + """Response for linked activities (no pagination).""" + + items: list[LinkedActivity] = [] + + +# Core / Activities - Capacity Categories + + +class ActivityCapacityCategory(BaseModel): + """Capacity category for an activity.""" + + capacityCategory: str + + +class ActivityCapacityCategoriesResponse(BaseModel): + """Response for activity capacity categories.""" + + items: list[ActivityCapacityCategory] = [] + totalResults: Optional[int] = None + + +# endregion Core / Activities + + +# region Core / Resources + + +class Resource(BaseModel): + resourceId: Optional[str] = None + parentResourceId: Optional[str] = None + resourceType: str + name: str + status: str = "active" + organization: str = "default" + language: str + languageISO: Optional[str] = None + timeZone: str + timeFormat: str = "24-hour" + dateFormat: str = "mm/dd/yy" + email: Optional[str] = None + phone: Optional[str] = None + model_config = ConfigDict(extra="allow") + + +class ResourceList(RootModel[list[Resource]]): + def __iter__(self): # type: ignore[override] + return iter(self.root) + + def __getitem__(self, item): + return self.root[item] + + class BaseUser(BaseModel): login: str @@ -419,10 +583,6 @@ def users(self) -> list[str]: return [item.login for item in self.items] -# endregion - - -# region 202411 Calendars class RecurrenceType(str, Enum): daily = "daily" weekly = "weekly" @@ -502,10 +662,6 @@ def __getitem__(self, item): return self.root[item] -# endregion -# region 202503 ResourceWorkSchedule - - class ResourceWorkScheduleItem(BaseModel): comments: Optional[str] = None endDate: Optional[date] = None @@ -538,10 +694,6 @@ class ResourceWorkScheduleResponse(OFSResponseList[ResourceWorkScheduleItem]): pass -# endregion - - -# region 202504 Locations class Location(BaseModel): label: str postalCode: Optional[str] = "" @@ -584,8 +736,118 @@ class LocationListResponse(OFSResponseList[Location]): pass -# endregion -# region 202505 Daily Extracts +class ResourceListResponse(OFSResponseList[Resource]): + """Paginated list of resources.""" + + pass + + +class ResourceAssistant(BaseModel): + """Assistant resource assignment.""" + + resourceId: Optional[str] = None + parentResourceId: Optional[str] = None + model_config = ConfigDict(extra="allow") + + +class ResourceAssistantsResponse(OFSResponseList[ResourceAssistant]): + """List of assistant resources.""" + + pass + + +class ResourceWorkskillAssignment(BaseModel): + """Workskill assigned to a resource.""" + + workSkill: Optional[str] = None + ratio: Optional[int] = None + startDate: Optional[str] = None + model_config = ConfigDict(extra="allow") + + +class ResourceWorkskillListResponse(OFSResponseList[ResourceWorkskillAssignment]): + """Workskills assigned to a resource.""" + + pass + + +class ResourceWorkzoneAssignment(BaseModel): + """Workzone assigned to a resource.""" + + workZoneLabel: Optional[str] = None + ratio: Optional[int] = None + startDate: Optional[str] = None + model_config = ConfigDict(extra="allow") + + +class ResourceWorkzoneListResponse(OFSResponseList[ResourceWorkzoneAssignment]): + """Workzones assigned to a resource.""" + + pass + + +class PositionHistoryItem(BaseModel): + """Position history entry.""" + + time: Optional[str] = None + latitude: Optional[float] = None + longitude: Optional[float] = None + model_config = ConfigDict(extra="allow") + + +class PositionHistoryResponse(OFSResponseList[PositionHistoryItem]): + """Position history response.""" + + pass + + +class ResourceRouteActivity(BaseModel): + """Activity in a resource route.""" + + activityId: Optional[int] = None + activityType: Optional[str] = None + status: Optional[str] = None + model_config = ConfigDict(extra="allow") + + +class ResourceRouteResponse(OFSResponseList[ResourceRouteActivity]): + """Resource route for a specific date.""" + + routeStartTime: Optional[str] = None + + +class ResourcePlan(BaseModel): + """Resource routing plan.""" + + label: Optional[str] = None + name: Optional[str] = None + model_config = ConfigDict(extra="allow") + + +class ResourcePlansResponse(OFSResponseList[ResourcePlan]): + """Resource plans response.""" + + pass + + +class Calendar(BaseModel): + """Calendar definition.""" + + label: Optional[str] = None + name: Optional[str] = None + model_config = ConfigDict(extra="allow") + + +class CalendarsListResponse(OFSResponseList[Calendar]): + """List of calendars.""" + + pass + + +# endregion Core / Resources + + +# region Core / Daily Extracts class DailyExtractLink(BaseModel): @@ -617,6 +879,9 @@ class DailyExtractFiles(BaseModel): files: Optional[DailyExtractItemList] = None +# endregion Core / Daily Extracts + + # region Capacity @@ -1558,6 +1823,22 @@ class EnumerationValueList(OFSResponseList[EnumerationValue]): # region Metadata / Resource Types +class ResourceType(BaseModel): + label: str + name: str + active: bool + role: str # TODO: change to enum + model_config = ConfigDict(extra="allow") + + +class ResourceTypeList(RootModel[list[ResourceType]]): + def __iter__(self): # type: ignore[override] + return iter(self.root) + + def __getitem__(self, item): + return self.root[item] + + class ResourceTypeListResponse(OFSResponseList[ResourceType]): pass diff --git a/pyproject.toml b/pyproject.toml index bb65c4f..17d57ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,6 +31,7 @@ dev = [ "python-dotenv>=1.0.1,<2", "pytablewriter>=1.2.1,<2", "pyright>=1.1.390,<2", + "pytest-xdist>=3.8.0", ] [build-system] @@ -44,10 +45,12 @@ log_cli_format = "%(asctime)s [%(levelname)8s] %(message)s (%(filename)s:%(linen log_cli_date_format = "%Y-%m-%d %H:%M:%S" asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "function" +addopts = "-n auto --dist worksteal" markers = [ "integration: marks tests as integration tests (may be slow)", "slow: marks tests as slow (deselect with '-m \"not slow\"')", "uses_real_data: marks tests that use real API data (requires credentials)", + "serial: marks tests that must run sequentially (not in parallel)", ] [tool.pytest_env] diff --git a/scripts/capture_api_responses.py b/scripts/capture_api_responses.py index 9d75817..590a734 100644 --- a/scripts/capture_api_responses.py +++ b/scripts/capture_api_responses.py @@ -719,6 +719,326 @@ "metadata": {"work_skill_group_label": "NONEXISTENT_GROUP_12345"}, }, ], + "activities": [ + # 1. get_activities - list with pagination + { + "name": "get_activities_200_success", + "description": "Get activities list with pagination", + "method": "GET", + "path": "/rest/ofscCore/v1/activities", + "params": { + "dateFrom": "2025-12-01", + "dateTo": "2025-12-31", + "resources": "SUNRISE", + "limit": 10, + }, + "body": None, + "metadata": {}, + }, + # 2. get_activity - single activity + { + "name": "get_activity_200_success", + "description": "Get a single activity by ID", + "method": "GET", + "path": "/rest/ofscCore/v1/activities/3954799", + "params": None, + "body": None, + "metadata": {"activity_id": "3954799"}, + }, + { + "name": "get_activity_404_not_found", + "description": "Get a non-existent activity", + "method": "GET", + "path": "/rest/ofscCore/v1/activities/999999999", + "params": None, + "body": None, + "metadata": {"activity_id": "999999999"}, + }, + # 3. get_multiday_segments - SKIPPED (no test data) + # 5. get_submitted_forms - forms with pagination + { + "name": "get_submitted_forms_200_success", + "description": "Get submitted forms for an activity", + "method": "GET", + "path": "/rest/ofscCore/v1/activities/3954799/submittedForms", + "params": {"offset": 0, "limit": 100}, + "body": None, + "metadata": {"activity_id": "3954799"}, + }, + # 6. get_resource_preferences - no pagination + { + "name": "get_resource_preferences_200_success", + "description": "Get resource preferences for an activity", + "method": "GET", + "path": "/rest/ofscCore/v1/activities/3954799/resourcePreferences", + "params": None, + "body": None, + "metadata": {"activity_id": "3954799"}, + }, + # 7. get_required_inventories - items array + { + "name": "get_required_inventories_200_success", + "description": "Get required inventories for an activity", + "method": "GET", + "path": "/rest/ofscCore/v1/activities/3954799/requiredInventories", + "params": None, + "body": None, + "metadata": {"activity_id": "3954799"}, + }, + # 8. get_customer_inventories - uses common Inventory schema + { + "name": "get_customer_inventories_200_success", + "description": "Get customer inventories for an activity", + "method": "GET", + "path": "/rest/ofscCore/v1/activities/3954799/customerInventories", + "params": {"offset": 0, "limit": 100}, + "body": None, + "metadata": {"activity_id": "3954799"}, + }, + # 9. get_installed_inventories + { + "name": "get_installed_inventories_200_success", + "description": "Get installed inventories for an activity", + "method": "GET", + "path": "/rest/ofscCore/v1/activities/3954799/installedInventories", + "params": {"offset": 0, "limit": 100}, + "body": None, + "metadata": {"activity_id": "3954799"}, + }, + # 10. get_deinstalled_inventories + { + "name": "get_deinstalled_inventories_200_success", + "description": "Get deinstalled inventories for an activity", + "method": "GET", + "path": "/rest/ofscCore/v1/activities/3954799/deinstalledInventories", + "params": {"offset": 0, "limit": 100}, + "body": None, + "metadata": {"activity_id": "3954799"}, + }, + # 11. get_linked_activities - items array + { + "name": "get_linked_activities_200_success", + "description": "Get linked activities", + "method": "GET", + "path": "/rest/ofscCore/v1/activities/3954799/linkedActivities", + "params": None, + "body": None, + "metadata": {"activity_id": "3954799"}, + }, + # 12. get_activity_link - single link details + { + "name": "get_activity_link_200_success", + "description": "Get specific activity link details", + "method": "GET", + "path": "/rest/ofscCore/v1/activities/3954799/linkedActivities/4224073/linkTypes/start_before", + "params": None, + "body": None, + "metadata": { + "activity_id": "3954799", + "linked_activity_id": "4224073", + "link_type": "start_before", + }, + }, + # 13. get_capacity_categories - items + totalResults + { + "name": "get_capacity_categories_200_success", + "description": "Get capacity categories for an activity", + "method": "GET", + "path": "/rest/ofscCore/v1/activities/3954799/capacityCategories", + "params": None, + "body": None, + "metadata": {"activity_id": "3954799"}, + }, + ], + "resources": [ + # List resources + { + "name": "get_resources_200_success", + "description": "Get all resources with pagination", + "method": "GET", + "path": "/rest/ofscCore/v1/resources", + "params": {"offset": 0, "limit": 10}, + "body": None, + "metadata": {}, + }, + # Single resources (different types) + { + "name": "get_resource_individual_200_success", + "description": "Get an individual resource", + "method": "GET", + "path": "/rest/ofscCore/v1/resources/33001", + "params": None, + "body": None, + "metadata": {"resource_id": "33001"}, + }, + { + "name": "get_resource_bucket_200_success", + "description": "Get a bucket resource", + "method": "GET", + "path": "/rest/ofscCore/v1/resources/FLUSA", + "params": None, + "body": None, + "metadata": {"resource_id": "FLUSA"}, + }, + { + "name": "get_resource_group_200_success", + "description": "Get a group resource", + "method": "GET", + "path": "/rest/ofscCore/v1/resources/ACMECONTRACTOR", + "params": None, + "body": None, + "metadata": {"resource_id": "ACMECONTRACTOR"}, + }, + { + "name": "get_resource_404_not_found", + "description": "Get a non-existent resource", + "method": "GET", + "path": "/rest/ofscCore/v1/resources/NONEXISTENT_12345", + "params": None, + "body": None, + "metadata": {"resource_id": "NONEXISTENT_12345"}, + }, + # Children and descendants + { + "name": "get_resource_children_200_success", + "description": "Get resource children", + "method": "GET", + "path": "/rest/ofscCore/v1/resources/SUNRISE/children", + "params": None, + "body": None, + "metadata": {"resource_id": "SUNRISE"}, + }, + { + "name": "get_resource_descendants_200_success", + "description": "Get resource descendants", + "method": "GET", + "path": "/rest/ofscCore/v1/resources/SUNRISE/descendants", + "params": None, + "body": None, + "metadata": {"resource_id": "SUNRISE"}, + }, + # Sub-entities + { + "name": "get_resource_users_200_success", + "description": "Get users assigned to a resource", + "method": "GET", + "path": "/rest/ofscCore/v1/resources/33001/users", + "params": None, + "body": None, + "metadata": {"resource_id": "33001"}, + }, + { + "name": "get_resource_inventories_200_success", + "description": "Get inventories assigned to a resource", + "method": "GET", + "path": "/rest/ofscCore/v1/resources/33001/inventories", + "params": None, + "body": None, + "metadata": {"resource_id": "33001"}, + }, + { + "name": "get_resource_workskills_200_success", + "description": "Get workskills assigned to a resource", + "method": "GET", + "path": "/rest/ofscCore/v1/resources/33001/workSkills", + "params": None, + "body": None, + "metadata": {"resource_id": "33001"}, + }, + { + "name": "get_resource_workzones_200_success", + "description": "Get workzones assigned to a resource", + "method": "GET", + "path": "/rest/ofscCore/v1/resources/33001/workZones", + "params": None, + "body": None, + "metadata": {"resource_id": "33001"}, + }, + { + "name": "get_resource_workschedules_200_success", + "description": "Get workschedules for a resource", + "method": "GET", + "path": "/rest/ofscCore/v1/resources/33001/workSchedules", + "params": {"actualDate": "2025-12-31"}, + "body": None, + "metadata": {"resource_id": "33001"}, + }, + { + "name": "get_resource_calendar_200_success", + "description": "Get calendar view for a resource", + "method": "GET", + "path": "/rest/ofscCore/v1/resources/33001/workSchedules/calendarView", + "params": {"dateFrom": "2025-12-01", "dateTo": "2025-12-31"}, + "body": None, + "metadata": {"resource_id": "33001"}, + }, + # Locations + { + "name": "get_resource_locations_200_success", + "description": "Get locations for a resource", + "method": "GET", + "path": "/rest/ofscCore/v1/resources/33001/locations", + "params": None, + "body": None, + "metadata": {"resource_id": "33001"}, + }, + { + "name": "get_assigned_locations_200_success", + "description": "Get assigned locations for a resource", + "method": "GET", + "path": "/rest/ofscCore/v1/resources/33001/assignedLocations", + "params": {"dateFrom": "2025-12-01", "dateTo": "2025-12-31"}, + "body": None, + "metadata": {"resource_id": "33001"}, + }, + { + "name": "get_position_history_200_success", + "description": "Get position history for a resource", + "method": "GET", + "path": "/rest/ofscCore/v1/resources/33001/positionHistory", + "params": {"date": "2025-12-31"}, + "body": None, + "metadata": {"resource_id": "33001"}, + }, + # Routes and plans + { + "name": "get_resource_route_200_success", + "description": "Get route for a resource on a specific date", + "method": "GET", + "path": "/rest/ofscCore/v1/resources/33001/routes/2025-12-31", + "params": None, + "body": None, + "metadata": {"resource_id": "33001", "date": "2025-12-31"}, + }, + { + "name": "get_resource_plans_200_success", + "description": "Get routing plans for a resource", + "method": "GET", + "path": "/rest/ofscCore/v1/resources/33001/plans", + "params": None, + "body": None, + "metadata": {"resource_id": "33001"}, + }, + { + "name": "get_resource_assistants_200_success", + "description": "Get assistant resources", + "method": "GET", + "path": "/rest/ofscCore/v1/resources/33001/assistants", + "params": None, + "body": None, + "metadata": {"resource_id": "33001"}, + }, + # Calendars + { + "name": "get_calendars_200_success", + "description": "Get all calendars", + "method": "GET", + "path": "/rest/ofscCore/v1/calendars", + "params": None, + "body": None, + "metadata": {}, + }, + ], } diff --git a/tests/async/test_async_activities.py b/tests/async/test_async_activities.py new file mode 100644 index 0000000..cb4225b --- /dev/null +++ b/tests/async/test_async_activities.py @@ -0,0 +1,429 @@ +"""Tests for async activities API methods.""" + +import json +from pathlib import Path + +import pytest + +from ofsc.async_client import AsyncOFSC +from ofsc.exceptions import OFSCNotFoundError +from ofsc.models import ( + Activity, + ActivityCapacityCategoriesResponse, + ActivityListResponse, + InventoryListResponse, + LinkedActivitiesResponse, + RequiredInventoriesResponse, + ResourcePreferencesResponse, + SubmittedFormsResponse, +) + + +class TestAsyncGetActivitiesLive: + """Live tests against actual API.""" + + @pytest.mark.asyncio + @pytest.mark.uses_real_data + async def test_get_activities(self, async_instance: AsyncOFSC): + """Test get_activities with actual API - validates structure.""" + from calendar import monthrange + from datetime import date + + # Calculate date range for current month (day 1 to last day) + today = date.today() + date_from = date(today.year, today.month, 1) + last_day = monthrange(today.year, today.month)[1] + date_to = date(today.year, today.month, last_day) + + result = await async_instance.core.get_activities( + params={ + "dateFrom": date_from, + "dateTo": date_to, + "resources": ["SUNRISE"], + "includeChildren": "all", + }, + limit=100, + ) + + assert isinstance(result, ActivityListResponse) + assert hasattr(result, "items") + assert hasattr(result, "offset") + assert hasattr(result, "limit") + assert len(result.items) > 5, f"Expected more than 5 activities, got {len(result.items)}" + assert len(result.items) <= 100 + + +class TestAsyncGetActivities: + """Model validation tests for get_activities.""" + + @pytest.mark.asyncio + async def test_get_activities_returns_model(self, async_instance: AsyncOFSC): + """Test that get_activities returns ActivityListResponse model.""" + # This test will use the actual API + # Skip if no credentials available + pytest.skip("Requires API credentials and specific date range") + + @pytest.mark.asyncio + async def test_get_activities_pagination(self, async_instance: AsyncOFSC): + """Test get_activities with pagination parameters.""" + pytest.skip("Requires API credentials and specific date range") + + +class TestAsyncGetActivityLive: + """Live tests for get_activity.""" + + @pytest.mark.asyncio + @pytest.mark.uses_real_data + async def test_get_activity(self, async_instance: AsyncOFSC): + """Test get_activity with actual API.""" + activity_id = 3954799 # Known test activity + result = await async_instance.core.get_activity(activity_id) + + assert isinstance(result, Activity) + assert result.activityId == activity_id + + @pytest.mark.asyncio + @pytest.mark.uses_real_data + async def test_get_activity_not_found(self, async_instance: AsyncOFSC): + """Test get_activity with non-existent activity.""" + with pytest.raises(OFSCNotFoundError): + await async_instance.core.get_activity(999999999) + + +class TestAsyncGetSubmittedFormsLive: + """Live tests for get_submitted_forms.""" + + @pytest.mark.asyncio + @pytest.mark.uses_real_data + async def test_get_submitted_forms(self, async_instance: AsyncOFSC): + """Test get_submitted_forms with actual API.""" + activity_id = 3954799 + result = await async_instance.core.get_submitted_forms(activity_id) + + assert isinstance(result, SubmittedFormsResponse) + assert hasattr(result, "items") + assert hasattr(result, "totalResults") + assert hasattr(result, "hasMore") + + +class TestAsyncGetResourcePreferencesLive: + """Live tests for get_resource_preferences.""" + + @pytest.mark.asyncio + @pytest.mark.uses_real_data + async def test_get_resource_preferences(self, async_instance: AsyncOFSC): + """Test get_resource_preferences with actual API.""" + activity_id = 3954799 + result = await async_instance.core.get_resource_preferences(activity_id) + + assert isinstance(result, ResourcePreferencesResponse) + assert hasattr(result, "items") + # Validate preference types if items exist + for pref in result.items: + assert pref.preferenceType in ["required", "preferred", "forbidden"] + + +class TestAsyncGetRequiredInventoriesLive: + """Live tests for get_required_inventories.""" + + @pytest.mark.asyncio + @pytest.mark.uses_real_data + async def test_get_required_inventories(self, async_instance: AsyncOFSC): + """Test get_required_inventories with actual API.""" + activity_id = 3954799 + result = await async_instance.core.get_required_inventories(activity_id) + + assert isinstance(result, RequiredInventoriesResponse) + assert hasattr(result, "items") + + +class TestAsyncGetCustomerInventoriesLive: + """Live tests for get_customer_inventories.""" + + @pytest.mark.asyncio + @pytest.mark.uses_real_data + async def test_get_customer_inventories(self, async_instance: AsyncOFSC): + """Test get_customer_inventories with actual API.""" + activity_id = 3954799 + result = await async_instance.core.get_customer_inventories(activity_id) + + assert isinstance(result, InventoryListResponse) + assert hasattr(result, "items") + # Validate status field if items exist + for inv in result.items: + assert inv.status in [ + "customer", + "resource", + "installed", + "deinstalled", + None, + ] + + +class TestAsyncGetInstalledInventoriesLive: + """Live tests for get_installed_inventories.""" + + @pytest.mark.asyncio + @pytest.mark.uses_real_data + async def test_get_installed_inventories(self, async_instance: AsyncOFSC): + """Test get_installed_inventories with actual API.""" + activity_id = 3954799 + result = await async_instance.core.get_installed_inventories(activity_id) + + assert isinstance(result, InventoryListResponse) + assert hasattr(result, "items") + + +class TestAsyncGetDeinstalledInventoriesLive: + """Live tests for get_deinstalled_inventories.""" + + @pytest.mark.asyncio + @pytest.mark.uses_real_data + async def test_get_deinstalled_inventories(self, async_instance: AsyncOFSC): + """Test get_deinstalled_inventories with actual API.""" + activity_id = 3954799 + result = await async_instance.core.get_deinstalled_inventories(activity_id) + + assert isinstance(result, InventoryListResponse) + assert hasattr(result, "items") + + +class TestAsyncGetLinkedActivitiesLive: + """Live tests for get_linked_activities.""" + + @pytest.mark.asyncio + @pytest.mark.uses_real_data + async def test_get_linked_activities(self, async_instance: AsyncOFSC): + """Test get_linked_activities with actual API.""" + activity_id = 3954799 + result = await async_instance.core.get_linked_activities(activity_id) + + assert isinstance(result, LinkedActivitiesResponse) + assert hasattr(result, "items") + # Validate link structure if items exist + for link in result.items: + assert hasattr(link, "fromActivityId") + assert hasattr(link, "toActivityId") + assert hasattr(link, "linkType") + + +class TestAsyncGetCapacityCategoriesLive: + """Live tests for get_capacity_categories.""" + + @pytest.mark.asyncio + @pytest.mark.uses_real_data + async def test_get_capacity_categories(self, async_instance: AsyncOFSC): + """Test get_capacity_categories with actual API.""" + activity_id = 3954799 + result = await async_instance.core.get_capacity_categories(activity_id) + + assert isinstance(result, ActivityCapacityCategoriesResponse) + assert hasattr(result, "items") + assert hasattr(result, "totalResults") + + +class TestAsyncActivitySavedResponses: + """Saved response validation tests.""" + + def test_activity_list_response_validation(self): + """Test ActivityListResponse model validates against saved response.""" + saved_response_path = ( + Path(__file__).parent.parent + / "saved_responses" + / "activities" + / "get_activities_200_success.json" + ) + + with open(saved_response_path) as f: + saved_data = json.load(f) + + response = ActivityListResponse.model_validate(saved_data["response_data"]) + + assert isinstance(response, ActivityListResponse) + assert hasattr(response, "items") + assert len(response.items) > 0 + # Validate first activity has required fields + first_activity = response.items[0] + assert hasattr(first_activity, "activityId") + + def test_activity_single_response_validation(self): + """Test Activity model validates against saved single response.""" + saved_response_path = ( + Path(__file__).parent.parent + / "saved_responses" + / "activities" + / "get_activity_200_success.json" + ) + + with open(saved_response_path) as f: + saved_data = json.load(f) + + activity = Activity.model_validate(saved_data["response_data"]) + + assert isinstance(activity, Activity) + assert activity.activityId == 3954799 + + def test_submitted_forms_response_validation(self): + """Test SubmittedFormsResponse model validates against saved response.""" + saved_response_path = ( + Path(__file__).parent.parent + / "saved_responses" + / "activities" + / "get_submitted_forms_200_success.json" + ) + + with open(saved_response_path) as f: + saved_data = json.load(f) + + response = SubmittedFormsResponse.model_validate(saved_data["response_data"]) + + assert isinstance(response, SubmittedFormsResponse) + assert hasattr(response, "items") + assert hasattr(response, "totalResults") + assert hasattr(response, "hasMore") + + def test_resource_preferences_response_validation(self): + """Test ResourcePreferencesResponse model validates against saved response.""" + saved_response_path = ( + Path(__file__).parent.parent + / "saved_responses" + / "activities" + / "get_resource_preferences_200_success.json" + ) + + with open(saved_response_path) as f: + saved_data = json.load(f) + + response = ResourcePreferencesResponse.model_validate( + saved_data["response_data"] + ) + + assert isinstance(response, ResourcePreferencesResponse) + assert hasattr(response, "items") + # Validate preference types + for pref in response.items: + assert pref.preferenceType in ["required", "preferred", "forbidden"] + + def test_required_inventories_response_validation(self): + """Test RequiredInventoriesResponse model validates against saved response.""" + saved_response_path = ( + Path(__file__).parent.parent + / "saved_responses" + / "activities" + / "get_required_inventories_200_success.json" + ) + + with open(saved_response_path) as f: + saved_data = json.load(f) + + response = RequiredInventoriesResponse.model_validate( + saved_data["response_data"] + ) + + assert isinstance(response, RequiredInventoriesResponse) + assert hasattr(response, "items") + # Validate required inventory fields + for inv in response.items: + assert hasattr(inv, "inventoryType") + assert hasattr(inv, "model") + assert hasattr(inv, "quantity") + + def test_customer_inventories_response_validation(self): + """Test InventoryListResponse model validates against customer inventories response.""" + saved_response_path = ( + Path(__file__).parent.parent + / "saved_responses" + / "activities" + / "get_customer_inventories_200_success.json" + ) + + with open(saved_response_path) as f: + saved_data = json.load(f) + + response = InventoryListResponse.model_validate(saved_data["response_data"]) + + assert isinstance(response, InventoryListResponse) + assert hasattr(response, "items") + # Validate inventory has expected fields + for inv in response.items: + assert inv.status == "customer" or inv.status is None + assert hasattr(inv, "inventoryType") + + def test_installed_inventories_response_validation(self): + """Test InventoryListResponse model validates against installed inventories response.""" + saved_response_path = ( + Path(__file__).parent.parent + / "saved_responses" + / "activities" + / "get_installed_inventories_200_success.json" + ) + + with open(saved_response_path) as f: + saved_data = json.load(f) + + response = InventoryListResponse.model_validate(saved_data["response_data"]) + + assert isinstance(response, InventoryListResponse) + assert hasattr(response, "items") + + def test_deinstalled_inventories_response_validation(self): + """Test InventoryListResponse model validates against deinstalled inventories response.""" + saved_response_path = ( + Path(__file__).parent.parent + / "saved_responses" + / "activities" + / "get_deinstalled_inventories_200_success.json" + ) + + with open(saved_response_path) as f: + saved_data = json.load(f) + + response = InventoryListResponse.model_validate(saved_data["response_data"]) + + assert isinstance(response, InventoryListResponse) + assert hasattr(response, "items") + + def test_linked_activities_response_validation(self): + """Test LinkedActivitiesResponse model validates against saved response.""" + saved_response_path = ( + Path(__file__).parent.parent + / "saved_responses" + / "activities" + / "get_linked_activities_200_success.json" + ) + + with open(saved_response_path) as f: + saved_data = json.load(f) + + response = LinkedActivitiesResponse.model_validate(saved_data["response_data"]) + + assert isinstance(response, LinkedActivitiesResponse) + assert hasattr(response, "items") + # Validate link structure + for link in response.items: + assert hasattr(link, "fromActivityId") + assert hasattr(link, "toActivityId") + assert hasattr(link, "linkType") + + def test_capacity_categories_response_validation(self): + """Test ActivityCapacityCategoriesResponse model validates against saved response.""" + saved_response_path = ( + Path(__file__).parent.parent + / "saved_responses" + / "activities" + / "get_capacity_categories_200_success.json" + ) + + with open(saved_response_path) as f: + saved_data = json.load(f) + + response = ActivityCapacityCategoriesResponse.model_validate( + saved_data["response_data"] + ) + + assert isinstance(response, ActivityCapacityCategoriesResponse) + assert hasattr(response, "items") + assert hasattr(response, "totalResults") + # Validate capacity category structure + for cat in response.items: + assert hasattr(cat, "capacityCategory") diff --git a/tests/async/test_async_ofsc.py b/tests/async/test_async_ofsc.py index 31bf4df..28c17d4 100644 --- a/tests/async/test_async_ofsc.py +++ b/tests/async/test_async_ofsc.py @@ -140,26 +140,6 @@ async def test_str_representation(self): assert "mycompany" in str(client) -class TestAsyncOFSCoreStubs: - """Test that AsyncOFSCore methods raise NotImplementedError.""" - - @pytest.mark.asyncio - async def test_get_activities_not_implemented(self): - async with AsyncOFSC( - clientID="test", companyName="test", secret="test" - ) as client: - with pytest.raises(NotImplementedError): - await client.core.get_activities({}) - - @pytest.mark.asyncio - async def test_get_resource_not_implemented(self): - async with AsyncOFSC( - clientID="test", companyName="test", secret="test" - ) as client: - with pytest.raises(NotImplementedError): - await client.core.get_resource("resource_id") - - class TestAsyncOFSMetadataStubs: """Test that AsyncOFSMetadata stub methods raise NotImplementedError.""" diff --git a/tests/async/test_async_resources_get.py b/tests/async/test_async_resources_get.py new file mode 100644 index 0000000..d0b2935 --- /dev/null +++ b/tests/async/test_async_resources_get.py @@ -0,0 +1,362 @@ +"""Tests for async resource GET operations.""" + +import json +from datetime import date +from pathlib import Path + +import pytest + +from ofsc.async_client import AsyncOFSC +from ofsc.exceptions import OFSCNotFoundError +from ofsc.models import ( + AssignedLocationsResponse, + CalendarView, + InventoryListResponse, + LocationListResponse, + PositionHistoryResponse, + Resource, + ResourceListResponse, + ResourceRouteResponse, + ResourceUsersListResponse, + ResourceWorkScheduleResponse, + ResourceWorkskillListResponse, + ResourceWorkzoneListResponse, +) + + +# =================================================================== +# GET RESOURCES (LIST) +# =================================================================== + + +class TestAsyncGetResourcesLive: + """Live tests for get_resources.""" + + @pytest.mark.asyncio + @pytest.mark.uses_real_data + async def test_get_resources(self, async_instance: AsyncOFSC): + """Test get_resources with actual API.""" + result = await async_instance.core.get_resources(limit=10) + + assert isinstance(result, ResourceListResponse) + assert hasattr(result, "items") + assert hasattr(result, "totalResults") + assert len(result.items) <= 10 + if len(result.items) > 0: + assert isinstance(result.items[0], Resource) + + @pytest.mark.asyncio + @pytest.mark.uses_real_data + async def test_get_resources_with_expand(self, async_instance: AsyncOFSC): + """Test get_resources with expand parameters.""" + result = await async_instance.core.get_resources( + limit=2, expand_inventories=True, expand_workskills=True + ) + + assert isinstance(result, ResourceListResponse) + assert len(result.items) <= 2 + + +# =================================================================== +# GET RESOURCE (SINGLE) +# =================================================================== + + +class TestAsyncGetResourceLive: + """Live tests for get_resource.""" + + @pytest.mark.asyncio + @pytest.mark.uses_real_data + async def test_get_resource_individual(self, async_instance: AsyncOFSC): + """Test get_resource for individual resource.""" + result = await async_instance.core.get_resource("33001") + + assert isinstance(result, Resource) + assert result.resourceId == "33001" + + @pytest.mark.asyncio + @pytest.mark.uses_real_data + async def test_get_resource_bucket(self, async_instance: AsyncOFSC): + """Test get_resource for bucket resource.""" + result = await async_instance.core.get_resource("FLUSA") + + assert isinstance(result, Resource) + assert result.resourceId == "FLUSA" + + @pytest.mark.asyncio + @pytest.mark.uses_real_data + async def test_get_resource_group(self, async_instance: AsyncOFSC): + """Test get_resource for group resource.""" + result = await async_instance.core.get_resource("ACMECONTRACTOR") + + assert isinstance(result, Resource) + assert result.resourceId == "ACMECONTRACTOR" + + @pytest.mark.asyncio + @pytest.mark.uses_real_data + async def test_get_resource_not_found(self, async_instance: AsyncOFSC): + """Test get_resource with non-existent resource.""" + with pytest.raises(OFSCNotFoundError): + await async_instance.core.get_resource("NONEXISTENT_12345") + + +# =================================================================== +# RESOURCE HIERARCHY +# =================================================================== + + +class TestAsyncResourceHierarchyLive: + """Live tests for resource hierarchy methods.""" + + @pytest.mark.asyncio + @pytest.mark.uses_real_data + async def test_get_resource_children(self, async_instance: AsyncOFSC): + """Test get_resource_children.""" + result = await async_instance.core.get_resource_children("SUNRISE", limit=10) + + assert isinstance(result, ResourceListResponse) + assert hasattr(result, "items") + + @pytest.mark.asyncio + @pytest.mark.uses_real_data + async def test_get_resource_descendants(self, async_instance: AsyncOFSC): + """Test get_resource_descendants.""" + result = await async_instance.core.get_resource_descendants("SUNRISE", limit=10) + + assert isinstance(result, ResourceListResponse) + assert hasattr(result, "items") + + +# =================================================================== +# RESOURCE SUB-ENTITIES +# =================================================================== + + +class TestAsyncResourceSubEntitiesLive: + """Live tests for resource sub-entity methods.""" + + @pytest.mark.asyncio + @pytest.mark.uses_real_data + async def test_get_resource_users(self, async_instance: AsyncOFSC): + """Test get_resource_users.""" + result = await async_instance.core.get_resource_users("33001") + + assert isinstance(result, ResourceUsersListResponse) + assert hasattr(result, "items") + + @pytest.mark.asyncio + @pytest.mark.uses_real_data + async def test_get_resource_inventories(self, async_instance: AsyncOFSC): + """Test get_resource_inventories.""" + result = await async_instance.core.get_resource_inventories("33001") + + assert isinstance(result, InventoryListResponse) + assert hasattr(result, "items") + + @pytest.mark.asyncio + @pytest.mark.uses_real_data + async def test_get_resource_workskills(self, async_instance: AsyncOFSC): + """Test get_resource_workskills.""" + result = await async_instance.core.get_resource_workskills("33001") + + assert isinstance(result, ResourceWorkskillListResponse) + assert hasattr(result, "items") + + @pytest.mark.asyncio + @pytest.mark.uses_real_data + async def test_get_resource_workzones(self, async_instance: AsyncOFSC): + """Test get_resource_workzones.""" + result = await async_instance.core.get_resource_workzones("33001") + + assert isinstance(result, ResourceWorkzoneListResponse) + assert hasattr(result, "items") + + @pytest.mark.asyncio + @pytest.mark.uses_real_data + async def test_get_resource_workschedules(self, async_instance: AsyncOFSC): + """Test get_resource_workschedules.""" + result = await async_instance.core.get_resource_workschedules( + "33001", date.today() + ) + + assert isinstance(result, ResourceWorkScheduleResponse) + + @pytest.mark.asyncio + @pytest.mark.uses_real_data + async def test_get_resource_calendar(self, async_instance: AsyncOFSC): + """Test get_resource_calendar.""" + from calendar import monthrange + from datetime import date + + today = date.today() + date_from = date(today.year, today.month, 1) + last_day = monthrange(today.year, today.month)[1] + date_to = date(today.year, today.month, last_day) + + result = await async_instance.core.get_resource_calendar( + "33001", date_from, date_to + ) + + assert isinstance(result, CalendarView) + + +# =================================================================== +# LOCATIONS +# =================================================================== + + +class TestAsyncResourceLocationsLive: + """Live tests for resource location methods.""" + + @pytest.mark.asyncio + @pytest.mark.uses_real_data + async def test_get_resource_locations(self, async_instance: AsyncOFSC): + """Test get_resource_locations.""" + result = await async_instance.core.get_resource_locations("33001") + + assert isinstance(result, LocationListResponse) + assert hasattr(result, "items") + + @pytest.mark.asyncio + @pytest.mark.uses_real_data + async def test_get_assigned_locations(self, async_instance: AsyncOFSC): + """Test get_assigned_locations.""" + from calendar import monthrange + from datetime import date + + today = date.today() + date_from = date(today.year, today.month, 1) + last_day = monthrange(today.year, today.month)[1] + date_to = date(today.year, today.month, last_day) + + result = await async_instance.core.get_assigned_locations( + "33001", date_from, date_to + ) + + assert isinstance(result, AssignedLocationsResponse) + + @pytest.mark.asyncio + @pytest.mark.uses_real_data + async def test_get_position_history(self, async_instance: AsyncOFSC): + """Test get_position_history.""" + result = await async_instance.core.get_position_history("33001", date.today()) + + assert isinstance(result, PositionHistoryResponse) + assert hasattr(result, "items") + + +# =================================================================== +# ROUTES & PLANS +# =================================================================== + + +class TestAsyncResourceRoutesPlansLive: + """Live tests for resource routes and plans.""" + + @pytest.mark.asyncio + @pytest.mark.uses_real_data + async def test_get_resource_route(self, async_instance: AsyncOFSC): + """Test get_resource_route.""" + result = await async_instance.core.get_resource_route("33001", date.today()) + + assert isinstance(result, ResourceRouteResponse) + assert hasattr(result, "items") + + +# =================================================================== +# SAVED RESPONSE VALIDATION +# =================================================================== + + +class TestAsyncResourceSavedResponses: + """Test model validation against saved API responses.""" + + def test_resources_list_response_validation(self): + """Test ResourceListResponse validates against saved response.""" + saved_response_path = ( + Path(__file__).parent.parent + / "saved_responses" + / "resources" + / "get_resources_200_success.json" + ) + + with open(saved_response_path) as f: + saved_data = json.load(f) + + response = ResourceListResponse.model_validate(saved_data["response_data"]) + + assert isinstance(response, ResourceListResponse) + assert hasattr(response, "items") + assert len(response.items) > 0 + assert all(isinstance(r, Resource) for r in response.items) + + def test_resource_individual_validation(self): + """Test Resource model validates against individual resource.""" + saved_response_path = ( + Path(__file__).parent.parent + / "saved_responses" + / "resources" + / "get_resource_individual_200_success.json" + ) + + with open(saved_response_path) as f: + saved_data = json.load(f) + + resource = Resource.model_validate(saved_data["response_data"]) + + assert isinstance(resource, Resource) + assert resource.resourceId == "33001" + + def test_resource_bucket_validation(self): + """Test Resource model validates against bucket resource.""" + saved_response_path = ( + Path(__file__).parent.parent + / "saved_responses" + / "resources" + / "get_resource_bucket_200_success.json" + ) + + with open(saved_response_path) as f: + saved_data = json.load(f) + + resource = Resource.model_validate(saved_data["response_data"]) + + assert isinstance(resource, Resource) + assert resource.resourceId == "FLUSA" + + def test_resource_workskills_validation(self): + """Test ResourceWorkskillListResponse validates.""" + saved_response_path = ( + Path(__file__).parent.parent + / "saved_responses" + / "resources" + / "get_resource_workskills_200_success.json" + ) + + with open(saved_response_path) as f: + saved_data = json.load(f) + + response = ResourceWorkskillListResponse.model_validate( + saved_data["response_data"] + ) + + assert isinstance(response, ResourceWorkskillListResponse) + assert hasattr(response, "items") + + def test_resource_route_validation(self): + """Test ResourceRouteResponse validates.""" + saved_response_path = ( + Path(__file__).parent.parent + / "saved_responses" + / "resources" + / "get_resource_route_200_success.json" + ) + + with open(saved_response_path) as f: + saved_data = json.load(f) + + response = ResourceRouteResponse.model_validate(saved_data["response_data"]) + + assert isinstance(response, ResourceRouteResponse) + assert hasattr(response, "items") + assert hasattr(response, "routeStartTime") diff --git a/tests/async/test_async_workzones.py b/tests/async/test_async_workzones.py index 0ede2b0..e2e9b2e 100644 --- a/tests/async/test_async_workzones.py +++ b/tests/async/test_async_workzones.py @@ -121,6 +121,7 @@ async def test_get_workzone_details(self, async_instance): class TestAsyncReplaceWorkzone: """Test async replace_workzone method.""" + @pytest.mark.serial @pytest.mark.asyncio @pytest.mark.uses_real_data async def test_replace_workzone(self, async_instance, faker): @@ -150,6 +151,7 @@ async def test_replace_workzone(self, async_instance, faker): # Restore original workzone await async_instance.metadata.replace_workzone(original_workzone) + @pytest.mark.serial @pytest.mark.asyncio @pytest.mark.uses_real_data async def test_replace_workzone_with_auto_resolve_conflicts( @@ -181,6 +183,7 @@ async def test_replace_workzone_with_auto_resolve_conflicts( # Restore original workzone await async_instance.metadata.replace_workzone(original_workzone) + @pytest.mark.serial @pytest.mark.asyncio @pytest.mark.uses_real_data async def test_replace_workzone_returns_workzone(self, async_instance, faker): @@ -233,6 +236,7 @@ async def test_replace_workzone_non_existing(self, async_instance): class TestAsyncCreateWorkzone: """Test async create_workzone method.""" + @pytest.mark.serial @pytest.mark.asyncio @pytest.mark.uses_real_data async def test_create_workzone(self, async_instance, faker): diff --git a/tests/core/test_subscriptions.py b/tests/core/test_subscriptions.py index 1825100..9d86ef8 100644 --- a/tests/core/test_subscriptions.py +++ b/tests/core/test_subscriptions.py @@ -2,9 +2,12 @@ import logging import time +import pytest + from ofsc.common import FULL_RESPONSE +@pytest.mark.serial def test_get_subscriptions(instance): raw_response = instance.core.get_subscriptions(response_type=FULL_RESPONSE) assert raw_response.status_code == 200 @@ -12,6 +15,7 @@ def test_get_subscriptions(instance): assert "totalResults" in response.keys() +@pytest.mark.serial def test_get_subscriptions_with_token(instance_with_token): raw_response = instance_with_token.core.get_subscriptions( response_type=FULL_RESPONSE @@ -21,6 +25,7 @@ def test_get_subscriptions_with_token(instance_with_token): assert "totalResults" in response.keys() +@pytest.mark.serial def test_create_delete_subscription(instance): data = {"events": ["activityMoved"], "title": "Simple Subscription"} raw_response = instance.core.create_subscription( @@ -44,6 +49,7 @@ def test_create_delete_subscription(instance): assert response.status_code == 204 +@pytest.mark.serial def test_get_events(instance, pp, demo_data, clear_subscriptions): move_data = demo_data.get("events") diff --git a/tests/metadata/test_properties.py b/tests/metadata/test_properties.py index d5ce75c..dc4b8c2 100644 --- a/tests/metadata/test_properties.py +++ b/tests/metadata/test_properties.py @@ -1,5 +1,7 @@ import logging +import pytest + from ofsc import OFSC from ofsc.common import FULL_RESPONSE from ofsc.models import ( @@ -34,6 +36,7 @@ def test_get_properties(instance, demo_data): assert response["items"][0]["label"] == "ITEM_NUMBER" +@pytest.mark.serial def test_create_replace_property(instance: OFSC, faker): property = Property.model_validate( { @@ -65,6 +68,7 @@ def test_create_replace_property(instance: OFSC, faker): property = Property.model_validate(response) +@pytest.mark.serial def test_create_replace_property_noansi(instance: OFSC, request_logging, faker): property = Property.model_validate( { diff --git a/tests/metadata/test_workzones.py b/tests/metadata/test_workzones.py index dbef4f2..62a6a8b 100644 --- a/tests/metadata/test_workzones.py +++ b/tests/metadata/test_workzones.py @@ -74,6 +74,7 @@ def test_get_workzone_with_response_type(instance): assert "status" in workzone_data +@pytest.mark.serial @pytest.mark.uses_real_data def test_replace_workzone(instance, faker): """Test replacing an existing workzone""" @@ -107,6 +108,7 @@ def test_replace_workzone(instance, faker): assert restore_response.status_code in [200, 204] +@pytest.mark.serial @pytest.mark.uses_real_data def test_replace_workzone_with_auto_resolve_conflicts(instance, faker): """Test replacing a workzone with auto_resolve_conflicts parameter""" @@ -168,6 +170,7 @@ def test_replace_workzone_model_validation(): assert minimal_workzone.organization is None +@pytest.mark.serial @pytest.mark.uses_real_data def test_replace_workzone_with_model(instance, faker): """Test that replace_workzone returns Workzone when using model parameter and status is 200""" diff --git a/tests/test_get_activities_params.py b/tests/test_get_activities_params.py new file mode 100644 index 0000000..b59a9dc --- /dev/null +++ b/tests/test_get_activities_params.py @@ -0,0 +1,195 @@ +"""Tests for GetActivitiesParams model.""" + +from datetime import date + +import pytest +from pydantic import ValidationError + +from ofsc.models import GetActivitiesParams + + +class TestGetActivitiesParamsValidation: + """Test GetActivitiesParams validation logic.""" + + def test_valid_params_with_dates_and_resources(self): + """Test valid params with all common fields.""" + params = GetActivitiesParams( + resources=["SUNRISE", "TECH001"], + includeChildren="all", + dateFrom=date(2025, 12, 1), + dateTo=date(2025, 12, 31), + fields=["activityId", "status", "activityType"], + q="status=='pending'", + ) + + assert params.resources == ["SUNRISE", "TECH001"] + assert params.dateFrom == date(2025, 12, 1) + assert params.dateTo == date(2025, 12, 31) + + def test_valid_params_with_include_non_scheduled(self): + """Test valid params with includeNonScheduled=True (dates not required).""" + params = GetActivitiesParams( + resources=["SUNRISE"], + includeNonScheduled=True, + ) + + assert params.includeNonScheduled is True + assert params.dateFrom is None + assert params.dateTo is None + + def test_valid_params_with_svc_work_order_id(self): + """Test valid params with svcWorkOrderId (dates not required).""" + params = GetActivitiesParams( + svcWorkOrderId=12345, + ) + + assert params.svcWorkOrderId == 12345 + assert params.dateFrom is None + + def test_invalid_date_from_without_date_to(self): + """Test invalid: dateFrom without dateTo.""" + with pytest.raises(ValidationError) as exc_info: + GetActivitiesParams( + resources=["SUNRISE"], + dateFrom=date(2025, 12, 1), + ) + + assert "dateFrom and dateTo must both be specified or both omitted" in str( + exc_info.value + ) + + def test_invalid_date_to_without_date_from(self): + """Test invalid: dateTo without dateFrom.""" + with pytest.raises(ValidationError) as exc_info: + GetActivitiesParams( + resources=["SUNRISE"], + dateTo=date(2025, 12, 31), + ) + + assert "dateFrom and dateTo must both be specified or both omitted" in str( + exc_info.value + ) + + def test_invalid_date_from_after_date_to(self): + """Test invalid: dateFrom > dateTo.""" + with pytest.raises(ValidationError) as exc_info: + GetActivitiesParams( + resources=["SUNRISE"], + dateFrom=date(2025, 12, 31), + dateTo=date(2025, 12, 1), + ) + + assert "dateFrom must be before or equal to dateTo" in str(exc_info.value) + + def test_invalid_no_dates_no_svc_work_order_id_include_non_scheduled_false(self): + """Test invalid: no dates, no svcWorkOrderId, includeNonScheduled=False.""" + with pytest.raises(ValidationError) as exc_info: + GetActivitiesParams( + resources=["SUNRISE"], + includeNonScheduled=False, + ) + + assert ( + "Either dateFrom/dateTo, svcWorkOrderId, or includeNonScheduled=True is required" + in str(exc_info.value) + ) + + def test_include_children_enum_validation(self): + """Test includeChildren accepts only valid enum values.""" + # Valid values + for value in ["none", "immediate", "all"]: + params = GetActivitiesParams( + svcWorkOrderId=12345, + includeChildren=value, + ) + assert params.includeChildren == value + + # Invalid value + with pytest.raises(ValidationError): + GetActivitiesParams( + svcWorkOrderId=12345, + includeChildren="invalid", + ) + + def test_extra_fields_forbidden(self): + """Test that extra fields are not allowed.""" + with pytest.raises(ValidationError): + GetActivitiesParams( + resources=["SUNRISE"], + dateFrom=date(2025, 12, 1), + dateTo=date(2025, 12, 31), + extraField="should fail", + ) + + +class TestGetActivitiesParamsToApiParams: + """Test to_api_params() conversion method.""" + + def test_to_api_params_all_fields(self): + """Test conversion with all fields specified.""" + params = GetActivitiesParams( + resources=["SUNRISE", "TECH001"], + includeChildren="immediate", + q="status=='pending'", + dateFrom=date(2025, 12, 1), + dateTo=date(2025, 12, 31), + fields=["activityId", "status"], + includeNonScheduled=True, + svcWorkOrderId=12345, + ) + + api_params = params.to_api_params() + + assert api_params["resources"] == "SUNRISE,TECH001" + assert api_params["includeChildren"] == "immediate" + assert api_params["q"] == "status=='pending'" + assert api_params["dateFrom"] == "2025-12-01" + assert api_params["dateTo"] == "2025-12-31" + assert api_params["fields"] == "activityId,status" + assert api_params["includeNonScheduled"] == "true" + assert api_params["svcWorkOrderId"] == 12345 + + def test_to_api_params_minimal_fields(self): + """Test conversion with minimal fields.""" + params = GetActivitiesParams( + svcWorkOrderId=12345, + ) + + api_params = params.to_api_params() + + assert api_params["svcWorkOrderId"] == 12345 + # includeChildren has default "all" + assert api_params["includeChildren"] == "all" + # Other fields should not be in output + assert "resources" not in api_params + assert "dateFrom" not in api_params + assert "q" not in api_params + + def test_to_api_params_omits_none_values(self): + """Test that None values are not included in API params.""" + params = GetActivitiesParams( + resources=["SUNRISE"], + dateFrom=date(2025, 12, 1), + dateTo=date(2025, 12, 31), + q=None, + fields=None, + ) + + api_params = params.to_api_params() + + assert "q" not in api_params + assert "fields" not in api_params + assert "resources" in api_params + + def test_to_api_params_include_non_scheduled_false_not_in_output(self): + """Test that includeNonScheduled=False is not in output.""" + params = GetActivitiesParams( + dateFrom=date(2025, 12, 1), + dateTo=date(2025, 12, 31), + includeNonScheduled=False, + ) + + api_params = params.to_api_params() + + # False should not add the parameter + assert "includeNonScheduled" not in api_params diff --git a/uv.lock b/uv.lock index 5dc4f1c..ebe6514 100644 --- a/uv.lock +++ b/uv.lock @@ -191,6 +191,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" }, ] +[[package]] +name = "execnet" +version = "2.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bf/89/780e11f9588d9e7128a3f87788354c7946a9cbb1401ad38a48c4db9a4f07/execnet-2.1.2.tar.gz", hash = "sha256:63d83bfdd9a23e35b9c6a3261412324f964c2ec8dcd8d3c6916ee9373e0befcd", size = 166622, upload-time = "2025-11-12T09:56:37.75Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ab/84/02fc1827e8cdded4aa65baef11296a9bbe595c474f0d6d758af082d849fd/execnet-2.1.2-py3-none-any.whl", hash = "sha256:67fba928dd5a544b783f6056f449e5e3931a5c378b128bc18501f7ea79e296ec", size = 40708, upload-time = "2025-11-12T09:56:36.333Z" }, +] + [[package]] name = "faker" version = "14.2.1" @@ -340,6 +349,7 @@ dev = [ { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "pytest-env" }, + { name = "pytest-xdist" }, { name = "python-dotenv" }, ] @@ -365,6 +375,7 @@ dev = [ { name = "pytest-asyncio", specifier = ">=0.24.0,<1" }, { name = "pytest-cov", specifier = ">=6.0.0,<7" }, { name = "pytest-env", specifier = ">=1.1.5,<2" }, + { name = "pytest-xdist", specifier = ">=3.8.0" }, { name = "python-dotenv", specifier = ">=1.0.1,<2" }, ] @@ -570,6 +581,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/de/b8/87cfb16045c9d4092cfcf526135d73b88101aac83bc1adcf82dfb5fd3833/pytest_env-1.1.5-py3-none-any.whl", hash = "sha256:ce90cf8772878515c24b31cd97c7fa1f4481cd68d588419fd45f10ecaee6bc30", size = 6141, upload-time = "2024-09-17T22:39:16.942Z" }, ] +[[package]] +name = "pytest-xdist" +version = "3.8.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "execnet" }, + { name = "pytest" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/78/b4/439b179d1ff526791eb921115fca8e44e596a13efeda518b9d845a619450/pytest_xdist-3.8.0.tar.gz", hash = "sha256:7e578125ec9bc6050861aa93f2d59f1d8d085595d6551c2c90b6f4fad8d3a9f1", size = 88069, upload-time = "2025-07-01T13:30:59.346Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ca/31/d4e37e9e550c2b92a9cbc2e4d0b7420a27224968580b5a447f420847c975/pytest_xdist-3.8.0-py3-none-any.whl", hash = "sha256:202ca578cfeb7370784a8c33d6d05bc6e13b4f25b5053c30a152269fd10f0b88", size = 46396, upload-time = "2025-07-01T13:30:56.632Z" }, +] + [[package]] name = "python-dateutil" version = "2.9.0.post0"