diff --git a/dailybot_cli/api_client.py b/dailybot_cli/api_client.py index 91cb3c6..194975d 100644 --- a/dailybot_cli/api_client.py +++ b/dailybot_cli/api_client.py @@ -309,8 +309,13 @@ def delete_form_response( ) return self._handle_response(response) - def list_users(self) -> list[dict[str, Any]]: - """GET /v1/users/ — fetch all pages and return the combined results list.""" + def list_users(self, *, include_inactive: bool = False) -> list[dict[str, Any]]: + """GET /v1/users/ — fetch all pages and return the combined results list. + + By default returns only members with ``is_active`` truthy. Pass + ``include_inactive=True`` to get the unfiltered server response (useful + for admin / audit flows that need to surface deactivated accounts). + """ results: list[dict[str, Any]] = [] url: str | None = f"{self.api_url}/v1/users/" pages_fetched: int = 0 @@ -326,7 +331,9 @@ def list_users(self) -> list[dict[str, Any]]: results.extend(body.get("results", [])) url = body.get("next") pages_fetched += 1 - return results + if include_inactive: + return results + return [u for u in results if u.get("is_active", True)] def give_kudos( self, diff --git a/dailybot_cli/commands/user.py b/dailybot_cli/commands/user.py index 597960f..2187784 100644 --- a/dailybot_cli/commands/user.py +++ b/dailybot_cli/commands/user.py @@ -16,17 +16,25 @@ def user() -> None: @user.command("list") +@click.option( + "--include-inactive", + is_flag=True, + help="Also include deactivated members (default: active members only).", +) @click.option("--json", "json_mode", is_flag=True, help="Emit machine-readable JSON to stdout.") -def user_list(json_mode: bool) -> None: +def user_list(include_inactive: bool, json_mode: bool) -> None: """List team members in your organization. \b Acts as you. You can only see and act on what you could in the webapp. + By default only active members are listed; pass --include-inactive to + include deactivated accounts (useful for admin / audit flows). \b Examples: dailybot user list + dailybot user list --include-inactive dailybot user list --json """ client = require_bearer_auth() - execute_user_list(client, json_mode=json_mode) + execute_user_list(client, json_mode=json_mode, include_inactive=include_inactive) diff --git a/dailybot_cli/commands/user_scoped_actions.py b/dailybot_cli/commands/user_scoped_actions.py index 737419b..1a7b55d 100644 --- a/dailybot_cli/commands/user_scoped_actions.py +++ b/dailybot_cli/commands/user_scoped_actions.py @@ -516,11 +516,16 @@ def execute_user_list( client: DailyBotClient, *, json_mode: bool = False, + include_inactive: bool = False, ) -> list[dict[str, Any]] | None: - """Fetch and display organization members.""" + """Fetch and display organization members. + + By default only active members are returned; pass ``include_inactive=True`` + to include deactivated accounts. + """ try: with console.status("Fetching team members..."): - users: list[dict[str, Any]] = client.list_users() + users: list[dict[str, Any]] = client.list_users(include_inactive=include_inactive) except APIError as exc: exit_for_api_error(exc, json_mode) diff --git a/tests/api_client_test.py b/tests/api_client_test.py index 94823de..dcc10d9 100644 --- a/tests/api_client_test.py +++ b/tests/api_client_test.py @@ -201,13 +201,13 @@ def test_list_users_paginated(self, client: DailyBotClient) -> None: first_response: MagicMock = MagicMock(spec=httpx.Response) first_response.status_code = 200 first_response.json.return_value = { - "results": [{"uuid": "user-1", "full_name": "Jane Doe"}], + "results": [{"uuid": "user-1", "full_name": "Jane Doe", "is_active": True}], "next": "http://test-api.example.com/v1/users/?page=2", } second_response: MagicMock = MagicMock(spec=httpx.Response) second_response.status_code = 200 second_response.json.return_value = { - "results": [{"uuid": "user-2", "full_name": "John Doe"}], + "results": [{"uuid": "user-2", "full_name": "John Doe", "is_active": True}], "next": None, } @@ -217,6 +217,42 @@ def test_list_users_paginated(self, client: DailyBotClient) -> None: assert len(result) == 2 assert mock_get.call_count == 2 + def test_list_users_filters_inactive_by_default(self, client: DailyBotClient) -> None: + page: MagicMock = MagicMock(spec=httpx.Response) + page.status_code = 200 + page.json.return_value = { + "results": [ + {"uuid": "u1", "full_name": "Active Alice", "is_active": True}, + {"uuid": "u2", "full_name": "Deactivated Dan", "is_active": False}, + {"uuid": "u3", "full_name": "Missing-flag User"}, + ], + "next": None, + } + + with patch("httpx.get", return_value=page): + result: list[dict[str, Any]] = client.list_users() + + uuids: set[str] = {u["uuid"] for u in result} + assert "u1" in uuids + assert "u2" not in uuids # is_active=False dropped + assert "u3" in uuids # missing flag defaults to active for forward-compat + + def test_list_users_include_inactive(self, client: DailyBotClient) -> None: + page: MagicMock = MagicMock(spec=httpx.Response) + page.status_code = 200 + page.json.return_value = { + "results": [ + {"uuid": "u1", "full_name": "Active Alice", "is_active": True}, + {"uuid": "u2", "full_name": "Deactivated Dan", "is_active": False}, + ], + "next": None, + } + + with patch("httpx.get", return_value=page): + result: list[dict[str, Any]] = client.list_users(include_inactive=True) + + assert len(result) == 2 + def test_list_users_page_cap(self, client: DailyBotClient) -> None: """Pagination must stop at _MAX_LIST_PAGES even if the backend keeps returning next.""" from dailybot_cli.api_client import _MAX_LIST_PAGES diff --git a/tests/public_api_commands_test.py b/tests/public_api_commands_test.py index 1dbc523..28d79f5 100644 --- a/tests/public_api_commands_test.py +++ b/tests/public_api_commands_test.py @@ -428,6 +428,23 @@ def test_user_list_json( assert result.exit_code == 0 payload: list[dict[str, Any]] = json.loads(result.output) assert payload[0]["uuid"] == "user-uuid-1" + mock_client.list_users.assert_called_once_with(include_inactive=False) + + @patch("dailybot_cli.commands.public_api_helpers.get_token") + @patch("dailybot_cli.commands.public_api_helpers.DailyBotClient") + def test_user_list_include_inactive_flag( + self, + mock_client_cls: MagicMock, + mock_get_token: MagicMock, + runner: CliRunner, + ) -> None: + mock_get_token.return_value = "tok" + mock_client: MagicMock = mock_client_cls.return_value + mock_client.list_users.return_value = [] + + result = runner.invoke(cli, ["user", "list", "--include-inactive", "--json"]) + assert result.exit_code == 0 + mock_client.list_users.assert_called_once_with(include_inactive=True) class TestKudosCommand: