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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ classifiers = [
dependencies = [
"cyclopts>=3.0",
"rich>=13.0",
"qodev-apollo-api>=0.3.1",
"qodev-apollo-api>=0.4.0",
]

[project.optional-dependencies]
Expand Down
44 changes: 44 additions & 0 deletions src/apollo_cli/commands/deals.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,50 @@ async def get(
output(deal, ctx=ctx, format_fn=format_deal_detail)


@deals_app.command
async def create(
*,
name: Annotated[str, Parameter(name="--name", help="Deal name (required)")],
owner_id: Annotated[str | None, Parameter(name="--owner-id", help="Deal owner (team member) ID")] = None,
account_id: Annotated[str | None, Parameter(name="--account-id", help="Target account/company ID")] = None,
amount: Annotated[float | None, Parameter(name="--amount", help="Deal value (no currency symbol)")] = None,
stage_id: Annotated[str | None, Parameter(name="--stage-id", help="Deal stage ID")] = None,
stage_name: Annotated[
str | None,
Parameter(name="--stage-name", help="Deal stage name (resolved to an ID; avoids a `pipelines stages` lookup)"),
] = None,
closed_date: Annotated[str | None, Parameter(name="--closed-date", help="Expected close date (YYYY-MM-DD)")] = None,
) -> None:
"""Create a new deal/opportunity.

Requires a master Apollo API key (non-master keys get a 403). ``--name`` is the
only required field. Use ``--stage-name`` to set the stage by name instead of ID.
"""
if stage_id and stage_name:
error("Pass either --stage-id or --stage-name, not both.", ctx=ctx, code="conflicting_args", exit_code=2)
return

fields: dict[str, Any] = {}
if owner_id:
fields["owner_id"] = owner_id
if account_id:
fields["account_id"] = account_id
if amount is not None:
fields["amount"] = amount
if closed_date:
fields["closed_date"] = closed_date

async with ctx.client() as client:
if stage_name:
all_stages = await client.list_all_stages()
stage_id = resolve_stage_id(stage_name, all_stages.items, kind="deal stage")
if stage_id:
fields["opportunity_stage_id"] = stage_id
deal = await client.create_deal(name, **fields)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Improvement] Call create_deal with name= keyword to be robust to a keyword-only signature in qodev-apollo-api (and to make the callsite self-documenting).

Fix: deal = await client.create_deal(name=name, **fields) (and adjust the test to assert call_args.kwargs["name"]).


output(deal, ctx=ctx, format_fn=format_deal_detail)


ROLE_TYPE_COLUMNS = [("ID", "id"), ("Name", "name"), ("Display Order", "display_order")]


Expand Down
54 changes: 54 additions & 0 deletions tests/test_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,60 @@ async def test_set_role_never_sends_null_role_type(self, capsys) -> None:
assert all("opportunity_contact_role_type_id" not in r for r in roles)


class TestDealCreate:
@pytest.mark.asyncio
async def test_create_passes_name_and_optional_fields(self, sample_deal: dict, capsys) -> None:
"""deals create forwards --name plus only the optional fields that were provided."""
mock_client = MagicMock()
mock_client.create_deal = AsyncMock(return_value=sample_deal)

_ctx.ctx.configure(json_mode=True, api_key="test-key", limit=25, page=1)

with patch.object(_ctx.ctx, "client", return_value=MockAsyncContextManager(mock_client)):
from apollo_cli.commands.deals import create

await create(name="Big One", owner_id="o1", amount=5000, stage_id="st1")

name_arg = mock_client.create_deal.call_args.args[0]
fields = mock_client.create_deal.call_args.kwargs
assert name_arg == "Big One"
assert fields == {"owner_id": "o1", "amount": 5000, "opportunity_stage_id": "st1"}

@pytest.mark.asyncio
async def test_create_resolves_stage_name(self, sample_deal: dict, capsys) -> None:
"""deals create --stage-name resolves to opportunity_stage_id."""
mock_client = MagicMock()
mock_client.list_all_stages = AsyncMock(
return_value=MockSearchResult(items=[{"id": "st-neg", "name": "Negotiation"}], total=1, page=1)
)
mock_client.create_deal = AsyncMock(return_value=sample_deal)

_ctx.ctx.configure(json_mode=True, api_key="test-key", limit=25, page=1)

with patch.object(_ctx.ctx, "client", return_value=MockAsyncContextManager(mock_client)):
from apollo_cli.commands.deals import create

await create(name="Deal", stage_name="negotiation")

assert mock_client.create_deal.call_args.kwargs["opportunity_stage_id"] == "st-neg"

@pytest.mark.asyncio
async def test_create_rejects_both_stage_id_and_name(self, capsys) -> None:
"""Passing both --stage-id and --stage-name is a validation error; no API call is made."""
mock_client = MagicMock()
mock_client.create_deal = AsyncMock()

_ctx.ctx.configure(json_mode=True, api_key="test-key", limit=25, page=1)

with patch.object(_ctx.ctx, "client", return_value=MockAsyncContextManager(mock_client)):
from apollo_cli.commands.deals import create

with pytest.raises(SystemExit):
await create(name="Deal", stage_id="st1", stage_name="Negotiation")

mock_client.create_deal.assert_not_called()


class TestCustomFieldsCommand:
@pytest.mark.asyncio
async def test_custom_fields_list_filters_by_modality(self, capsys) -> None:
Expand Down
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading