diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 975d1b8..70a43ee 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,12 @@ # Changelog +## 2025-06-06 "Search Response Model Improvements" - version 1.14.1 + +### Fixed +- Enhanced SearchResponse `Object` model to properly handle more fields returned by the Iconik API, including complex nested structures +### Technical Details +This patch improves the SDK's handling of search responses by gathering more fields from the Iconik API and properly deserializing them, especially the critical `_sort` field needed for pagination and consistent ordering. + ## 2025-06-04 "Search Enhancements & Field Model Fixes" - version 1.14.0 ### Added diff --git a/pythonik/models/search/search_response.py b/pythonik/models/search/search_response.py index 831fa39..32cfbe3 100644 --- a/pythonik/models/search/search_response.py +++ b/pythonik/models/search/search_response.py @@ -2,27 +2,131 @@ from typing import Any, List, Optional, Dict -from pydantic import BaseModel +from pydantic import BaseModel, Field from pythonik.models.base import PaginatedResponse from pythonik.models.files.file import File +from pythonik.models.files.format import Format from pythonik.models.files.keyframe import Keyframe from pythonik.models.files.proxy import Proxy +class Highlight(BaseModel): + """Represents highlighted search results.""" + + title: Optional[List[str]] = [] + # Add other highlight fields as needed + + +# class FormatMetadata(BaseModel): +# """Metadata specific to a format.""" + +# format: Optional[str] = None +# # Add other format metadata fields as needed + + +# class Format(BaseModel): +# """Represents a file format.""" + +# archive_status: Optional[str] = None +# date_created: Optional[str] = None +# date_deleted: Optional[str] = None +# date_modified: Optional[str] = None +# deleted_by_user: Optional[str] = None +# id: Optional[str] = None +# is_online: Optional[bool] = None +# metadata: Optional[List[FormatMetadata]] = None +# name: Optional[str] = None +# size: Optional[int] = None +# status: Optional[str] = None +# storage_methods: Optional[List[str]] = None +# user_id: Optional[str] = None + + +class Version(BaseModel): + """Represents a version of an asset.""" + + analyze_status: Optional[str] = None + archive_status: Optional[str] = None + created_by_user: Optional[str] = None + date_created: Optional[str] = None + face_recognition_status: Optional[str] = None + has_unconfirmed_persons: Optional[bool] = None + id: Optional[str] = None + is_online: Optional[bool] = None + person_ids: Optional[List[str]] = [] + status: Optional[str] = None + transcribe_status: Optional[str] = None + transcribed_languages: Optional[List[str]] = [] + + class Object(BaseModel): - date_created: Optional[str] = "" - date_modified: Optional[str] = "" - description: Optional[str] = "" - id: Optional[str] = "" + """Represents an object in the search response.""" + + # Base fields from original model + date_created: Optional[str] = None + date_modified: Optional[str] = None + description: Optional[str] = None + id: Optional[str] = None metadata: Optional[Dict[str, Any]] = {} - object_type: Optional[str] = "" - title: Optional[str] = "" + object_type: Optional[str] = None + title: Optional[str] = None files: Optional[List[File]] = [] proxies: Optional[List[Proxy]] = [] keyframes: Optional[List[Keyframe]] = [] + # Additional fields from example response + highlight: Optional[Highlight] = Field(None, alias="_highlight") + sort: Optional[List[Any]] = Field(None, alias="_sort") + analyze_status: Optional[str] = None + ancestor_collections: Optional[List[str]] = [] + archive_status: Optional[str] = None + category: Optional[str] = None + created_by_share: Optional[str] = None + created_by_share_user: Optional[str] = None + created_by_user: Optional[str] = None + created_by_user_info: Optional[Any] = None + custom_keyframe: Optional[str] = None + custom_poster: Optional[str] = None + date_deleted: Optional[str] = None + date_imported: Optional[str] = None + date_viewed: Optional[str] = None + deleted_by_user: Optional[str] = None + external_id: Optional[str] = None + external_link: Optional[str] = None + face_recognition_status: Optional[str] = None + file_names: Optional[List[str]] = [] + format: Optional[str] = None + formats: Optional[List[Format]] = [] + has_unconfirmed_persons: Optional[bool] = None + in_collections: Optional[List[str]] = [] + is_blocked: Optional[bool] = None + is_online: Optional[bool] = None + last_archive_restore_date: Optional[str] = None + media_type: Optional[str] = None + original_asset_id: Optional[str] = None + original_segment_id: Optional[str] = None + original_version_id: Optional[str] = None + permissions: Optional[List[str]] = [] + person_ids: Optional[List[str]] = [] + position: Optional[int] = None + site_name: Optional[str] = None + status: Optional[str] = None + system_domain_id: Optional[str] = None + system_name: Optional[str] = None + time_end_milliseconds: Optional[int] = None + time_start_milliseconds: Optional[int] = None + transcribe_status: Optional[str] = None + transcribed_languages: Optional[List[str]] = [] + type: Optional[str] = None + updated_by_user: Optional[str] = None + versions: Optional[List[Version]] = [] + versions_number: Optional[int] = None + warning: Optional[str] = None + class SearchResponse(PaginatedResponse): + """Represents the complete search response.""" + facets: Optional[Dict[str, Any]] = {} objects: Optional[List[Object]] = [] diff --git a/pythonik/specs/assets.py b/pythonik/specs/assets.py index a048c82..41bca90 100644 --- a/pythonik/specs/assets.py +++ b/pythonik/specs/assets.py @@ -12,7 +12,7 @@ AssetVersionFromAssetCreate, AssetVersion, ) -from pythonik.models.base import Response +from pythonik.models.base import Response, PaginatedResponse from pythonik.specs.base import Spec from pythonik.specs.collection import CollectionSpec @@ -511,3 +511,72 @@ def delete_version( VERSION_URL.format(asset_id, version_id), params=params, **kwargs ) return self.parse_response(response, None) + + def list_all(self, **kwargs) -> Response: + """ + Get list of assets. + + Args: + **kwargs: Additional kwargs to pass to the request + + Returns: + Response(model=PaginatedResponse) containing paginated asset list + """ + resp = self._get(self.gen_url("assets/"), **kwargs) + return self.parse_response(resp, PaginatedResponse) + + def list_asset_history_entities(self, asset_id: str, **kwargs) -> Response: + """ + Get list of history entities for asset. + + Args: + asset_id: ID of the asset + **kwargs: Additional kwargs to pass to the request + + Returns: + Response(model=PaginatedResponse) containing history entities + """ + resp = self._get(self.gen_url(f"assets/{asset_id}/history/"), **kwargs) + return self.parse_response(resp, PaginatedResponse) + + def create_history_entity( + self, asset_id: str, operation_description: str, + operation_type: str, + **kwargs + ) -> Response: + """ + Create an asset history entity. + + Args: + asset_id: ID of the asset + operation_description: Description of the operation + operation_type: Type of operation (e.g., VERSION_CREATE, ADD_FORMAT) + **kwargs: Additional kwargs to pass to the request + + Returns: + Response with history entry creation status + + Raises: + ValueError: If operation_type is not a valid operation type + """ + operation_types = [ + "EXPORT", "TRANSCODE", "ANALYZE", "ADD_FORMAT", "DELETE_FORMAT", + "RESTORE_FORMAT", "DELETE_FILESET", "DELETE_FILE", + "RESTORE_FILESET", "MODIFY_FILESET", "APPROVE", "REJECT", + "DOWNLOAD", "METADATA", "CUSTOM", "TRANSCRIPTION", "VERSION_CREATE", + "VERSION_DELETE", "VERSION_UPDATE", "VERSION_PROMOTE", "RESTORE", + "RESTORE_FROM_GLACIER", "ARCHIVE", "RESTORE_ARCHIVE", "DELETE", + "TRANSFER", "UNLINK_SUBCLIP", "FACE_RECOGNITION" + ] + if operation_type not in operation_types: + raise ValueError( + f"operation_type must be one of: {'|'.join(operation_types)}" + ) + body = { + "operation_description": operation_description, + "operation_type": operation_type + } + resp = self._post( + self.gen_url(f"assets/{asset_id}/history/"), json=body, **kwargs + ) + return self.parse_response(resp, None) \ No newline at end of file diff --git a/pythonik/tests/test_assets_history.py b/pythonik/tests/test_assets_history.py new file mode 100644 index 0000000..5089c58 --- /dev/null +++ b/pythonik/tests/test_assets_history.py @@ -0,0 +1,185 @@ +# pythonik/tests/test_assets_history.py +import uuid +import pytest +import requests_mock + +from pythonik.client import PythonikClient +from pythonik.models.base import PaginatedResponse +from pythonik.specs.assets import AssetSpec + + +def test_fetch(): + """Test fetching a list of assets.""" + with requests_mock.Mocker() as m: + app_id = str(uuid.uuid4()) + auth_token = str(uuid.uuid4()) + + # Mock response data structure + response_data = { + "objects": [ + {"id": str(uuid.uuid4()), "title": "Test Asset 1", "status": "ACTIVE"}, + {"id": str(uuid.uuid4()), "title": "Test Asset 2", "status": "ACTIVE"}, + ], + "page": 1, + "pages": 1, + "per_page": 2, + "total": 2, + } + + # Setup mock endpoint + mock_address = AssetSpec.gen_url("assets/") + m.get(mock_address, json=response_data) + + # Create client and call the method + client = PythonikClient(app_id=app_id, auth_token=auth_token, timeout=3) + result = client.assets().list_all() + + # Verify response + assert result.response.ok + assert isinstance(result.data, PaginatedResponse) + assert len(result.data.objects) == 2 + assert result.data.page == 1 + assert result.data.pages == 1 + assert result.data.total == 2 + + +def test_fetch_with_params(): + """Test fetching a list of assets with query parameters.""" + with requests_mock.Mocker() as m: + app_id = str(uuid.uuid4()) + auth_token = str(uuid.uuid4()) + + # Mock response data + response_data = { + "objects": [ + {"id": str(uuid.uuid4()), "title": "Test Asset 1", "status": "ACTIVE"} + ], + "page": 1, + "pages": 1, + "per_page": 1, + "total": 1, + } + + # Setup mock endpoint with params matcher + mock_address = AssetSpec.gen_url("assets/") + m.get( + mock_address, + json=response_data, + # Add request matcher to ensure params are passed correctly + additional_matcher=lambda req: req.qs == {"page": ["1"], "per_page": ["1"]}, + ) + + # Create client and call method with params + client = PythonikClient(app_id=app_id, auth_token=auth_token, timeout=3) + params = {"page": 1, "per_page": 1} + result = client.assets().list_all(params=params) + + # Verify response + assert result.response.ok + assert isinstance(result.data, PaginatedResponse) + assert len(result.data.objects) == 1 + + +def test_fetch_asset_history_entities(): + """Test fetching history entities for an asset.""" + with requests_mock.Mocker() as m: + app_id = str(uuid.uuid4()) + auth_token = str(uuid.uuid4()) + asset_id = str(uuid.uuid4()) + + # Mock response data + response_data = { + "objects": [ + { + "id": str(uuid.uuid4()), + "operation_type": "METADATA", + "operation_description": "Updated metadata", + "date_created": "2025-05-13T10:00:00Z", + "created_by_user": "user123", + }, + { + "id": str(uuid.uuid4()), + "operation_type": "VERSION_CREATE", + "operation_description": "Created new version", + "date_created": "2025-05-12T15:30:00Z", + "created_by_user": "user123", + }, + ], + "page": 1, + "pages": 1, + "per_page": 10, + "total": 2, + } + + # Setup mock endpoint + mock_address = AssetSpec.gen_url(f"assets/{asset_id}/history/") + m.get(mock_address, json=response_data) + + # Create client and call the method + client = PythonikClient(app_id=app_id, auth_token=auth_token, timeout=3) + result = client.assets().list_asset_history_entities(asset_id) + + # Verify response + assert result.response.ok + assert isinstance(result.data, PaginatedResponse) + assert len(result.data.objects) == 2 + assert result.data.objects[0]["operation_type"] == "METADATA" + assert result.data.objects[1]["operation_type"] == "VERSION_CREATE" + + +def test_create_history_entity(): + """Test creating a history entity for an asset.""" + with requests_mock.Mocker() as m: + app_id = str(uuid.uuid4()) + auth_token = str(uuid.uuid4()) + asset_id = str(uuid.uuid4()) + operation_description = "Test history entry" + operation_type = "CUSTOM" + + # Setup mock endpoint + mock_address = AssetSpec.gen_url(f"assets/{asset_id}/history/") + m.post(mock_address, status_code=201) + + # Create client and call the method + client = PythonikClient(app_id=app_id, auth_token=auth_token, timeout=3) + result = client.assets().create_history_entity( + asset_id=asset_id, + operation_description=operation_description, + operation_type=operation_type, + ) + + # Verify response + assert result.response.ok + assert result.response.status_code == 201 + + # Verify the correct request body was sent + assert m.last_request.json() == { + "operation_description": operation_description, + "operation_type": operation_type, + } + + +def test_create_history_entity_with_invalid_operation_type(): + """Test creating a history entity with an invalid operation type.""" + app_id = str(uuid.uuid4()) + auth_token = str(uuid.uuid4()) + asset_id = str(uuid.uuid4()) + operation_description = "Test history entry" + operation_type = "INVALID_TYPE" + + # Create client + client = PythonikClient(app_id=app_id, auth_token=auth_token, timeout=3) + + # Check that an error is raised for invalid operation type + with pytest.raises(ValueError) as excinfo: + client.assets().create_history_entity( + asset_id=asset_id, + operation_description=operation_description, + operation_type=operation_type, + ) + + # Verify the error message + assert "operation_type must be one of:" in str(excinfo.value) + assert "EXPORT" in str(excinfo.value) + assert "CUSTOM" in str(excinfo.value) + assert "VERSION_CREATE" in str(excinfo.value)