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: 2 additions & 0 deletions plane/api/initiatives/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from .epics import InitiativeEpics
from .labels import InitiativeLabels
from .projects import InitiativeProjects
from .work_items import InitiativeWorkItems


class Initiatives(BaseResource):
Expand All @@ -22,6 +23,7 @@ def __init__(self, config: Any) -> None:
# Initialize sub-resources
self.labels = InitiativeLabels(config)
self.projects = InitiativeProjects(config)
self.work_items = InitiativeWorkItems(config)
self.epics = InitiativeEpics(config)

def create(self, workspace_slug: str, data: CreateInitiative) -> Initiative:
Expand Down
9 changes: 8 additions & 1 deletion plane/api/initiatives/epics.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,14 @@


class InitiativeEpics(BaseResource):
"""API client for managing epics associated with initiatives."""
"""Deprecated. Use :class:`~plane.api.initiatives.work_items.InitiativeWorkItems`.

The ``/epics/`` endpoints are preserved for backward compatibility. Server-side
they share one implementation and one association model with ``/work-items/``,
so any work-item type is accepted and returned here despite the name; the
request field is spelled ``epic_ids`` only because it predates the unified
model. New code should call ``client.initiatives.work_items``.
"""

def __init__(self, config: Any) -> None:
super().__init__(config, "/workspaces/")
Expand Down
76 changes: 76 additions & 0 deletions plane/api/initiatives/work_items.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
from collections.abc import Iterable, Mapping
from typing import Any

from ...models.work_items import PaginatedWorkItemResponse, WorkItem
from ..base_resource import BaseResource


class InitiativeWorkItems(BaseResource):
"""API client for managing work items associated with initiatives.

This is the successor to :class:`~plane.api.initiatives.epics.InitiativeEpics`.
The two surfaces share one implementation server-side and one association
model, so they behave identically; they differ only in the URL and in the
request field name (``work_item_ids`` here, ``epic_ids`` there). Any
work-item type is accepted -- the ``/epics/`` spelling reflects the old
Epic-only model and is deprecated.
"""

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

def list(
self, workspace_slug: str, initiative_id: str, params: Mapping[str, Any] | None = None
) -> PaginatedWorkItemResponse:
"""List the work items associated with an initiative (paginated).

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

Args:
workspace_slug: The workspace slug identifier
initiative_id: UUID of the initiative
params: Optional query parameters, e.g. `per_page`, `cursor`

Returns:
Paginated list of work items
"""
response = self._get(
f"{workspace_slug}/initiatives/{initiative_id}/work-items", params=params
)
return PaginatedWorkItemResponse.model_validate(response)

def add(
self, workspace_slug: str, initiative_id: str, work_item_ids: Iterable[str]
) -> Iterable[WorkItem]:
"""Associate work items with an initiative.

Work items already associated are skipped. The response covers every id
requested, not only the newly added ones.

Args:
workspace_slug: The workspace slug identifier
initiative_id: UUID of the initiative
work_item_ids: List of work item UUIDs to associate

Returns:
List of the work items named in the request
"""
response = self._post(
f"{workspace_slug}/initiatives/{initiative_id}/work-items",
{"work_item_ids": work_item_ids},
)
return [WorkItem.model_validate(work_item) for work_item in response]

def remove(self, workspace_slug: str, initiative_id: str, work_item_ids: Iterable[str]) -> None:
"""Remove work items from an initiative.

Args:
workspace_slug: The workspace slug identifier
initiative_id: UUID of the initiative
work_item_ids: List of work item UUIDs to remove
"""
return self._delete(
f"{workspace_slug}/initiatives/{initiative_id}/work-items",
{"work_item_ids": work_item_ids},
Comment thread
akhil-vamshi-konam marked this conversation as resolved.
)
2 changes: 1 addition & 1 deletion plane/api/work_items/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -181,7 +181,7 @@ def update(
"""
response = self._patch(
f"{workspace_slug}/projects/{project_id}/work-items/{work_item_id}",
data.model_dump(exclude_none=True),
data.model_dump(exclude_unset=True),
Comment thread
akhil-vamshi-konam marked this conversation as resolved.
)
return WorkItem.model_validate(response)

Expand Down
41 changes: 22 additions & 19 deletions plane/models/customers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,10 @@

from pydantic import BaseModel, ConfigDict, Field, RootModel, field_serializer, model_validator

from .enums import PropertyType, RelationType
from .enums import CustomerPropertyType, CustomerRelationType

from .enums import PropertyType as PropertyType
from .enums import RelationType as RelationType
from .pagination import PaginatedResponse
from .work_item_property_configurations import (
DateAttributeSettings,
Expand Down Expand Up @@ -161,8 +164,8 @@ class CustomerProperty(BaseModel):
description: str | None = None
logo_props: Any | None = None
sort_order: float | None = None
property_type: PropertyType
relation_type: RelationType | None = None
property_type: CustomerPropertyType
relation_type: CustomerRelationType | None = None
is_required: bool | None = None
default_value: list[str] | None = None
settings: PropertySettings | dict = None
Expand All @@ -177,11 +180,11 @@ class CustomerProperty(BaseModel):
options: list[CustomerPropertyOption] | None = None

@field_serializer("property_type")
def serialize_property_type(self, value: PropertyType) -> str | None:
def serialize_property_type(self, value: CustomerPropertyType) -> str | None:
return value.value if value else None

@field_serializer("relation_type")
def serialize_relation_type(self, value: RelationType) -> str | None:
def serialize_relation_type(self, value: CustomerRelationType) -> str | None:
return value.value if value else None


Expand All @@ -195,8 +198,8 @@ class CreateCustomerProperty(BaseModel):
description: str | None = None
logo_props: Any | None = None
sort_order: float | None = None
property_type: PropertyType
relation_type: RelationType | None = None
property_type: CustomerPropertyType
relation_type: CustomerRelationType | None = None
is_required: bool | None = None
default_value: list[str] | None = None
settings: PropertySettings = None
Expand All @@ -208,11 +211,11 @@ class CreateCustomerProperty(BaseModel):
options: list[CreateCustomerPropertyOption] | None = None

@field_serializer("property_type")
def serialize_property_type(self, value: PropertyType) -> str | None:
def serialize_property_type(self, value: CustomerPropertyType) -> str | None:
return value.value if value else None

@field_serializer("relation_type")
def serialize_relation_type(self, value: RelationType) -> str | None:
def serialize_relation_type(self, value: CustomerRelationType) -> str | None:
return value.value if value else None

@model_validator(mode="after")
Expand All @@ -223,7 +226,7 @@ def validate_settings_and_relation_type(self) -> "CreateCustomerProperty":
relation_type = self.relation_type

# TEXT properties require TextAttributeSettings
if prop_type == PropertyType.TEXT:
if prop_type == CustomerPropertyType.TEXT:
if settings is None:
raise ValueError(
"settings with TextAttributeSettings is required for TEXT properties"
Expand All @@ -232,7 +235,7 @@ def validate_settings_and_relation_type(self) -> "CreateCustomerProperty":
raise ValueError("settings must be TextAttributeSettings for TEXT properties")

# DATETIME properties require DateAttributeSettings
if prop_type == PropertyType.DATETIME:
if prop_type == CustomerPropertyType.DATETIME:
if settings is None:
raise ValueError(
"settings with DateAttributeSettings is required for DATETIME properties"
Expand All @@ -241,7 +244,7 @@ def validate_settings_and_relation_type(self) -> "CreateCustomerProperty":
raise ValueError("settings must be DateAttributeSettings for DATETIME properties")

# RELATION properties require relation_type
if prop_type == PropertyType.RELATION:
if prop_type == CustomerPropertyType.RELATION:
if relation_type is None:
raise ValueError("relation_type is required for RELATION properties")

Expand All @@ -257,8 +260,8 @@ class UpdateCustomerProperty(BaseModel):
description: str | None = None
logo_props: Any | None = None
sort_order: float | None = None
property_type: PropertyType | None = None
relation_type: RelationType | None = None
property_type: CustomerPropertyType | None = None
relation_type: CustomerRelationType | None = None
is_required: bool | None = None
default_value: list[str] | None = None
settings: PropertySettings = None
Expand All @@ -272,11 +275,11 @@ class UpdateCustomerProperty(BaseModel):
options: list[UpdateCustomerPropertyOption] | None = None

@field_serializer("property_type")
def serialize_property_type(self, value: PropertyType) -> str | None:
def serialize_property_type(self, value: CustomerPropertyType) -> str | None:
return value.value if value else None

@field_serializer("relation_type")
def serialize_relation_type(self, value: RelationType) -> str | None:
def serialize_relation_type(self, value: CustomerRelationType) -> str | None:
return value.value if value else None

@model_validator(mode="after")
Expand All @@ -291,7 +294,7 @@ def validate_settings_and_relation_type(self) -> "UpdateCustomerProperty":
return self

# TEXT properties require TextAttributeSettings
if prop_type == PropertyType.TEXT:
if prop_type == CustomerPropertyType.TEXT:
if settings is None:
raise ValueError(
"settings with TextAttributeSettings is required when updating to "
Expand All @@ -301,7 +304,7 @@ def validate_settings_and_relation_type(self) -> "UpdateCustomerProperty":
raise ValueError("settings must be TextAttributeSettings for TEXT properties")

# DATETIME properties require DateAttributeSettings
if prop_type == PropertyType.DATETIME:
if prop_type == CustomerPropertyType.DATETIME:
if settings is None:
raise ValueError(
"settings with DateAttributeSettings is required when updating to "
Expand All @@ -311,7 +314,7 @@ def validate_settings_and_relation_type(self) -> "UpdateCustomerProperty":
raise ValueError("settings must be DateAttributeSettings for DATETIME properties")

# RELATION properties require relation_type
if prop_type == PropertyType.RELATION:
if prop_type == CustomerPropertyType.RELATION:
if relation_type is None:
raise ValueError(
"relation_type is required when updating to RELATION property_type"
Expand Down
34 changes: 29 additions & 5 deletions plane/models/enums.py
Original file line number Diff line number Diff line change
Expand Up @@ -55,17 +55,19 @@
"EMAIL",
"FILE",
"FORMULA",
"CASCADING",
]
RelationTypeEnum = Literal["ISSUE", "USER", "RELEASE"]
RelationTypeEnum = Literal["ISSUE", "USER", "RELEASE", "RICH_TEXT"]
CycleStatusEnum = Literal["current", "upcoming", "completed", "draft", "incomplete"]
# Deprecated alias for CycleStatusEnum. ``status`` is the canonical cycle filter
# going forward; ``cycle_view`` is kept only for backward compatibility.
CycleViewEnum = CycleStatusEnum


# Proper Enum classes for better type safety and IDE support
class PropertyType(Enum):
"""Property type enumeration."""

class PropertyType(str, Enum):
"""Work item property types."""

TEXT = "TEXT"
DATETIME = "DATETIME"
Expand All @@ -77,17 +79,39 @@ class PropertyType(Enum):
EMAIL = "EMAIL"
FILE = "FILE"
FORMULA = "FORMULA"
CASCADING = "CASCADING"


class RelationType(Enum):
"""Relation type enumeration."""
class RelationType(str, Enum):
"""Work item relation types."""

ISSUE = "ISSUE"
USER = "USER"
RELEASE = "RELEASE"
RICH_TEXT = "RICH_TEXT"


class CustomerPropertyType(str, Enum):
"""Customer property types -- the work item set without FORMULA and CASCADING."""

TEXT = "TEXT"
DATETIME = "DATETIME"
DECIMAL = "DECIMAL"
BOOLEAN = "BOOLEAN"
OPTION = "OPTION"
RELATION = "RELATION"
URL = "URL"
EMAIL = "EMAIL"
FILE = "FILE"


class CustomerRelationType(str, Enum):
"""Customer relation types -- a customer property can relate to a work item or a user."""

ISSUE = "ISSUE"
USER = "USER"


class Priority(Enum):
"""Priority enumeration."""

Expand Down
30 changes: 29 additions & 1 deletion plane/models/work_item_properties.py
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,28 @@ class WorkItemPropertyValue(BaseModel):
value_option: str | None = None


class RichTextValue(BaseModel):
"""The value of a rich text property: `property_type=RELATION`, `relation_type=RICH_TEXT`.

Plane requires an object here, not a bare HTML string, and sanitises the HTML
before storing it. Reads return the stored content in `value_detail`.
"""

model_config = ConfigDict(extra="ignore", populate_by_name=True)

description_html: str = Field(..., description="The content, as HTML, e.g. '<p>Notes</p>'")


class RichTextValueDetail(BaseModel):
"""The stored content of a rich text property value, as Plane returns it."""

model_config = ConfigDict(extra="allow", populate_by_name=True)

id: str | None = Field(None, description="ID of the stored description; None when never set")
description_html: str = Field(..., description="The content as sanitised HTML")
description_stripped: str = Field("", description="The content as plain text")


class CreateWorkItemPropertyValue(BaseModel):
"""Request model for creating/updating a work item property value.

Expand All @@ -267,6 +289,7 @@ class CreateWorkItemPropertyValue(BaseModel):
- BOOLEAN: boolean (true/false)
- OPTION/RELATION (single): string (UUID)
- OPTION/RELATION (multi, when is_multi=True): list of strings (UUIDs) or single string
- RELATION with relation_type=RICH_TEXT: RichTextValue

For multi-value properties (is_multi=True):
- Accept either a single UUID string or a list of UUID strings
Expand All @@ -279,7 +302,7 @@ class CreateWorkItemPropertyValue(BaseModel):

model_config = ConfigDict(extra="ignore", populate_by_name=True)

value: str | bool | int | float | list[str] = Field(
value: str | bool | int | float | list[str] | RichTextValue = Field(
..., description="The value to set for the property (type depends on property type)"
)
external_id: str | None = Field(None, description="Optional external identifier for syncing")
Expand All @@ -304,6 +327,11 @@ class WorkItemPropertyValueDetail(BaseModel):
..., description="The actual value, formatted according to property type"
)
value_type: str | None = Field(None, description="Type of the value")
value_detail: RichTextValueDetail | None = Field(
None,
description="The stored content of a rich text property. For rich text, `value` is "
"the ID of that stored content; the HTML is here.",
)
external_id: str | None = Field(
None, description="External identifier if synced with external system"
)
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"

[project]
name = "plane-sdk"
version = "0.3.0"
version = "0.3.1"
description = "Python SDK for Plane API"
readme = "README.md"
requires-python = ">=3.10"
Expand Down
Loading
Loading