Skip to content
Open
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
24 changes: 20 additions & 4 deletions plane/api/base_resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,11 +37,18 @@ def _get(self, endpoint: str, params: Mapping[str, Any] | None = None) -> Any:
return self._handle_response(response)

def _post(
self, endpoint: str, data: Mapping[str, Any] | list[Any] | None = None
self,
endpoint: str,
data: Mapping[str, Any] | list[Any] | None = None,
params: Mapping[str, Any] | None = None,
) -> Any:
url = self._build_url(endpoint)
response = self.session.post(
url, headers=self._headers(), json=data, timeout=self.config.timeout
url,
headers=self._headers(),
json=data,
params=params,
timeout=self.config.timeout,
)
return self._handle_response(response)

Expand All @@ -59,10 +66,19 @@ def _patch(self, endpoint: str, data: Mapping[str, Any] | None = None) -> Any:
)
return self._handle_response(response)

def _delete(self, endpoint: str, data: Mapping[str, Any] | None = None) -> None:
def _delete(
self,
endpoint: str,
data: Mapping[str, Any] | None = None,
params: Mapping[str, Any] | None = None,
) -> None:
url = self._build_url(endpoint)
response = self.session.delete(
url, headers=self._headers(), json=data, timeout=self.config.timeout
url,
headers=self._headers(),
json=data,
params=params,
timeout=self.config.timeout,
)
self._handle_response(response)

Expand Down
36 changes: 31 additions & 5 deletions plane/api/customers/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@

from plane.api.base_resource import BaseResource
from plane.api.customers.properties import CustomerProperties
from plane.api.customers.property_values import CustomerPropertyValues
from plane.api.customers.requests import CustomerRequests
from plane.api.customers.work_items import CustomerWorkItems
from plane.models.customers import (
CreateCustomer,
Customer,
Expand All @@ -18,7 +20,9 @@ def __init__(self, config: Any) -> None:

# Initialize sub-resources
self.properties = CustomerProperties(config)
self.property_values = CustomerPropertyValues(config)
self.requests = CustomerRequests(config)
self.work_items = CustomerWorkItems(config)

def list(
self, workspace_slug: str, params: Mapping[str, Any] | None = None
Expand All @@ -27,7 +31,7 @@ def list(

Args:
workspace_slug: The workspace slug identifier
params: Optional query parameters
params: Optional query parameters, e.g. `query` to search by name
"""
response = self._get(f"{workspace_slug}/customers", params=params)
return PaginatedCustomerResponse.model_validate(response)
Expand All @@ -46,7 +50,7 @@ def retrieve(
return Customer.model_validate(response)

def create(self, workspace_slug: str, data: CreateCustomer) -> Customer:
"""Create a new customer.
"""Create a customer, or update the one it matches.

Args:
workspace_slug: The workspace slug identifier
Expand All @@ -68,11 +72,33 @@ def update(self, workspace_slug: str, customer_id: str, data: UpdateCustomer) ->
)
return Customer.model_validate(response)

def delete(self, workspace_slug: str, customer_id: str) -> None:
"""Delete a customer by ID.
def delete(
self,
workspace_slug: str,
customer_id: str | None = None,
external_source: str | None = None,
external_id: str | None = None,
) -> None:
"""Delete a customer, addressed by ID or by its external reference.

Pass `customer_id`, or both `external_source` and `external_id` for a
customer synced from an external system. Deleting by an external reference
that matches nothing succeeds silently.

Args:
workspace_slug: The workspace slug identifier
customer_id: UUID of the customer
external_source: External system the customer came from
external_id: The customer's identifier in that system

Raises:
ValueError: If neither a customer_id nor a full external reference is given
"""
return self._delete(f"{workspace_slug}/customers/{customer_id}")
if customer_id:
return self._delete(f"{workspace_slug}/customers/{customer_id}")
if external_source and external_id:
return self._delete(
f"{workspace_slug}/customers",
params={"external_source": external_source, "external_id": external_id},
)
raise ValueError("Provide customer_id, or both external_source and external_id.")
112 changes: 112 additions & 0 deletions plane/api/customers/property_values.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
from __future__ import annotations

from typing import Any

from plane.api.base_resource import BaseResource
from plane.models.customers import (
CustomerPropertyValues as CustomerPropertyValuesResponse,
)
from plane.models.customers import (
SetCustomerPropertyValue,
SetCustomerPropertyValues,
)


class CustomerPropertyValues(BaseResource):
"""API resource for the values a customer holds for its properties.

Values are addressed as a mapping of property id to a list of strings, for
every property type — dates as ``YYYY-MM-DD``, booleans as ``"True"`` /
``"False"``, options and relations as UUIDs. Properties that are not
``is_active`` are left out of reads.

Writes replace the values of the properties they name, rather than merging
into them. Properties absent from the payload keep their current values.
"""

def __init__(self, config: Any) -> None:
super().__init__(config, "/workspaces/")

def list(self, workspace_slug: str, customer_id: str) -> dict[str, list[str]]:
"""Retrieve every property value set on a customer.

Properties that have no value set are absent from the mapping.

Args:
workspace_slug: The workspace slug identifier
customer_id: UUID of the customer

Returns:
Property id mapped to that property's values
"""
response = self._get(f"{workspace_slug}/customers/{customer_id}/property-values")
return CustomerPropertyValuesResponse.model_validate(response or {}).root

def create(
self,
workspace_slug: str,
customer_id: str,
data: SetCustomerPropertyValues,
) -> None:
"""Set several of a customer's property values at once.

Acts as an upsert: the properties named are given these values, replacing
any they already held. Properties absent from the payload are untouched.

Args:
workspace_slug: The workspace slug identifier
customer_id: UUID of the customer
data: Property ids mapped to their new values

Raises:
HttpError: 400 if a value fails its property's validation
"""
self._post(
f"{workspace_slug}/customers/{customer_id}/property-values",
data.model_dump(exclude_none=True),
)

def retrieve(self, workspace_slug: str, customer_id: str, property_id: str) -> list[str]:
"""Retrieve the values a customer holds for one property.

Args:
workspace_slug: The workspace slug identifier
customer_id: UUID of the customer
property_id: UUID of the customer property

Returns:
The property's values, empty when none are set

Raises:
HttpError: 404 if the customer or property does not exist
"""
response = self._get(
f"{workspace_slug}/customers/{customer_id}/property-values/{property_id}"
)
values = CustomerPropertyValuesResponse.model_validate(response or {}).root
return values.get(str(property_id), [])

def update(
self,
workspace_slug: str,
customer_id: str,
property_id: str,
data: SetCustomerPropertyValue,
) -> None:
"""Replace the values a customer holds for one property.

Args:
workspace_slug: The workspace slug identifier
customer_id: UUID of the customer
property_id: UUID of the customer property
data: The property's new values

Raises:
HttpError: 400 if the property is required and the values are empty,
or if a value fails the property's validation
HttpError: 404 if the customer or property does not exist
"""
self._patch(
f"{workspace_slug}/customers/{customer_id}/property-values/{property_id}",
data.model_dump(exclude_none=True),
)
29 changes: 21 additions & 8 deletions plane/api/customers/requests.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@

from plane.api.base_resource import BaseResource
from plane.models.customers import (
CreateCustomerRequest,
CustomerRequest,
PaginatedCustomerRequestResponse,
UpdateCustomerRequest,
)

Expand All @@ -17,18 +19,20 @@ def list(
workspace_slug: str,
customer_id: str,
params: Mapping[str, Any] | None = None,
) -> list[CustomerRequest]:
"""List customer requests.
) -> PaginatedCustomerRequestResponse:
"""List customer requests (paginated).

Returns one page (1000 by default). Pass `per_page`/`cursor` in params and
follow `next_cursor` to page through the rest.

Args:
workspace_slug: The workspace slug identifier
customer_id: UUID of the customer
params: Optional query parameters
params: Optional query parameters, e.g. `query` to search by name,
`per_page` to size the page, `cursor` to walk pages
"""
response = self._get(f"{workspace_slug}/customers/{customer_id}/requests", params=params)
if isinstance(response, list):
return [CustomerRequest.model_validate(item) for item in response]
return []
return PaginatedCustomerRequestResponse.model_validate(response)

def retrieve(self, workspace_slug: str, customer_id: str, request_id: str) -> CustomerRequest:
"""Retrieve a customer request by ID.
Expand All @@ -42,14 +46,23 @@ def retrieve(self, workspace_slug: str, customer_id: str, request_id: str) -> Cu
return CustomerRequest.model_validate(response)

def create(
self, workspace_slug: str, customer_id: str, data: CustomerRequest
self,
workspace_slug: str,
customer_id: str,
data: CreateCustomerRequest | CustomerRequest,
) -> CustomerRequest:
"""Create a new customer request.

`data.work_item_ids` links work items to the customer as the request is
created. The returned request does not echo them back; read them with
`client.customers.work_items.list()`.

Args:
workspace_slug: The workspace slug identifier
customer_id: UUID of the customer
data: Customer request data
data: Customer request data. Prefer `CreateCustomerRequest`;
`CustomerRequest` is still accepted for callers written against
earlier versions.
"""
response = self._post(
f"{workspace_slug}/customers/{customer_id}/requests",
Expand Down
102 changes: 102 additions & 0 deletions plane/api/customers/work_items.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
from __future__ import annotations

from collections.abc import Mapping
from typing import Any

from plane.api.base_resource import BaseResource
from plane.models.customers import (
CustomerWorkItem,
LinkCustomerWorkItems,
LinkCustomerWorkItemsResponse,
)


class CustomerWorkItems(BaseResource):
"""API resource for the work items linked to a customer.

Links may hang off the customer alone, or off one of its requests when a
`customer_request_id` is supplied.
"""

def __init__(self, config: Any) -> None:
super().__init__(config, "/workspaces/")

def list(
self,
workspace_slug: str,
customer_id: str,
customer_request_id: str | None = None,
search: str | None = None,
params: Mapping[str, Any] | None = None,
) -> list[CustomerWorkItem]:
"""List the work items linked to a customer.

This endpoint is not paginated and returns every linked work item.

Args:
workspace_slug: The workspace slug identifier
customer_id: UUID of the customer
customer_request_id: Only return work items linked via this request
search: Match on work item name, sequence ID or project identifier
params: Additional query parameters
"""
query: dict[str, Any] = dict(params or {})
if customer_request_id:
query["customer_request_id"] = customer_request_id
if search:
query["search"] = search

response = self._get(
f"{workspace_slug}/customers/{customer_id}/issues", params=query or None
)
return [CustomerWorkItem.model_validate(item) for item in response]

def create(
self,
workspace_slug: str,
customer_id: str,
data: LinkCustomerWorkItems,
customer_request_id: str | None = None,
) -> LinkCustomerWorkItemsResponse:
"""Link one or more work items to a customer.

The API rejects the whole call if any work item in `data.work_item_ids`
does not exist. Re-linking an already linked work item is a no-op.

Args:
workspace_slug: The workspace slug identifier
customer_id: UUID of the customer
data: The work items to link
customer_request_id: Attach the links to this request of the customer
"""
query = {"customer_request_id": customer_request_id} if customer_request_id else None
response = self._post(
f"{workspace_slug}/customers/{customer_id}/issues",
data.model_dump(exclude_none=True, by_alias=True),
params=query,
)
return LinkCustomerWorkItemsResponse.model_validate(response)

def delete(
self,
workspace_slug: str,
customer_id: str,
work_item_id: str,
customer_request_id: str | None = None,
) -> None:
"""Unlink a work item from a customer.

Args:
workspace_slug: The workspace slug identifier
customer_id: UUID of the customer
work_item_id: UUID of the work item to unlink
customer_request_id: Only drop the link made via this request;
without it every link between the customer and the work item goes

Raises:
HttpError: 404 if no such link exists
"""
query = {"customer_request_id": customer_request_id} if customer_request_id else None
return self._delete(
f"{workspace_slug}/customers/{customer_id}/issues/{work_item_id}", params=query
)
Loading