From 5141048d4c38ca29248ce55d1ca43b2a11dedd38 Mon Sep 17 00:00:00 2001 From: akhil-vamshi-konam Date: Mon, 21 Sep 2026 11:49:50 +0530 Subject: [PATCH 1/3] feat: introduce InitiativeWorkItems API client --- plane/api/initiatives/base.py | 2 + plane/api/initiatives/epics.py | 9 +++- plane/api/initiatives/work_items.py | 76 +++++++++++++++++++++++++++++ pyproject.toml | 2 +- tests/unit/test_initiatives.py | 73 +++++++++++++++++++++++++++ 5 files changed, 160 insertions(+), 2 deletions(-) create mode 100644 plane/api/initiatives/work_items.py diff --git a/plane/api/initiatives/base.py b/plane/api/initiatives/base.py index 91e5d4a..8e97031 100644 --- a/plane/api/initiatives/base.py +++ b/plane/api/initiatives/base.py @@ -11,6 +11,7 @@ from .epics import InitiativeEpics from .labels import InitiativeLabels from .projects import InitiativeProjects +from .work_items import InitiativeWorkItems class Initiatives(BaseResource): @@ -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: diff --git a/plane/api/initiatives/epics.py b/plane/api/initiatives/epics.py index 70ebfc0..ab43123 100644 --- a/plane/api/initiatives/epics.py +++ b/plane/api/initiatives/epics.py @@ -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/") diff --git a/plane/api/initiatives/work_items.py b/plane/api/initiatives/work_items.py new file mode 100644 index 0000000..d7c7d4e --- /dev/null +++ b/plane/api/initiatives/work_items.py @@ -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}, + ) diff --git a/pyproject.toml b/pyproject.toml index efed6f5..931de2e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/tests/unit/test_initiatives.py b/tests/unit/test_initiatives.py index 210ce7f..a20fdd4 100644 --- a/tests/unit/test_initiatives.py +++ b/tests/unit/test_initiatives.py @@ -12,6 +12,7 @@ UpdateInitiativeLabel, ) from plane.models.projects import Project +from plane.models.work_items import CreateWorkItem class TestInitiativesAPI: @@ -298,3 +299,75 @@ def test_list_epics(self, client: PlaneClient, workspace_slug: str, initiative) assert hasattr(response, "results") assert hasattr(response, "count") assert isinstance(response.results, list) + + +class TestInitiativeWorkItemsAPI: + """Test Initiative Work Items API operations.""" + + @pytest.fixture + def initiative( + self, + client: PlaneClient, + workspace_slug: str, + ): + """Create a test initiative and yield it, then delete it.""" + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") + initiative_data = CreateInitiative( + name=f"Test Initiative Work Items {timestamp}", + description="Test initiative for work item operations", + ) + initiative = client.initiatives.create(workspace_slug, initiative_data) + yield initiative + try: + client.initiatives.delete(workspace_slug, initiative.id) + except Exception: + pass + + @pytest.fixture + def work_item(self, client: PlaneClient, workspace_slug: str, project: Project): + """Create a test work item and yield it, then delete it.""" + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S_%f") + work_item = client.work_items.create( + workspace_slug, + project.id, + CreateWorkItem(name=f"Test Initiative Work Item {timestamp}"), + ) + yield work_item + try: + client.work_items.delete(workspace_slug, project.id, work_item.id) + except Exception: + pass + + def test_list_work_items(self, client: PlaneClient, workspace_slug: str, initiative) -> None: + """Test listing work items in an initiative.""" + response = client.initiatives.work_items.list(workspace_slug, initiative.id) + assert response is not None + assert hasattr(response, "results") + assert hasattr(response, "count") + assert isinstance(response.results, list) + + def test_list_work_items_with_params( + self, client: PlaneClient, workspace_slug: str, initiative + ) -> None: + """Test listing work items in an initiative with query parameters.""" + response = client.initiatives.work_items.list( + workspace_slug, initiative.id, params={"per_page": 5} + ) + assert response is not None + assert len(response.results) <= 5 + + def test_add_and_remove_work_items( + self, client: PlaneClient, workspace_slug: str, initiative, work_item + ) -> None: + """Test adding and removing work items from an initiative.""" + added = client.initiatives.work_items.add(workspace_slug, initiative.id, [work_item.id]) + assert isinstance(added, list) + assert work_item.id in [item.id for item in added] + + listed = client.initiatives.work_items.list(workspace_slug, initiative.id) + assert work_item.id in [item.id for item in listed.results] + + client.initiatives.work_items.remove(workspace_slug, initiative.id, [work_item.id]) + + remaining = client.initiatives.work_items.list(workspace_slug, initiative.id) + assert work_item.id not in [item.id for item in remaining.results] From 7cda0800cfb46f26b83b68c77ca380968ae43d7c Mon Sep 17 00:00:00 2001 From: akhil-vamshi-konam Date: Mon, 21 Sep 2026 13:05:31 +0530 Subject: [PATCH 2/3] fix: update WorkItems API to use exclude_unset for model serialization --- plane/api/work_items/base.py | 2 +- tests/unit/test_work_items.py | 56 +++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/plane/api/work_items/base.py b/plane/api/work_items/base.py index 6b91017..0b7c730 100644 --- a/plane/api/work_items/base.py +++ b/plane/api/work_items/base.py @@ -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), ) return WorkItem.model_validate(response) diff --git a/tests/unit/test_work_items.py b/tests/unit/test_work_items.py index 53a2328..2bbc9c2 100644 --- a/tests/unit/test_work_items.py +++ b/tests/unit/test_work_items.py @@ -268,6 +268,24 @@ def test_update_work_item( assert updated is not None assert updated.id == work_item.id + def test_update_clears_a_date_set_to_none( + self, client: PlaneClient, workspace_slug: str, project: Project, work_item + ) -> None: + """An explicit None clears the date; a date left out keeps its value.""" + client.work_items.update( + workspace_slug, + project.id, + work_item.id, + UpdateWorkItem(start_date="2026-01-01", target_date="2026-01-31"), + ) + + cleared = client.work_items.update( + workspace_slug, project.id, work_item.id, UpdateWorkItem(target_date=None) + ) + + assert cleared.target_date is None + assert cleared.start_date == "2026-01-01" + class TestWorkItemsSubResources: """Test WorkItems sub-resources (comments, links, relations, etc.).""" @@ -434,3 +452,41 @@ def test_archive_and_unarchive_work_item( client.work_items.delete(workspace_slug, project.id, wi.id) except Exception: pass + + +class TestWorkItemUpdatePayload: + """What `update` puts on the wire. Offline, so it runs without a Plane instance. + + Plane patches partially, so the payload is the whole contract: a field that is + present is written, and a field that is absent is left alone. `exclude_none` + used to erase an explicit None, which made a nullable date impossible to clear. + """ + + @pytest.fixture + def sent(self, monkeypatch: pytest.MonkeyPatch): + client = PlaneClient(api_key="k", base_url="http://plane.invalid") + payloads: list[dict] = [] + + def patch(endpoint: str, data: dict | None = None) -> dict: + payloads.append(data or {}) + return {"id": "w"} + + monkeypatch.setattr(client.work_items, "_patch", patch) + + def send(data: UpdateWorkItem) -> dict: + client.work_items.update("ws", "p", "w", data) + return payloads[-1] + + return send + + def test_a_field_set_to_none_is_sent_as_null(self, sent) -> None: + assert sent(UpdateWorkItem(target_date=None)) == {"target_date": None} + + def test_a_field_left_out_is_not_sent(self, sent) -> None: + assert sent(UpdateWorkItem(name="Renamed")) == {"name": "Renamed"} + + def test_clearing_one_date_leaves_the_other_alone(self, sent) -> None: + assert sent(UpdateWorkItem(start_date="2026-01-01", target_date=None)) == { + "start_date": "2026-01-01", + "target_date": None, + } From e613bebab9da26ed08acbbcf34ded9f3792aac90 Mon Sep 17 00:00:00 2001 From: akhil-vamshi-konam Date: Mon, 21 Sep 2026 13:28:09 +0530 Subject: [PATCH 3/3] refactor: update customer property models to use new enums for property and relation types --- plane/models/customers.py | 41 +++++++------- plane/models/enums.py | 34 ++++++++++-- plane/models/work_item_properties.py | 30 +++++++++- tests/unit/test_customers.py | 38 ++++++++++++- tests/unit/test_work_item_properties.py | 74 +++++++++++++++++++++++++ 5 files changed, 191 insertions(+), 26 deletions(-) diff --git a/plane/models/customers.py b/plane/models/customers.py index 924a256..f31455f 100644 --- a/plane/models/customers.py +++ b/plane/models/customers.py @@ -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, @@ -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 @@ -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 @@ -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 @@ -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") @@ -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" @@ -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" @@ -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") @@ -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 @@ -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") @@ -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 " @@ -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 " @@ -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" diff --git a/plane/models/enums.py b/plane/models/enums.py index e2ddba8..a1bf59a 100644 --- a/plane/models/enums.py +++ b/plane/models/enums.py @@ -55,8 +55,9 @@ "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. @@ -64,8 +65,9 @@ # 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" @@ -77,10 +79,11 @@ 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" @@ -88,6 +91,27 @@ class RelationType(Enum): 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.""" diff --git a/plane/models/work_item_properties.py b/plane/models/work_item_properties.py index 92828f9..a014789 100644 --- a/plane/models/work_item_properties.py +++ b/plane/models/work_item_properties.py @@ -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. '

Notes

'") + + +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. @@ -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 @@ -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") @@ -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" ) diff --git a/tests/unit/test_customers.py b/tests/unit/test_customers.py index 8b0e354..231d591 100644 --- a/tests/unit/test_customers.py +++ b/tests/unit/test_customers.py @@ -22,7 +22,7 @@ UpdateCustomerPropertyOption, UpdateCustomerRequest, ) -from plane.models.enums import PropertyType +from plane.models.enums import CustomerPropertyType, PropertyType, RelationType from plane.models.projects import Project from plane.models.work_item_property_configurations import TextAttributeSettings from plane.models.work_items import CreateWorkItem @@ -660,3 +660,39 @@ def test_create_request_with_work_item_ids_links_them( client.customers.requests.delete(workspace_slug, customer.id, req.id) except Exception: pass + + +class TestCustomerPropertyTypesOffline: + """Customer properties take Plane's customer set, not the work item one.""" + + BASE = {"name": "tier", "display_name": "Tier"} + + @pytest.mark.parametrize("property_type", ["FORMULA", "CASCADING"]) + def test_a_work_item_only_type_is_refused(self, property_type: str) -> None: + with pytest.raises(ValueError, match="property_type"): + CreateCustomerProperty(**self.BASE, property_type=property_type) + + @pytest.mark.parametrize("relation_type", ["RELEASE", "RICH_TEXT"]) + def test_a_work_item_only_relation_is_refused(self, relation_type: str) -> None: + with pytest.raises(ValueError, match="relation_type"): + CreateCustomerProperty( + **self.BASE, property_type="RELATION", relation_type=relation_type + ) + + def test_the_shared_enums_still_build_a_customer_property(self) -> None: + """Code written against PropertyType and RelationType keeps working, and a + property built that way still compares equal to the shared member.""" + prop = CreateCustomerProperty( + **self.BASE, property_type=PropertyType.RELATION, relation_type=RelationType.USER + ) + + assert prop.property_type == PropertyType.RELATION + assert isinstance(prop.property_type, CustomerPropertyType) + assert prop.model_dump(exclude_none=True, include={"property_type", "relation_type"}) == { + "property_type": "RELATION", + "relation_type": "USER", + } + + def test_a_shared_member_the_customer_set_lacks_is_still_refused(self) -> None: + with pytest.raises(ValueError, match="property_type"): + CreateCustomerProperty(**self.BASE, property_type=PropertyType.FORMULA) diff --git a/tests/unit/test_work_item_properties.py b/tests/unit/test_work_item_properties.py index ee6586a..e107e9e 100644 --- a/tests/unit/test_work_item_properties.py +++ b/tests/unit/test_work_item_properties.py @@ -8,7 +8,11 @@ from plane.models.work_item_properties import ( CreateWorkItemProperty, CreateWorkItemPropertyOption, + CreateWorkItemPropertyValue, + RichTextValue, UpdateWorkItemProperty, + WorkItemProperty, + WorkItemPropertyValueDetail, ) from plane.models.work_item_property_configurations import ( DateAttributeSettings, @@ -776,3 +780,73 @@ def test_attach_detach_property_to_type( client.work_item_types.delete(workspace_slug, project.id, wit.id) except Exception: pass + + +class TestWorkItemPropertyTypesOffline: + """What the models accept and send. Offline, so it runs without a Plane instance.""" + + def test_a_cascading_property_parses(self) -> None: + """Plane returns CASCADING properties; the enum lacked the value, so listing a + workspace that had one raised ValidationError.""" + prop = WorkItemProperty.model_validate( + {"id": "p", "property_type": "CASCADING", "display_name": "Region"} + ) + + assert prop.property_type == PropertyType.CASCADING + + def test_rich_text_is_a_relation_type_not_a_property_type(self) -> None: + assert "RICH_TEXT" in {member.value for member in RelationType} + assert "RICH_TEXT" not in {member.value for member in PropertyType} + + +class TestRichTextValueOffline: + """A rich text value is an object with description_html -- Plane answers a bare + string with 400 "Rich text value must be an object".""" + + @pytest.fixture + def sent(self, monkeypatch: pytest.MonkeyPatch): + client = PlaneClient(api_key="k", base_url="http://plane.invalid") + payloads: list[dict] = [] + + def post(endpoint: str, data: dict | None = None, params: dict | None = None) -> dict: + payloads.append(data or {}) + return {"id": "v", "property_id": "p", "issue_id": "w", "value": "desc-1"} + + monkeypatch.setattr(client.work_item_properties.values, "_post", post) + + def send(value) -> dict: + client.work_item_properties.values.create( + "ws", "p", "w", "prop", CreateWorkItemPropertyValue(value=value) + ) + return payloads[-1] + + return send + + def test_a_rich_text_value_is_sent_as_an_object(self, sent) -> None: + assert sent(RichTextValue(description_html="

Notes

")) == { + "value": {"description_html": "

Notes

"} + } + + def test_the_other_value_types_are_unchanged(self, sent) -> None: + assert sent("plain") == {"value": "plain"} + assert sent(True) == {"value": True} + assert sent(["a", "b"]) == {"value": ["a", "b"]} + + def test_a_rich_text_read_carries_its_html_in_value_detail(self) -> None: + """`value` is the stored content's ID; the HTML is beside it.""" + detail = WorkItemPropertyValueDetail.model_validate( + { + "id": "v", + "property_id": "p", + "issue_id": "w", + "value": "desc-1", + "value_detail": { + "id": "desc-1", + "description_html": "

Notes

", + "description_stripped": "Notes", + }, + } + ) + + assert detail.value == "desc-1" + assert detail.value_detail.description_html == "

Notes

"