diff --git a/CHANGELOG.md b/CHANGELOG.md index de7e8fe..011d560 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## [Unreleased] +- Replace `entropy-data organization custom-team-roles ...` and the interim `organization team-roles-mode` command with `entropy-data settings team-roles get|put`. The configuration is now a single aggregate payload — `{mode: default}` or `{mode: custom-team-roles, team-roles: [...]}` — wrapping the new `GET`/`PUT /api/settings/team-roles`. `put` replaces the whole catalog when mode is `custom-team-roles`; an empty catalog or dropping a role still assigned to a member is rejected with 409. + ## [0.3.14] - `integrations` commands now address an integration by its `externalId` only; the internal UUID is no longer accepted. The API is now externalId-native — every path keys on `externalId` (`GET /api/integrations/{externalId}`, `.../runs`, `.../run`, `.../cancel`) and responses no longer return the internal `ingestionId`. Run and trigger output reference their integration via `integrationExternalId`. Display `name` is still accepted and resolved client-side. diff --git a/README.md b/README.md index 2e207c8..66e7e6f 100644 --- a/README.md +++ b/README.md @@ -69,6 +69,10 @@ entropy-data datacontracts list --output json # Create or update a team from a YAML file entropy-data teams put marketing --file team.yaml +# Show the team roles configuration, or switch to a custom role catalog +entropy-data settings team-roles get +entropy-data settings team-roles put --file team-roles.yaml + # Approve an access agreement entropy-data access approve 640864de-83d4-4619-afba-ccea8037ed3a @@ -98,8 +102,8 @@ entropy-data [--version] [--connection NAME] [--output table|json|yaml] [--debug api-keys create | delete connectors list | get | put | delete integrations list | get | runs | runs-get | runs-latest | run | cancel - organization get | members ... | git-credentials ... | custom-team-roles ... - settings get-customization | put-customization | get-scim-mapping | put-scim-mapping + organization get | members ... | git-credentials ... + settings get-customization | put-customization | get-scim-mapping | put-scim-mapping | team-roles ... events poll lineage list | submit | delete search query diff --git a/src/entropy_data/commands/custom_team_roles.py b/src/entropy_data/commands/custom_team_roles.py deleted file mode 100644 index f2adc94..0000000 --- a/src/entropy_data/commands/custom_team_roles.py +++ /dev/null @@ -1,161 +0,0 @@ -"""Custom team roles commands (organization-scoped).""" - -from pathlib import Path -from typing import Annotated, Optional - -import typer - -from entropy_data.output import OutputFormat, print_resource, print_resource_list, print_success -from entropy_data.util import read_body - -custom_team_roles_app = typer.Typer(no_args_is_help=True) - -BASE_PATH = "organization/custom-team-roles" - - -def _split_permissions(values: Optional[list[str]]) -> Optional[list[str]]: - """Allow `--permissions A,B --permissions C` style; flatten and trim.""" - if not values: - return None - out: list[str] = [] - for v in values: - for part in v.split(","): - part = part.strip() - if part: - out.append(part) - return out - - -def _build_body( - name: str, - body_name: Optional[str], - description: Optional[str], - rank: Optional[int], - permissions: Optional[list[str]], - file: Optional[Path], -) -> dict: - if file is not None: - body = read_body(file) - body.setdefault("name", body_name or name) - return body - body: dict = {"name": body_name or name} - if description is not None: - body["description"] = description - if rank is not None: - body["rank"] = rank - if permissions is not None: - body["permissions"] = permissions - else: - body["permissions"] = [] - return body - - -@custom_team_roles_app.command("list") -def list_roles( - page: Annotated[int, typer.Option("--page", "-p", help="Page number (0-indexed).")] = 0, - output: Annotated[Optional[OutputFormat], typer.Option("--output", "-o", help="Output format.")] = None, -) -> None: - """List custom team roles defined for the organization.""" - from entropy_data.cli import get_client, get_output_format, handle_error - - fmt = output or get_output_format() - try: - client = get_client() - data, has_next = client.list_resources(BASE_PATH, params={"p": page}) - print_resource_list(data, "custom-team-roles", fmt, has_next_page=has_next, page=page) - except Exception as e: - handle_error(e) - - -@custom_team_roles_app.command("get") -def get_role( - name: Annotated[str, typer.Argument(help="Name of the custom team role.")], - output: Annotated[Optional[OutputFormat], typer.Option("--output", "-o", help="Output format.")] = None, -) -> None: - """Get a custom team role by name.""" - from entropy_data.cli import get_client, get_output_format, handle_error - - fmt = output or get_output_format() - try: - client = get_client() - data = client.get_resource(BASE_PATH, name) - print_resource(data, "custom-team-roles", fmt) - except Exception as e: - handle_error(e) - - -@custom_team_roles_app.command("put") -def put_role( - name: Annotated[str, typer.Argument(help="Name of the role at this URL. Pass a different --name to rename.")], - file: Annotated[ - Optional[Path], - typer.Option("--file", "-f", help="JSON or YAML file with the body (use - for stdin)."), - ] = None, - body_name: Annotated[ - Optional[str], - typer.Option( - "--name", - help="Name to set on the role. Must equal the path name on create. Set to a different value to rename an existing role.", - ), - ] = None, - description: Annotated[Optional[str], typer.Option("--description", help="Optional description.")] = None, - rank: Annotated[ - Optional[int], - typer.Option("--rank", help="Display order — lower ranks appear first."), - ] = None, - permissions: Annotated[ - Optional[list[str]], - typer.Option( - "--permissions", - help="Comma-separated or repeated TeamPermission values (e.g. ACCESS_APPROVE,ACCESS_EDIT).", - ), - ] = None, -) -> None: - """Create, update, or rename a custom team role. - - Resolution by path `name`: - - No role at path, body name matches → create. - - Role at path, body name matches → update in place. - - Role at path, body name differs → rename (rejected if the path name is still - assigned to a team member, or if the new name already exists). - - No role at path, body name differs → 400. - """ - from entropy_data.cli import get_client, handle_error - - body = _build_body( - name=name, - body_name=body_name, - description=description, - rank=rank, - permissions=_split_permissions(permissions), - file=file, - ) - - try: - client = get_client() - client.put_resource(BASE_PATH, name, body) - target = body.get("name") or name - if target != name: - print_success(f"Custom team role '{name}' renamed to '{target}'.") - else: - print_success(f"Custom team role '{target}' saved.") - except Exception as e: - handle_error(e) - - -@custom_team_roles_app.command("delete") -def delete_role( - name: Annotated[str, typer.Argument(help="Name of the custom team role to delete.")], -) -> None: - """Delete a custom team role. - - Rejected with 409 when the role is still assigned to any team member. - """ - from entropy_data.cli import get_client, handle_error - - try: - client = get_client() - client.delete_resource(BASE_PATH, name) - print_success(f"Custom team role '{name}' deleted.") - except Exception as e: - handle_error(e) diff --git a/src/entropy_data/commands/organization.py b/src/entropy_data/commands/organization.py index 767ae6c..39db6e3 100644 --- a/src/entropy_data/commands/organization.py +++ b/src/entropy_data/commands/organization.py @@ -88,7 +88,6 @@ def get_member( organization_app.add_typer(members_app, name="members", help="Manage organization members.") -from entropy_data.commands.custom_team_roles import custom_team_roles_app # noqa: E402 from entropy_data.commands.git_credentials import make_git_credentials_app # noqa: E402 organization_app.add_typer( @@ -96,9 +95,3 @@ def get_member( name="git-credentials", help="Manage organization-level git credentials.", ) - -organization_app.add_typer( - custom_team_roles_app, - name="custom-team-roles", - help="Manage organization-level custom team roles.", -) diff --git a/src/entropy_data/commands/settings.py b/src/entropy_data/commands/settings.py index b6bf774..22bc9ca 100644 --- a/src/entropy_data/commands/settings.py +++ b/src/entropy_data/commands/settings.py @@ -6,10 +6,13 @@ from typing import Annotated, Optional import typer +from rich.table import Table -from entropy_data.output import OutputFormat, console, print_success +from entropy_data.output import OutputFormat, console, print_data, print_success +from entropy_data.util import read_body settings_app = typer.Typer(no_args_is_help=True) +team_roles_app = typer.Typer(no_args_is_help=True) @settings_app.command("get-customization") @@ -109,3 +112,74 @@ def _put_yaml_or_json(client, path: str, file: Path) -> None: timeout=REQUEST_TIMEOUT, ) _raise_for_status(response) + + +@team_roles_app.command("get") +def get_team_roles( + output: Annotated[Optional[OutputFormat], typer.Option("--output", "-o", help="Output format.")] = None, +) -> None: + """Show the team roles configuration (default roles or the custom catalog).""" + from entropy_data.cli import get_client, get_output_format, handle_error + from entropy_data.client import REQUEST_TIMEOUT, _raise_for_status + + fmt = output or get_output_format() + try: + client = get_client() + response = client.session.get(f"{client.base_url}/api/settings/team-roles", timeout=REQUEST_TIMEOUT) + _raise_for_status(response) + data = response.json() + if fmt != OutputFormat.table: + print_data(data, fmt) + return + + console.print(f"mode: [cyan]{data.get('mode', '')}[/cyan]") + roles = data.get("team-roles") or [] + if roles: + table = Table() + table.add_column("name", style="cyan") + table.add_column("rank") + table.add_column("permissions") + for role in roles: + table.add_row( + str(role.get("name", "")), + str(role.get("rank", "")), + ", ".join(role.get("permissions", []) or []), + ) + console.print(table) + except Exception as e: + handle_error(e) + + +@team_roles_app.command("put") +def put_team_roles( + file: Annotated[Path, typer.Option("--file", "-f", help="JSON or YAML file with the body (use - for stdin).")] = ..., +) -> None: + """Set the team roles configuration. + + The body is the aggregate payload, e.g. `{"mode": "default"}` or + `{"mode": "custom-team-roles", "team-roles": [ ... ]}`. With `custom-team-roles` + the listed roles replace the whole catalog; an empty list is rejected with 409. + """ + from entropy_data.cli import get_client, handle_error + from entropy_data.client import REQUEST_TIMEOUT, _raise_for_status + + try: + body = read_body(file) + client = get_client() + response = client.session.put( + f"{client.base_url}/api/settings/team-roles", + json=body, + timeout=REQUEST_TIMEOUT, + ) + _raise_for_status(response) + mode = response.json().get("mode", "") + print_success(f"Team roles configuration saved (mode: {mode}).") + except Exception as e: + handle_error(e) + + +settings_app.add_typer( + team_roles_app, + name="team-roles", + help="Get or set the organization's team roles configuration.", +) diff --git a/tests/commands/test_custom_team_roles.py b/tests/commands/test_custom_team_roles.py deleted file mode 100644 index 3de3325..0000000 --- a/tests/commands/test_custom_team_roles.py +++ /dev/null @@ -1,235 +0,0 @@ -"""Tests for organization custom-team-roles commands.""" - -import json - -import responses -from typer.testing import CliRunner - -import entropy_data.config as cfg -from entropy_data.cli import app - -runner = CliRunner() -BASE_URL = "https://api.entropy-data.com" -COLLECTION_URL = f"{BASE_URL}/api/organization/custom-team-roles" - -APPROVER = { - "id": "00000000-0000-0000-0000-000000000001", - "name": "Approver", - "description": "Approves access", - "rank": 10, - "permissions": ["ACCESS_APPROVE", "ACCESS_EDIT"], - "createdAt": "2026-06-10T00:00:00Z", - "createdBy": "api-key:abc", - "updatedAt": "2026-06-10T00:00:00Z", - "updatedBy": "api-key:abc", -} - - -def _setup(monkeypatch, tmp_path): - monkeypatch.setattr(cfg, "CONFIG_FILE", tmp_path / "config.toml") - monkeypatch.setenv("ENTROPY_DATA_API_KEY", "test-key") - - -@responses.activate -def test_list_table(monkeypatch, tmp_path): - _setup(monkeypatch, tmp_path) - responses.add(responses.GET, COLLECTION_URL, json=[APPROVER], status=200) - result = runner.invoke(app, ["organization", "custom-team-roles", "list"]) - assert result.exit_code == 0, result.output - assert "Approver" in result.output - assert "ACCESS_APPROVE" in result.output - - -@responses.activate -def test_list_json(monkeypatch, tmp_path): - _setup(monkeypatch, tmp_path) - responses.add(responses.GET, COLLECTION_URL, json=[APPROVER], status=200) - result = runner.invoke(app, ["organization", "custom-team-roles", "list", "--output", "json"]) - assert result.exit_code == 0 - data = json.loads(result.output) - assert len(data) == 1 - assert data[0]["name"] == "Approver" - assert data[0]["permissions"] == ["ACCESS_APPROVE", "ACCESS_EDIT"] - - -@responses.activate -def test_list_paging(monkeypatch, tmp_path): - _setup(monkeypatch, tmp_path) - responses.add(responses.GET, COLLECTION_URL, json=[APPROVER], status=200) - result = runner.invoke(app, ["organization", "custom-team-roles", "list", "--page", "2"]) - assert result.exit_code == 0 - assert "p=2" in responses.calls[0].request.url - - -@responses.activate -def test_get(monkeypatch, tmp_path): - _setup(monkeypatch, tmp_path) - responses.add(responses.GET, f"{COLLECTION_URL}/Approver", json=APPROVER, status=200) - result = runner.invoke(app, ["organization", "custom-team-roles", "get", "Approver", "--output", "json"]) - assert result.exit_code == 0 - data = json.loads(result.output) - assert data["name"] == "Approver" - assert data["rank"] == 10 - - -@responses.activate -def test_get_not_found(monkeypatch, tmp_path): - _setup(monkeypatch, tmp_path) - responses.add( - responses.GET, - f"{COLLECTION_URL}/Ghost", - json={ - "type": "about:blank", - "title": "Not Found", - "status": 404, - "detail": "Custom team role 'Ghost' not found", - }, - status=404, - ) - result = runner.invoke(app, ["organization", "custom-team-roles", "get", "Ghost"]) - assert result.exit_code != 0 - assert "not found" in result.output.lower() - - -@responses.activate -def test_put_create_from_options(monkeypatch, tmp_path): - _setup(monkeypatch, tmp_path) - responses.add(responses.PUT, f"{COLLECTION_URL}/Approver", json=APPROVER, status=201) - result = runner.invoke( - app, - [ - "organization", - "custom-team-roles", - "put", - "Approver", - "--description", - "Approves access", - "--rank", - "10", - "--permissions", - "ACCESS_APPROVE,ACCESS_EDIT", - ], - ) - assert result.exit_code == 0, result.output - assert "saved" in result.output.lower() - body = json.loads(responses.calls[0].request.body) - assert body["name"] == "Approver" - assert body["rank"] == 10 - assert body["permissions"] == ["ACCESS_APPROVE", "ACCESS_EDIT"] - - -@responses.activate -def test_put_create_from_file(monkeypatch, tmp_path): - _setup(monkeypatch, tmp_path) - body_file = tmp_path / "role.json" - body_file.write_text(json.dumps({"name": "Approver", "rank": 10, "permissions": ["ACCESS_APPROVE"]})) - responses.add(responses.PUT, f"{COLLECTION_URL}/Approver", json=APPROVER, status=201) - result = runner.invoke(app, ["organization", "custom-team-roles", "put", "Approver", "--file", str(body_file)]) - assert result.exit_code == 0, result.output - body = json.loads(responses.calls[0].request.body) - assert body["name"] == "Approver" - assert body["permissions"] == ["ACCESS_APPROVE"] - - -@responses.activate -def test_put_rename(monkeypatch, tmp_path): - _setup(monkeypatch, tmp_path) - renamed = {**APPROVER, "name": "FinalApprover"} - responses.add(responses.PUT, f"{COLLECTION_URL}/Approver", json=renamed, status=200) - result = runner.invoke( - app, - [ - "organization", - "custom-team-roles", - "put", - "Approver", - "--name", - "FinalApprover", - "--permissions", - "ACCESS_APPROVE", - ], - ) - assert result.exit_code == 0, result.output - assert "renamed" in result.output.lower() - assert "FinalApprover" in result.output - body = json.loads(responses.calls[0].request.body) - assert body["name"] == "FinalApprover" - - -@responses.activate -def test_put_permissions_repeated_and_comma_combined(monkeypatch, tmp_path): - _setup(monkeypatch, tmp_path) - responses.add(responses.PUT, f"{COLLECTION_URL}/Approver", json=APPROVER, status=201) - result = runner.invoke( - app, - [ - "organization", - "custom-team-roles", - "put", - "Approver", - "--permissions", - "ACCESS_APPROVE,ACCESS_EDIT", - "--permissions", - "ACCESS_REQUEST", - ], - ) - assert result.exit_code == 0, result.output - body = json.loads(responses.calls[0].request.body) - assert body["permissions"] == ["ACCESS_APPROVE", "ACCESS_EDIT", "ACCESS_REQUEST"] - - -@responses.activate -def test_put_rejects_invalid_permission(monkeypatch, tmp_path): - _setup(monkeypatch, tmp_path) - responses.add( - responses.PUT, - f"{COLLECTION_URL}/BadRole", - json={ - "type": "about:blank", - "title": "Bad Request", - "status": 400, - "detail": "Unknown permission(s): NOT_A_THING. Valid permissions: ...", - }, - status=400, - ) - result = runner.invoke( - app, - [ - "organization", - "custom-team-roles", - "put", - "BadRole", - "--permissions", - "NOT_A_THING", - ], - ) - assert result.exit_code != 0 - assert "NOT_A_THING" in result.output - - -@responses.activate -def test_delete(monkeypatch, tmp_path): - _setup(monkeypatch, tmp_path) - responses.add(responses.DELETE, f"{COLLECTION_URL}/Approver", status=204) - result = runner.invoke(app, ["organization", "custom-team-roles", "delete", "Approver"]) - assert result.exit_code == 0, result.output - assert "deleted" in result.output.lower() - - -@responses.activate -def test_delete_in_use_shows_conflict_message(monkeypatch, tmp_path): - _setup(monkeypatch, tmp_path) - responses.add( - responses.DELETE, - f"{COLLECTION_URL}/Approver", - json={ - "type": "about:blank", - "title": "Conflict", - "status": 409, - "detail": "Custom team role 'Approver' is assigned to 1 team member(s). Reassign or remove them before deleting or renaming.", - }, - status=409, - ) - result = runner.invoke(app, ["organization", "custom-team-roles", "delete", "Approver"]) - assert result.exit_code != 0 - assert "assigned to 1 team member" in result.output diff --git a/tests/commands/test_team_roles.py b/tests/commands/test_team_roles.py new file mode 100644 index 0000000..7518464 --- /dev/null +++ b/tests/commands/test_team_roles.py @@ -0,0 +1,109 @@ +"""Tests for the settings team-roles commands.""" + +import json + +import responses +from typer.testing import CliRunner + +import entropy_data.config as cfg +from entropy_data.cli import app + +runner = CliRunner() +BASE_URL = "https://api.entropy-data.com" +URL = f"{BASE_URL}/api/settings/team-roles" + +CUSTOM = { + "mode": "custom-team-roles", + "team-roles": [ + {"name": "Approver", "rank": 0, "permissions": ["ACCESS_APPROVE", "ACCESS_EDIT"]}, + ], +} + + +def _setup(monkeypatch, tmp_path): + monkeypatch.setattr(cfg, "CONFIG_FILE", tmp_path / "config.toml") + monkeypatch.setenv("ENTROPY_DATA_API_KEY", "test-key") + + +@responses.activate +def test_get_default_table(monkeypatch, tmp_path): + _setup(monkeypatch, tmp_path) + responses.add(responses.GET, URL, json={"mode": "default"}, status=200) + result = runner.invoke(app, ["settings", "team-roles", "get"]) + assert result.exit_code == 0, result.output + assert "default" in result.output + + +@responses.activate +def test_get_custom_table(monkeypatch, tmp_path): + _setup(monkeypatch, tmp_path) + responses.add(responses.GET, URL, json=CUSTOM, status=200) + result = runner.invoke(app, ["settings", "team-roles", "get"]) + assert result.exit_code == 0, result.output + assert "custom-team-roles" in result.output + assert "Approver" in result.output + assert "ACCESS_APPROVE" in result.output + + +@responses.activate +def test_get_json(monkeypatch, tmp_path): + _setup(monkeypatch, tmp_path) + responses.add(responses.GET, URL, json=CUSTOM, status=200) + result = runner.invoke(app, ["settings", "team-roles", "get", "--output", "json"]) + assert result.exit_code == 0 + data = json.loads(result.output) + assert data["mode"] == "custom-team-roles" + assert data["team-roles"][0]["name"] == "Approver" + + +@responses.activate +def test_put_default(monkeypatch, tmp_path): + _setup(monkeypatch, tmp_path) + responses.add(responses.PUT, URL, json={"mode": "default"}, status=200) + body_file = tmp_path / "body.json" + body_file.write_text(json.dumps({"mode": "default"})) + result = runner.invoke(app, ["settings", "team-roles", "put", "--file", str(body_file)]) + assert result.exit_code == 0, result.output + assert json.loads(responses.calls[0].request.body) == {"mode": "default"} + + +@responses.activate +def test_put_custom_from_yaml(monkeypatch, tmp_path): + _setup(monkeypatch, tmp_path) + responses.add(responses.PUT, URL, json=CUSTOM, status=200) + body_file = tmp_path / "body.yaml" + body_file.write_text( + "mode: custom-team-roles\n" + "team-roles:\n" + " - name: Approver\n" + " permissions: [ACCESS_APPROVE]\n" + ) + result = runner.invoke(app, ["settings", "team-roles", "put", "--file", str(body_file)]) + assert result.exit_code == 0, result.output + sent = json.loads(responses.calls[0].request.body) + assert sent["mode"] == "custom-team-roles" + assert sent["team-roles"][0]["name"] == "Approver" + + +@responses.activate +def test_put_custom_conflict_surfaces_error(monkeypatch, tmp_path): + _setup(monkeypatch, tmp_path) + responses.add( + responses.PUT, + URL, + json={"detail": "Cannot switch to custom team roles with an empty role list."}, + status=409, + ) + body_file = tmp_path / "body.json" + body_file.write_text(json.dumps({"mode": "custom-team-roles", "team-roles": []})) + result = runner.invoke(app, ["settings", "team-roles", "put", "--file", str(body_file)]) + assert result.exit_code == 1 + assert "empty role list" in result.output + + +def test_help_lists_subcommands(monkeypatch, tmp_path): + _setup(monkeypatch, tmp_path) + result = runner.invoke(app, ["settings", "team-roles", "--help"]) + assert result.exit_code == 0 + assert "get" in result.output + assert "put" in result.output