diff --git a/compiler_admin/api/toggl.py b/compiler_admin/api/toggl.py index feae5fc..965de89 100644 --- a/compiler_admin/api/toggl.py +++ b/compiler_admin/api/toggl.py @@ -165,6 +165,108 @@ def detailed_time_entries(self, start_date: datetime, end_date: datetime, **kwar return response + def list_clients( + self, ids: list[int] | None = None, name: str | None = None, start: int | None = None + ) -> requests.Response: + """Request a filtered list of clients from Toggl Reports utils. + + Args: + ids (list[int] | None): Optional client IDs to filter. + name (str | None): Optional client name filter. + start (int | None): Optional pagination cursor. + + Returns: + requests.Response: The HTTP response. + """ + params: dict[str, object] = {} + if ids is not None: + params["ids"] = ids + if name is not None: + params["name"] = name + if start is not None: + params["start"] = start + + url = self.make_api_url("filters/clients") + return self._post(url, **params) + + def list_projects( + self, + client_ids: list[int] | None = None, + currency: str | None = None, + ids: list[int] | None = None, + is_active: bool | None = None, + is_billable: bool | None = None, + is_private: bool | None = None, + name: str | None = None, + page_size: int | None = None, + start: int | None = None, + ) -> requests.Response: + """Request a filtered list of projects from Toggl Reports utils. + + Args: + client_ids (list[int] | None): Optional Toggl client IDs. + currency (str | None): Optional currency filter. + ids (list[int] | None): Optional project IDs. + is_active (bool | None): Optional archived state filter. + is_billable (bool | None): Optional billable filter. + is_private (bool | None): Optional private filter. + name (str | None): Optional project name filter. + page_size (int | None): Optional page size. + start (int | None): Optional pagination cursor. + + Returns: + requests.Response: The HTTP response. + """ + params: dict[str, object] = {} + if client_ids is not None: + params["client_ids"] = client_ids + if currency is not None: + params["currency"] = currency + if ids is not None: + params["ids"] = ids + if is_active is not None: + params["is_active"] = is_active + if is_billable is not None: + params["is_billable"] = is_billable + if is_private is not None: + params["is_private"] = is_private + if name is not None: + params["name"] = name + if page_size is not None: + params["page_size"] = page_size + if start is not None: + params["start"] = start + + url = self.make_api_url("filters/projects") + return self._post(url, **params) + + def list_project_users( + self, + client_ids: list[int] | None = None, + project_ids: list[int] | None = None, + start_id: int | None = None, + ) -> requests.Response: + """Request a filtered list of project users from Toggl Reports utils. + + Args: + client_ids (list[int] | None): Optional client IDs. + project_ids (list[int] | None): Optional project IDs. + start_id (int | None): Optional pagination cursor. + + Returns: + requests.Response: The HTTP response. + """ + params: dict[str, object] = {} + if client_ids is not None: + params["client_ids"] = client_ids + if project_ids is not None: + params["project_ids"] = project_ids + if start_id is not None: + params["start_id"] = start_id + + url = self.make_api_url("filters/project_users") + return self._post(url, **params) + class TogglWorkspace(TogglBase): WORKSPACES_ID = "workspaces/{}" diff --git a/compiler_admin/commands/ls/__init__.py b/compiler_admin/commands/ls/__init__.py index cb95103..335d062 100644 --- a/compiler_admin/commands/ls/__init__.py +++ b/compiler_admin/commands/ls/__init__.py @@ -1,7 +1,10 @@ import click +from compiler_admin.commands.ls.clients import clients from compiler_admin.commands.ls.groups import groups from compiler_admin.commands.ls.orgs import orgs +from compiler_admin.commands.ls.project_users import project_users +from compiler_admin.commands.ls.projects import projects from compiler_admin.commands.ls.users import users @@ -11,6 +14,9 @@ def ls(): pass +ls.add_command(clients) ls.add_command(groups) ls.add_command(orgs) +ls.add_command(project_users) +ls.add_command(projects) ls.add_command(users) diff --git a/compiler_admin/commands/ls/clients.py b/compiler_admin/commands/ls/clients.py new file mode 100644 index 0000000..a1bd7d6 --- /dev/null +++ b/compiler_admin/commands/ls/clients.py @@ -0,0 +1,41 @@ +import click +import pandas as pd + +from compiler_admin import FORMATS, Format +from compiler_admin.services import files +from compiler_admin.services.toggl import TogglUtils + + +@click.command() +@click.option( + "--format", + "format_key", + help="The format of the output.", + type=click.Choice(FORMATS.keys(), case_sensitive=False), + default="basic", +) +@click.option( + "-i", + "--id", + "ids", + multiple=True, + type=int, + help="A Toggl client ID to filter by. Can be supplied more than once.", +) +@click.option("--name", help="Filter clients by name.") +def clients(format_key: str, ids: tuple[int, ...], name: str | None): + """List Toggl clients from the Compiler workspace.""" + format = FORMATS.get(format_key) + ids_list = list(ids) if ids else None + + api = TogglUtils() + click.echo("Getting Toggl clients...", err=True) + items = api.get_clients(ids=ids_list, name=name) + click.echo(f"Got {len(items)} Clients", err=True) + + stdout = click.get_text_stream("stdout") + if format in [Format.BASIC, Format.CSV]: + dataframe = pd.DataFrame(items) + files.write_csv(stdout, dataframe, columns=["id", "name"]) + elif format == Format.JSON: + files.write_json(stdout, items) diff --git a/compiler_admin/commands/ls/groups.py b/compiler_admin/commands/ls/groups.py index 9880481..77e1c7d 100644 --- a/compiler_admin/commands/ls/groups.py +++ b/compiler_admin/commands/ls/groups.py @@ -22,12 +22,12 @@ def toggl(format: int = Format.BASIC, **kwargs): click.echo("Getting all Toggl groups...", err=True) groups = api.get_organization_groups() - groups_df = pd.DataFrame(groups) click.echo(f"Got {len(groups)} Groups", err=True) stdout = click.get_text_stream("stdout") if format in [Format.BASIC, Format.CSV]: - files.write_csv(stdout, groups_df, columns=["group_id", "name", "at"]) + dataframe = pd.DataFrame(groups) + files.write_csv(stdout, dataframe, columns=["group_id", "name", "at"]) elif format == Format.JSON: files.write_json(stdout, groups) diff --git a/compiler_admin/commands/ls/project_users.py b/compiler_admin/commands/ls/project_users.py new file mode 100644 index 0000000..d0359ea --- /dev/null +++ b/compiler_admin/commands/ls/project_users.py @@ -0,0 +1,60 @@ +import click +import pandas as pd + +from compiler_admin import FORMATS, Format +from compiler_admin.services import files +from compiler_admin.services.toggl import TogglUtils + + +@click.command(name="project-users") +@click.option( + "--format", + "format_key", + help="The format of the output.", + type=click.Choice(FORMATS.keys(), case_sensitive=False), + default="basic", +) +@click.option( + "-c", + "--client-id", + "client_ids", + multiple=True, + type=int, + help="A Toggl client ID to filter project users by. Can be supplied more than once.", +) +@click.option( + "-p", + "--project-id", + "project_ids", + multiple=True, + type=int, + help="A Toggl project ID to filter project users by. Can be supplied more than once.", +) +def project_users( + format_key: str, + client_ids: tuple[int, ...], + project_ids: tuple[int, ...], +): + """List Toggl project users from the Compiler workspace.""" + format = FORMATS.get(format_key) + client_ids_list = list(client_ids) if client_ids else None + project_ids_list = list(project_ids) if project_ids else None + + api = TogglUtils() + click.echo("Getting Toggl project users...", err=True) + items = api.get_project_users( + client_ids=client_ids_list, + project_ids=project_ids_list, + ) + click.echo(f"Got {len(items)} Project Users", err=True) + + stdout = click.get_text_stream("stdout") + if format in [Format.BASIC, Format.CSV]: + dataframe = pd.DataFrame(items) + files.write_csv( + stdout, + dataframe, + columns=["id", "group_id", "project_id", "user_id", "hourly_rate", "labour_cost"], + ) + elif format == Format.JSON: + files.write_json(stdout, items) diff --git a/compiler_admin/commands/ls/projects.py b/compiler_admin/commands/ls/projects.py new file mode 100644 index 0000000..e78048b --- /dev/null +++ b/compiler_admin/commands/ls/projects.py @@ -0,0 +1,93 @@ +import click +import pandas as pd + +from compiler_admin import FORMATS, Format +from compiler_admin.services import files +from compiler_admin.services.toggl import TogglUtils + + +@click.command() +@click.option( + "--format", + "format_key", + help="The format of the output.", + type=click.Choice(FORMATS.keys(), case_sensitive=False), + default="basic", +) +@click.option( + "-c", + "--client-id", + "client_ids", + multiple=True, + type=int, + help="A Toggl client ID to filter projects by. Can be supplied more than once.", +) +@click.option( + "-i", + "--id", + "ids", + multiple=True, + type=int, + help="A Toggl project ID to filter by. Can be supplied more than once.", +) +@click.option("--name", help="Filter projects by name.") +@click.option( + "--active/--inactive", + "is_active", + default=True, + help="Filter for active projects if set to --active, archived projects if set to --inactive.", +) +@click.option( + "--billable/--internal", + "is_billable", + default=True, + help="Filter for billable projects if set to --billable, non-billable if set to --internal.", +) +@click.option( + "--private/--public", + "is_private", + default=True, + help="Filter for private projects if set to --private, public projects if set to --public.", +) +def projects( + format_key: str, + client_ids: tuple[int, ...], + ids: tuple[int, ...], + name: str | None, + is_active: bool | None, + is_billable: bool | None, + is_private: bool | None, +): + """List Toggl projects from the Compiler workspace.""" + format = FORMATS.get(format_key) + client_ids_list = list(client_ids) if client_ids else None + ids_list = list(ids) if ids else None + + api = TogglUtils() + click.echo("Getting Toggl projects...", err=True) + + items = api.get_projects( + client_ids=client_ids_list, + ids=ids_list, + is_active=is_active, + is_billable=is_billable, + is_private=is_private, + name=name, + ) + click.echo(f"Got {len(items)} Projects", err=True) + + stdout = click.get_text_stream("stdout") + if format in [Format.BASIC, Format.CSV]: + dataframe = pd.DataFrame(items) + # Ensure all requested columns exist on the dataframe to avoid KeyError + requested_columns = ["id", "name", "client_id", "active", "billable", "private"] + for col in requested_columns: + if col not in dataframe.columns: + dataframe[col] = None + files.write_csv( + stdout, + dataframe, + columns=requested_columns, + ) + elif format == Format.JSON: + files.write_json(stdout, items) diff --git a/compiler_admin/commands/ls/users.py b/compiler_admin/commands/ls/users.py index ea814ee..8a15446 100644 --- a/compiler_admin/commands/ls/users.py +++ b/compiler_admin/commands/ls/users.py @@ -36,14 +36,11 @@ def toggl(format: int = Format.BASIC, inactive: bool = False, account_type: str click.echo("Getting all Toggl users...", err=True) users = api.get_organization_users(inactive=inactive, groups=group_filter, **kwargs) - users_df = pd.DataFrame(users) click.echo(f"Got {len(users)} Users", err=True) stdout = click.get_text_stream("stdout") columns = ["email"] - if format == Format.BASIC: - files.write_csv(stdout, users_df, columns=columns) - elif format == Format.CSV: + if format == Format.CSV: columns += [ "name", "id", @@ -56,7 +53,10 @@ def toggl(format: int = Format.BASIC, inactive: bool = False, account_type: str "2fa_enabled", "avatar_url", ] - files.write_csv(stdout, users_df, columns=columns) + + if format in [Format.BASIC, Format.CSV]: + dataframe = pd.DataFrame(users) + files.write_csv(stdout, dataframe, columns=columns) elif format == Format.JSON: files.write_json(stdout, users) diff --git a/compiler_admin/services/toggl.py b/compiler_admin/services/toggl.py index 1bd6881..e7fa52f 100644 --- a/compiler_admin/services/toggl.py +++ b/compiler_admin/services/toggl.py @@ -311,6 +311,122 @@ def summarize(self, path: str | TextIO) -> TimeSummary: return summary +class TogglUtils(TogglService): + def get_clients(self, ids: list[int] | None = None, name: str | None = None) -> dict: + """Get a filtered list of Toggl clients, paging until all results are returned.""" + results: list[dict] = [] + start_cursor = None + seen_ids: set[int] = set() + + while True: + response = self.api_reports.list_clients(ids=ids, name=name, start=start_cursor) + data = response.json() + # API returns a list of items for this endpoint; treat the json as the page items. + if not data: + break + + new_items = [item for item in data if item.get("id") not in seen_ids] + if not new_items: + break + + results.extend(new_items) + seen_ids.update(item.get("id") for item in new_items if item.get("id") is not None) + + last_id = data[-1].get("id") + if last_id is None: + break + if start_cursor is not None and last_id <= start_cursor: + break + + start_cursor = last_id + + return results + + def get_projects( + self, + client_ids: list[int] | None = None, + ids: list[int] | None = None, + is_active: bool | None = None, + is_billable: bool | None = None, + is_private: bool | None = None, + name: str | None = None, + ) -> dict: + """Get a filtered list of Toggl projects, paging until all results are returned.""" + results: list[dict] = [] + start_cursor = None + page_size = 200 # default to 200, but code below grabs all the projects + seen_ids: set[int] = set() + + while True: + response = self.api_reports.list_projects( + client_ids=client_ids, + ids=ids, + is_active=is_active, + is_billable=is_billable, + is_private=is_private, + name=name, + page_size=page_size, + start=start_cursor, + ) + data = response.json() + # API returns a list of items for this endpoint; treat the json as the page items. + if not data: + break + + new_items = [item for item in data if item.get("id") not in seen_ids] + if not new_items: + break + + results.extend(new_items) + seen_ids.update(item.get("id") for item in new_items if item.get("id") is not None) + + last_id = data[-1].get("id") + if last_id is None: + break + if start_cursor is not None and last_id <= start_cursor: + break + if len(data) < page_size: + break + + start_cursor = last_id + + return results + + def get_project_users(self, client_ids: list[int] | None = None, project_ids: list[int] | None = None) -> dict: + """Get a filtered list of Toggl project users, paging until all results are returned.""" + results: list[dict] = [] + current_start_id = None + seen_ids: set[int] = set() + + while True: + response = self.api_reports.list_project_users( + client_ids=client_ids, + project_ids=project_ids, + start_id=current_start_id, + ) + data = response.json() + # API returns a list of items for this endpoint; treat the json as the page items. + if not data: + break + + new_items = [item for item in data if item.get("id") not in seen_ids] + if not new_items: + break + + results.extend(new_items) + seen_ids.update(item.get("id") for item in new_items if item.get("id") is not None) + + last_id = data[-1].get("id") + if last_id is None: + break + if current_start_id is not None and last_id <= current_start_id: + break + + current_start_id = last_id + + return results + + class TogglUsers(TogglService): def get_organization_group(self, name: str) -> dict: """Get group of users from the Toggl organization. diff --git a/docs/guides/ls.md b/docs/guides/ls.md index 53b39e3..c33c6f8 100644 --- a/docs/guides/ls.md +++ b/docs/guides/ls.md @@ -49,3 +49,15 @@ Or for even more detailed JSON output: ```console compiler-admin ls users --format json ``` + +## Listing Toggl Resources + +Because Toggl is the primary source for workspace project metadata today, `compiler-admin ls` also supports Toggl-specific list operations: + +```console +compiler-admin ls clients +compiler-admin ls projects +compiler-admin ls project-users +``` + +Each command supports the `--format` flag and filtering options such as `--id`, `--client-id`, and `--name`. diff --git a/tests/api/test_toggl.py b/tests/api/test_toggl.py index 5d473a9..186fd1b 100644 --- a/tests/api/test_toggl.py +++ b/tests/api/test_toggl.py @@ -146,6 +146,49 @@ def test_toggl_detailed_time_entries_dynamic_timeout(self, mock_requests): mock_requests.post.assert_called_once() assert mock_requests.post.call_args.kwargs["timeout"] == 30 + def test_list_clients(self, mocker): + url = "https://api.track.toggl.com/reports/api/v3/workspace/1234/filters/clients" + self.toggl._post = mocker.Mock() + + self.toggl.list_clients(ids=[1, 2], name="Client", start=10) + + self.toggl._post.assert_called_once_with(url, ids=[1, 2], name="Client", start=10) + + def test_list_projects(self, mocker): + url = "https://api.track.toggl.com/reports/api/v3/workspace/1234/filters/projects" + self.toggl._post = mocker.Mock() + + self.toggl.list_projects( + client_ids=[1], + ids=[11], + is_active=True, + is_billable=False, + is_private=True, + name="Project", + page_size=50, + start=0, + ) + + self.toggl._post.assert_called_once_with( + url, + client_ids=[1], + ids=[11], + is_active=True, + is_billable=False, + is_private=True, + name="Project", + page_size=50, + start=0, + ) + + def test_list_project_users(self, mocker): + url = "https://api.track.toggl.com/reports/api/v3/workspace/1234/filters/project_users" + self.toggl._post = mocker.Mock() + + self.toggl.list_project_users(client_ids=[1], project_ids=[11], start_id=5) + + self.toggl._post.assert_called_once_with(url, client_ids=[1], project_ids=[11], start_id=5) + class TestTogglWorkspace: @pytest.fixture(autouse=True) diff --git a/tests/commands/ls/test_clients.py b/tests/commands/ls/test_clients.py new file mode 100644 index 0000000..d26edf9 --- /dev/null +++ b/tests/commands/ls/test_clients.py @@ -0,0 +1,31 @@ +import json + +import pytest + +from compiler_admin import Result +from compiler_admin.commands.ls.clients import clients + + +@pytest.fixture +def mock_toggl_clients(mocker): + return mocker.patch("compiler_admin.commands.ls.clients.TogglUtils").return_value + + +def test_clients(cli_runner, mock_toggl_clients): + mock_toggl_clients.get_clients.return_value = [{"id": 1, "name": "Client A"}] + + result = cli_runner.invoke(clients, []) + + assert result.exit_code == Result.SUCCESS + mock_toggl_clients.get_clients.assert_called_once_with(ids=None, name=None) + assert "id,name" in result.output + assert "Client A" in result.output + + +def test_clients_json_format(cli_runner, mock_toggl_clients): + mock_toggl_clients.get_clients.return_value = [{"id": 2, "name": "Client B"}] + + result = cli_runner.invoke(clients, ["--format", "json"]) + + assert result.exit_code == Result.SUCCESS + assert json.dumps(mock_toggl_clients.get_clients.return_value, indent=2) in result.output diff --git a/tests/commands/ls/test_init.py b/tests/commands/ls/test_init.py index 3384520..afcd657 100644 --- a/tests/commands/ls/test_init.py +++ b/tests/commands/ls/test_init.py @@ -5,7 +5,14 @@ @pytest.mark.parametrize( "command", - ["groups", "orgs", "users"], + [ + "clients", + "groups", + "orgs", + "project-users", + "projects", + "users", + ], ) def test_user_commands(command): assert command in ls.commands diff --git a/tests/commands/ls/test_project_users.py b/tests/commands/ls/test_project_users.py new file mode 100644 index 0000000..9b2eef3 --- /dev/null +++ b/tests/commands/ls/test_project_users.py @@ -0,0 +1,24 @@ +import pytest + +from compiler_admin import Result +from compiler_admin.commands.ls.project_users import project_users + + +@pytest.fixture +def mock_toggl_project_users(mocker): + return mocker.patch("compiler_admin.commands.ls.project_users.TogglUtils").return_value + + +def test_project_users(cli_runner, mock_toggl_project_users): + mock_toggl_project_users.get_project_users.return_value = [ + {"id": 21, "group_id": 7, "project_id": 11, "user_id": 99, "hourly_rate": 120, "labour_cost": 80} + ] + + result = cli_runner.invoke(project_users, ["--client-id", "1", "--project-id", "11"]) + + assert result.exit_code == Result.SUCCESS + mock_toggl_project_users.get_project_users.assert_called_once_with( + client_ids=[1], + project_ids=[11], + ) + assert "group_id" in result.output diff --git a/tests/commands/ls/test_projects.py b/tests/commands/ls/test_projects.py new file mode 100644 index 0000000..4b912e0 --- /dev/null +++ b/tests/commands/ls/test_projects.py @@ -0,0 +1,31 @@ +import pytest + +from compiler_admin import Result +from compiler_admin.commands.ls.projects import projects + + +@pytest.fixture +def mock_toggl_projects(mocker): + return mocker.patch("compiler_admin.commands.ls.projects.TogglUtils").return_value + + +def test_projects(cli_runner, mock_toggl_projects): + mock_toggl_projects.get_projects.return_value = [ + {"id": 11, "name": "Project A", "client_id": 1, "active": True, "billable": False, "private": False} + ] + + result = cli_runner.invoke( + projects, + ["--client-id", "1", "--id", "11", "--name", "Project A", "--active", "--internal"], + ) + + assert result.exit_code == Result.SUCCESS + mock_toggl_projects.get_projects.assert_called_once_with( + client_ids=[1], + ids=[11], + is_active=True, + is_billable=False, + is_private=True, + name="Project A", + ) + assert "Project A" in result.output diff --git a/tests/services/test_toggl.py b/tests/services/test_toggl.py index a89c6f6..dd350e3 100644 --- a/tests/services/test_toggl.py +++ b/tests/services/test_toggl.py @@ -9,7 +9,7 @@ import pytest import compiler_admin.services.toggl -from compiler_admin.services.toggl import TogglTime, TogglUsers, __name__ as MODULE, files +from compiler_admin.services.toggl import TogglTime, TogglUsers, TogglUtils, __name__ as MODULE, files @pytest.fixture(autouse=True) @@ -355,3 +355,93 @@ def test_get_workspace_users(self, mocker): assert output == data self.users.api_workspace.get_users.assert_called_once_with(**kwargs) + + +class TestTogglUtils: + @pytest.fixture(autouse=True) + def setup(self, mocker): + self.utils = TogglUtils() + self.utils.api_reports = mocker.Mock() + + def test_get_clients(self, mocker): + data = [{"id": 1, "name": "Client A"}] + response = mocker.Mock(json=mocker.Mock(side_effect=[data, []])) + self.utils.api_reports.list_clients.return_value = response + + output = self.utils.get_clients(ids=[1], name="Client A") + + assert output == data + assert self.utils.api_reports.list_clients.call_count == 2 + assert self.utils.api_reports.list_clients.call_args_list[0] == mocker.call(ids=[1], name="Client A", start=None) + + def test_get_projects(self, mocker): + data = [{"id": i, "name": f"Project {i}"} for i in range(1, 201)] + response = mocker.Mock(json=mocker.Mock(side_effect=[data, []])) + self.utils.api_reports.list_projects.return_value = response + + output = self.utils.get_projects( + client_ids=[1], + ids=[11], + is_active=True, + is_billable=False, + is_private=True, + name="Project A", + ) + + assert output == data + assert self.utils.api_reports.list_projects.call_count == 2 + assert self.utils.api_reports.list_projects.call_args_list[0] == mocker.call( + client_ids=[1], + ids=[11], + is_active=True, + is_billable=False, + is_private=True, + name="Project A", + page_size=200, + start=None, + ) + + def test_get_project_users(self, mocker): + data = [{"id": 21, "project_id": 11, "user_id": 99}] + response = mocker.Mock(json=mocker.Mock(side_effect=[data, []])) + self.utils.api_reports.list_project_users.return_value = response + + output = self.utils.get_project_users(client_ids=[1], project_ids=[11]) + + assert output == data + assert self.utils.api_reports.list_project_users.call_count == 2 + assert self.utils.api_reports.list_project_users.call_args_list[0] == mocker.call( + client_ids=[1], + project_ids=[11], + start_id=None, + ) + + def test_get_clients_paging(self, mocker): + first_page = [{"id": 1, "name": "Client A"}, {"id": 2, "name": "Client B"}] + second_page = [{"id": 3, "name": "Client C"}] + end_page = [] + self.utils.api_reports.list_clients.side_effect = [ + mocker.Mock(json=mocker.Mock(return_value=first_page)), + mocker.Mock(json=mocker.Mock(return_value=second_page)), + mocker.Mock(json=mocker.Mock(return_value=end_page)), + ] + + output = self.utils.get_clients(name="Client") + + assert output == first_page + second_page + assert self.utils.api_reports.list_clients.call_count == 3 + + def test_get_projects_paging(self, mocker): + first_page = [{"id": i, "name": f"Project {i}"} for i in range(1, 201)] + second_page = [{"id": 201, "name": "Project 201"}] + self.utils.api_reports.list_projects.side_effect = [ + mocker.Mock(json=mocker.Mock(return_value=first_page)), + mocker.Mock(json=mocker.Mock(return_value=second_page)), + ] + + output = self.utils.get_projects(client_ids=[1]) + + assert len(output) == 201 + assert output[0]["id"] == 1 + assert output[-1]["id"] == 201 + assert self.utils.api_reports.list_projects.call_count == 2