diff --git a/pyproject.toml b/pyproject.toml index df3c535..66cb2da 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "bloomy-python" -version = "0.21.1" +version = "0.21.2" description = "Python SDK for Bloom Growth API" readme = "README.md" authors = [{ name = "Franccesco Orozco", email = "franccesco@codingdose.info" }] diff --git a/src/bloomy/operations/async_/goals.py b/src/bloomy/operations/async_/goals.py index 212fd82..1941017 100644 --- a/src/bloomy/operations/async_/goals.py +++ b/src/bloomy/operations/async_/goals.py @@ -91,13 +91,31 @@ async def delete(self, goal_id: int) -> None: response = await self._client.delete(f"rocks/{goal_id}") response.raise_for_status() + async def details(self, goal_id: int) -> GoalInfo: + """Get details for a specific goal. + + Args: + goal_id: The ID of the goal + + Returns: + A GoalInfo model instance containing the goal details + + """ + response = await self._client.get( + f"rocks/{goal_id}", params={"include_origin": True} + ) + response.raise_for_status() + data = response.json() + + return self._transform_goal_details(data) + async def update( self, goal_id: int, title: str | None = None, accountable_user: int | None = None, status: GoalStatus | str | None = None, - ) -> None: + ) -> GoalInfo: """Update a goal. Args: @@ -110,12 +128,17 @@ async def update( GoalStatus.AT_RISK, or GoalStatus.COMPLETE for type safety. Invalid values will raise ValueError via the update payload builder. + Returns: + A GoalInfo model instance containing the updated goal details + """ payload = self._build_goal_update_payload(accountable_user, title, status) response = await self._client.put(f"rocks/{goal_id}", json=payload) response.raise_for_status() + return await self.details(goal_id) + async def archive(self, goal_id: int) -> None: """Archive a rock with the specified goal ID. diff --git a/src/bloomy/operations/async_/headlines.py b/src/bloomy/operations/async_/headlines.py index 0ddaec2..1c53b80 100644 --- a/src/bloomy/operations/async_/headlines.py +++ b/src/bloomy/operations/async_/headlines.py @@ -59,18 +59,23 @@ async def create( notes_url=data.get("DetailsUrl") or "", ) - async def update(self, headline_id: int, title: str) -> None: + async def update(self, headline_id: int, title: str) -> HeadlineDetails: """Update a headline. Args: headline_id: The ID of the headline to update title: The new title of the headline + Returns: + A HeadlineDetails model instance containing the updated headline + """ payload = {"title": title} response = await self._client.put(f"headline/{headline_id}", json=payload) response.raise_for_status() + return await self.details(headline_id) + async def details(self, headline_id: int) -> HeadlineDetails: """Get headline details. diff --git a/src/bloomy/operations/goals.py b/src/bloomy/operations/goals.py index 5f62fe0..ac2acd0 100644 --- a/src/bloomy/operations/goals.py +++ b/src/bloomy/operations/goals.py @@ -124,13 +124,35 @@ def delete(self, goal_id: int) -> None: response = self._client.delete(f"rocks/{goal_id}") response.raise_for_status() + def details(self, goal_id: int) -> GoalInfo: + """Get details for a specific goal. + + Args: + goal_id: The ID of the goal + + Returns: + A GoalInfo model instance containing the goal details + + Example: + ```python + client.goal.details(1) + # Returns: GoalInfo(id=1, title='Complete project', ...) + ``` + + """ + response = self._client.get(f"rocks/{goal_id}", params={"include_origin": True}) + response.raise_for_status() + data = response.json() + + return self._transform_goal_details(data) + def update( self, goal_id: int, title: str | None = None, accountable_user: int | None = None, status: GoalStatus | str | None = None, - ) -> None: + ) -> GoalInfo: """Update a goal. Args: @@ -143,6 +165,9 @@ def update( GoalStatus.AT_RISK, or GoalStatus.COMPLETE for type safety. Invalid values will raise ValueError via the update payload builder. + Returns: + A GoalInfo model instance containing the updated goal details + Example: ```python from bloomy import GoalStatus @@ -160,6 +185,8 @@ def update( response = self._client.put(f"rocks/{goal_id}", json=payload) response.raise_for_status() + return self.details(goal_id) + def archive(self, goal_id: int) -> None: """Archive a rock with the specified goal ID. diff --git a/src/bloomy/operations/headlines.py b/src/bloomy/operations/headlines.py index a19a83f..7a4efc8 100644 --- a/src/bloomy/operations/headlines.py +++ b/src/bloomy/operations/headlines.py @@ -55,18 +55,23 @@ def create( notes_url=data.get("DetailsUrl") or "", ) - def update(self, headline_id: int, title: str) -> None: + def update(self, headline_id: int, title: str) -> HeadlineDetails: """Update a headline. Args: headline_id: The ID of the headline to update title: The new title of the headline + Returns: + A HeadlineDetails model instance containing the updated headline + """ payload = {"title": title} response = self._client.put(f"headline/{headline_id}", json=payload) response.raise_for_status() + return self.details(headline_id) + def details(self, headline_id: int) -> HeadlineDetails: """Get headline details. diff --git a/src/bloomy/operations/mixins/goals_transform.py b/src/bloomy/operations/mixins/goals_transform.py index a0edc5c..f32e715 100644 --- a/src/bloomy/operations/mixins/goals_transform.py +++ b/src/bloomy/operations/mixins/goals_transform.py @@ -51,6 +51,36 @@ def _transform_goal_list(self, data: Sequence[dict[str, Any]]) -> list[GoalInfo] for goal in data ] + def _transform_goal_details(self, data: dict[str, Any]) -> GoalInfo: + """Transform a single goal API response to a GoalInfo model. + + Args: + data: The raw API response data for a single goal. + + Returns: + A GoalInfo model. + + """ + return GoalInfo( + id=data["Id"], + user_id=data["Owner"]["Id"], + user_name=data["Owner"]["Name"], + title=data["Name"], + created_at=data["CreateTime"], + due_date=data["DueDate"], + status="Completed" if data.get("Complete") else "Incomplete", + meeting_id=( + data["Origins"][0]["Id"] + if data.get("Origins") and data["Origins"][0] + else None + ), + meeting_title=( + data["Origins"][0]["Name"] + if data.get("Origins") and data["Origins"][0] + else None + ), + ) + def _transform_archived_goals( self, data: Sequence[dict[str, Any]] ) -> list[ArchivedGoalInfo]: diff --git a/tests/test_adversarial_todos_goals.py b/tests/test_adversarial_todos_goals.py index ac3ef9c..a3795c5 100644 --- a/tests/test_adversarial_todos_goals.py +++ b/tests/test_adversarial_todos_goals.py @@ -68,6 +68,22 @@ class TestGoalUpdateOverwritesOwner: overwriting the goal owner. This has been fixed. """ + @pytest.fixture(autouse=True) + def _setup_goal_get_response(self, mock_http_client: Mock) -> None: + """Configure GET to return a valid goal response for the details() re-fetch.""" + get_response = Mock() + get_response.raise_for_status = Mock() + get_response.json.return_value = { + "Id": 1, + "Owner": {"Id": 123, "Name": "Alice"}, + "Name": "Goal Title", + "CreateTime": "2024-01-01T00:00:00Z", + "DueDate": "2024-12-31", + "Complete": False, + "Origins": [{"Id": 100, "Name": "Meeting A"}], + } + mock_http_client.get.return_value = get_response + def test_update_title_only_should_not_overwrite_owner( self, mock_http_client: Mock, mock_user_id: PropertyMock ) -> None: diff --git a/tests/test_async_goals.py b/tests/test_async_goals.py index ff82ef8..3bade3c 100644 --- a/tests/test_async_goals.py +++ b/tests/test_async_goals.py @@ -190,15 +190,30 @@ async def test_update( self, async_client: AsyncClient, mock_async_client: AsyncMock ): """Test updating a goal.""" - mock_response = MagicMock() - mock_response.raise_for_status = MagicMock() - mock_async_client.put.return_value = mock_response + mock_put_response = MagicMock() + mock_put_response.raise_for_status = MagicMock() + mock_async_client.put.return_value = mock_put_response + + mock_get_response = MagicMock() + mock_get_response.raise_for_status = MagicMock() + mock_get_response.json.return_value = { + "Id": 123, + "Owner": {"Id": 1, "Name": "John Doe"}, + "Name": "Updated Goal", + "CreateTime": "2024-01-01T00:00:00Z", + "DueDate": "2024-06-01", + "Complete": False, + "Origins": [{"Id": 10, "Name": "Team Meeting"}], + } + mock_async_client.get.return_value = mock_get_response result = await async_client.goal.update( goal_id=123, title="Updated Goal", status="on" ) - assert result is None + assert isinstance(result, GoalInfo) + assert result.id == 123 + assert result.title == "Updated Goal" mock_async_client.put.assert_called_once_with( "rocks/123", json={ diff --git a/tests/test_async_headlines.py b/tests/test_async_headlines.py index 5dd9e68..8e93bde 100644 --- a/tests/test_async_headlines.py +++ b/tests/test_async_headlines.py @@ -86,15 +86,31 @@ async def test_update( self, async_client: AsyncClient, mock_async_client: AsyncMock ): """Test updating a headline.""" - mock_response = MagicMock() - mock_response.raise_for_status = MagicMock() - mock_async_client.put.return_value = mock_response + mock_put_response = MagicMock() + mock_put_response.raise_for_status = MagicMock() + mock_async_client.put.return_value = mock_put_response + + mock_get_response = MagicMock() + mock_get_response.raise_for_status = MagicMock() + mock_get_response.json.return_value = { + "Id": 501, + "Name": "Updated headline", + "DetailsUrl": "https://example.com/headline/501", + "Owner": {"Id": 123, "Name": "John Doe"}, + "Origin": "Product Meeting", + "OriginId": 456, + "Archived": False, + "CreateTime": "2024-06-01T10:00:00Z", + "CloseTime": None, + } + mock_async_client.get.return_value = mock_get_response result = await async_client.headline.update( headline_id=501, title="Updated headline" ) - assert result is None + assert isinstance(result, HeadlineDetails) + assert result.id == 501 mock_async_client.put.assert_called_once_with( "headline/501", json={"title": "Updated headline"} ) diff --git a/tests/test_goals.py b/tests/test_goals.py index dd741cf..dc73f0d 100644 --- a/tests/test_goals.py +++ b/tests/test_goals.py @@ -109,16 +109,27 @@ def test_delete_goal(self, mock_http_client: Mock) -> None: assert result is None mock_http_client.delete.assert_called_once_with("rocks/101") - def test_update_goal(self, mock_http_client: Mock, mock_user_id: Mock) -> None: + def test_update_goal( + self, + mock_http_client: Mock, + mock_user_id: Mock, + sample_goal_data: dict[str, Any], + ) -> None: """Test updating a goal.""" - mock_response = Mock() - mock_http_client.put.return_value = mock_response + mock_put_response = Mock() + mock_http_client.put.return_value = mock_put_response + + mock_get_response = Mock() + mock_get_response.json.return_value = sample_goal_data + mock_http_client.get.return_value = mock_get_response goal_ops = GoalOperations(mock_http_client) + from bloomy.models import GoalInfo + result = goal_ops.update(goal_id=101, title="Updated Goal", status="complete") - assert result is None + assert isinstance(result, GoalInfo) mock_http_client.put.assert_called_once_with( "rocks/101", json={ diff --git a/tests/test_headlines.py b/tests/test_headlines.py index 7518160..37aebea 100644 --- a/tests/test_headlines.py +++ b/tests/test_headlines.py @@ -69,16 +69,24 @@ def test_create_default_owner( json={"title": "Product launch successful", "ownerId": 123}, ) - def test_update(self, mock_http_client: Mock) -> None: + def test_update( + self, mock_http_client: Mock, sample_headline_data: dict[str, Any] + ) -> None: """Test updating a headline.""" - mock_response = Mock() - mock_response.json.return_value = {"Id": 501, "Name": "Updated headline"} - mock_http_client.put.return_value = mock_response + mock_put_response = Mock() + mock_http_client.put.return_value = mock_put_response + + mock_get_response = Mock() + mock_get_response.json.return_value = sample_headline_data + mock_http_client.get.return_value = mock_get_response headline_ops = HeadlineOperations(mock_http_client) result = headline_ops.update(headline_id=501, title="Updated headline") - assert result is None + from bloomy.models import HeadlineDetails + + assert isinstance(result, HeadlineDetails) + assert result.id == 501 mock_http_client.put.assert_called_once_with( "headline/501", json={"title": "Updated headline"} diff --git a/uv.lock b/uv.lock index 7133b30..55e0f2b 100644 --- a/uv.lock +++ b/uv.lock @@ -61,7 +61,7 @@ wheels = [ [[package]] name = "bloomy-python" -version = "0.21.0" +version = "0.21.2" source = { editable = "." } dependencies = [ { name = "httpx" },