Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 10 additions & 3 deletions dailybot_cli/api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
Expand Down
12 changes: 10 additions & 2 deletions dailybot_cli/commands/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
9 changes: 7 additions & 2 deletions dailybot_cli/commands/user_scoped_actions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
40 changes: 38 additions & 2 deletions tests/api_client_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}

Expand All @@ -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
Expand Down
17 changes: 17 additions & 0 deletions tests/public_api_commands_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down