Skip to content

Commit e613beb

Browse files
refactor: update customer property models to use new enums for property and relation types
1 parent 7cda080 commit e613beb

5 files changed

Lines changed: 191 additions & 26 deletions

File tree

‎plane/models/customers.py‎

Lines changed: 22 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,10 @@
22

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

5-
from .enums import PropertyType, RelationType
5+
from .enums import CustomerPropertyType, CustomerRelationType
6+
7+
from .enums import PropertyType as PropertyType
8+
from .enums import RelationType as RelationType
69
from .pagination import PaginatedResponse
710
from .work_item_property_configurations import (
811
DateAttributeSettings,
@@ -161,8 +164,8 @@ class CustomerProperty(BaseModel):
161164
description: str | None = None
162165
logo_props: Any | None = None
163166
sort_order: float | None = None
164-
property_type: PropertyType
165-
relation_type: RelationType | None = None
167+
property_type: CustomerPropertyType
168+
relation_type: CustomerRelationType | None = None
166169
is_required: bool | None = None
167170
default_value: list[str] | None = None
168171
settings: PropertySettings | dict = None
@@ -177,11 +180,11 @@ class CustomerProperty(BaseModel):
177180
options: list[CustomerPropertyOption] | None = None
178181

179182
@field_serializer("property_type")
180-
def serialize_property_type(self, value: PropertyType) -> str | None:
183+
def serialize_property_type(self, value: CustomerPropertyType) -> str | None:
181184
return value.value if value else None
182185

183186
@field_serializer("relation_type")
184-
def serialize_relation_type(self, value: RelationType) -> str | None:
187+
def serialize_relation_type(self, value: CustomerRelationType) -> str | None:
185188
return value.value if value else None
186189

187190

@@ -195,8 +198,8 @@ class CreateCustomerProperty(BaseModel):
195198
description: str | None = None
196199
logo_props: Any | None = None
197200
sort_order: float | None = None
198-
property_type: PropertyType
199-
relation_type: RelationType | None = None
201+
property_type: CustomerPropertyType
202+
relation_type: CustomerRelationType | None = None
200203
is_required: bool | None = None
201204
default_value: list[str] | None = None
202205
settings: PropertySettings = None
@@ -208,11 +211,11 @@ class CreateCustomerProperty(BaseModel):
208211
options: list[CreateCustomerPropertyOption] | None = None
209212

210213
@field_serializer("property_type")
211-
def serialize_property_type(self, value: PropertyType) -> str | None:
214+
def serialize_property_type(self, value: CustomerPropertyType) -> str | None:
212215
return value.value if value else None
213216

214217
@field_serializer("relation_type")
215-
def serialize_relation_type(self, value: RelationType) -> str | None:
218+
def serialize_relation_type(self, value: CustomerRelationType) -> str | None:
216219
return value.value if value else None
217220

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

225228
# TEXT properties require TextAttributeSettings
226-
if prop_type == PropertyType.TEXT:
229+
if prop_type == CustomerPropertyType.TEXT:
227230
if settings is None:
228231
raise ValueError(
229232
"settings with TextAttributeSettings is required for TEXT properties"
@@ -232,7 +235,7 @@ def validate_settings_and_relation_type(self) -> "CreateCustomerProperty":
232235
raise ValueError("settings must be TextAttributeSettings for TEXT properties")
233236

234237
# DATETIME properties require DateAttributeSettings
235-
if prop_type == PropertyType.DATETIME:
238+
if prop_type == CustomerPropertyType.DATETIME:
236239
if settings is None:
237240
raise ValueError(
238241
"settings with DateAttributeSettings is required for DATETIME properties"
@@ -241,7 +244,7 @@ def validate_settings_and_relation_type(self) -> "CreateCustomerProperty":
241244
raise ValueError("settings must be DateAttributeSettings for DATETIME properties")
242245

243246
# RELATION properties require relation_type
244-
if prop_type == PropertyType.RELATION:
247+
if prop_type == CustomerPropertyType.RELATION:
245248
if relation_type is None:
246249
raise ValueError("relation_type is required for RELATION properties")
247250

@@ -257,8 +260,8 @@ class UpdateCustomerProperty(BaseModel):
257260
description: str | None = None
258261
logo_props: Any | None = None
259262
sort_order: float | None = None
260-
property_type: PropertyType | None = None
261-
relation_type: RelationType | None = None
263+
property_type: CustomerPropertyType | None = None
264+
relation_type: CustomerRelationType | None = None
262265
is_required: bool | None = None
263266
default_value: list[str] | None = None
264267
settings: PropertySettings = None
@@ -272,11 +275,11 @@ class UpdateCustomerProperty(BaseModel):
272275
options: list[UpdateCustomerPropertyOption] | None = None
273276

274277
@field_serializer("property_type")
275-
def serialize_property_type(self, value: PropertyType) -> str | None:
278+
def serialize_property_type(self, value: CustomerPropertyType) -> str | None:
276279
return value.value if value else None
277280

278281
@field_serializer("relation_type")
279-
def serialize_relation_type(self, value: RelationType) -> str | None:
282+
def serialize_relation_type(self, value: CustomerRelationType) -> str | None:
280283
return value.value if value else None
281284

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

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

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

313316
# RELATION properties require relation_type
314-
if prop_type == PropertyType.RELATION:
317+
if prop_type == CustomerPropertyType.RELATION:
315318
if relation_type is None:
316319
raise ValueError(
317320
"relation_type is required when updating to RELATION property_type"

‎plane/models/enums.py‎

Lines changed: 29 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,17 +55,19 @@
5555
"EMAIL",
5656
"FILE",
5757
"FORMULA",
58+
"CASCADING",
5859
]
59-
RelationTypeEnum = Literal["ISSUE", "USER", "RELEASE"]
60+
RelationTypeEnum = Literal["ISSUE", "USER", "RELEASE", "RICH_TEXT"]
6061
CycleStatusEnum = Literal["current", "upcoming", "completed", "draft", "incomplete"]
6162
# Deprecated alias for CycleStatusEnum. ``status`` is the canonical cycle filter
6263
# going forward; ``cycle_view`` is kept only for backward compatibility.
6364
CycleViewEnum = CycleStatusEnum
6465

6566

6667
# Proper Enum classes for better type safety and IDE support
67-
class PropertyType(Enum):
68-
"""Property type enumeration."""
68+
69+
class PropertyType(str, Enum):
70+
"""Work item property types."""
6971

7072
TEXT = "TEXT"
7173
DATETIME = "DATETIME"
@@ -77,17 +79,39 @@ class PropertyType(Enum):
7779
EMAIL = "EMAIL"
7880
FILE = "FILE"
7981
FORMULA = "FORMULA"
82+
CASCADING = "CASCADING"
8083

8184

82-
class RelationType(Enum):
83-
"""Relation type enumeration."""
85+
class RelationType(str, Enum):
86+
"""Work item relation types."""
8487

8588
ISSUE = "ISSUE"
8689
USER = "USER"
8790
RELEASE = "RELEASE"
8891
RICH_TEXT = "RICH_TEXT"
8992

9093

94+
class CustomerPropertyType(str, Enum):
95+
"""Customer property types -- the work item set without FORMULA and CASCADING."""
96+
97+
TEXT = "TEXT"
98+
DATETIME = "DATETIME"
99+
DECIMAL = "DECIMAL"
100+
BOOLEAN = "BOOLEAN"
101+
OPTION = "OPTION"
102+
RELATION = "RELATION"
103+
URL = "URL"
104+
EMAIL = "EMAIL"
105+
FILE = "FILE"
106+
107+
108+
class CustomerRelationType(str, Enum):
109+
"""Customer relation types -- a customer property can relate to a work item or a user."""
110+
111+
ISSUE = "ISSUE"
112+
USER = "USER"
113+
114+
91115
class Priority(Enum):
92116
"""Priority enumeration."""
93117

‎plane/models/work_item_properties.py‎

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,28 @@ class WorkItemPropertyValue(BaseModel):
257257
value_option: str | None = None
258258

259259

260+
class RichTextValue(BaseModel):
261+
"""The value of a rich text property: `property_type=RELATION`, `relation_type=RICH_TEXT`.
262+
263+
Plane requires an object here, not a bare HTML string, and sanitises the HTML
264+
before storing it. Reads return the stored content in `value_detail`.
265+
"""
266+
267+
model_config = ConfigDict(extra="ignore", populate_by_name=True)
268+
269+
description_html: str = Field(..., description="The content, as HTML, e.g. '<p>Notes</p>'")
270+
271+
272+
class RichTextValueDetail(BaseModel):
273+
"""The stored content of a rich text property value, as Plane returns it."""
274+
275+
model_config = ConfigDict(extra="allow", populate_by_name=True)
276+
277+
id: str | None = Field(None, description="ID of the stored description; None when never set")
278+
description_html: str = Field(..., description="The content as sanitised HTML")
279+
description_stripped: str = Field("", description="The content as plain text")
280+
281+
260282
class CreateWorkItemPropertyValue(BaseModel):
261283
"""Request model for creating/updating a work item property value.
262284
@@ -267,6 +289,7 @@ class CreateWorkItemPropertyValue(BaseModel):
267289
- BOOLEAN: boolean (true/false)
268290
- OPTION/RELATION (single): string (UUID)
269291
- OPTION/RELATION (multi, when is_multi=True): list of strings (UUIDs) or single string
292+
- RELATION with relation_type=RICH_TEXT: RichTextValue
270293
271294
For multi-value properties (is_multi=True):
272295
- Accept either a single UUID string or a list of UUID strings
@@ -279,7 +302,7 @@ class CreateWorkItemPropertyValue(BaseModel):
279302

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

282-
value: str | bool | int | float | list[str] = Field(
305+
value: str | bool | int | float | list[str] | RichTextValue = Field(
283306
..., description="The value to set for the property (type depends on property type)"
284307
)
285308
external_id: str | None = Field(None, description="Optional external identifier for syncing")
@@ -304,6 +327,11 @@ class WorkItemPropertyValueDetail(BaseModel):
304327
..., description="The actual value, formatted according to property type"
305328
)
306329
value_type: str | None = Field(None, description="Type of the value")
330+
value_detail: RichTextValueDetail | None = Field(
331+
None,
332+
description="The stored content of a rich text property. For rich text, `value` is "
333+
"the ID of that stored content; the HTML is here.",
334+
)
307335
external_id: str | None = Field(
308336
None, description="External identifier if synced with external system"
309337
)

‎tests/unit/test_customers.py‎

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
UpdateCustomerPropertyOption,
2323
UpdateCustomerRequest,
2424
)
25-
from plane.models.enums import PropertyType
25+
from plane.models.enums import CustomerPropertyType, PropertyType, RelationType
2626
from plane.models.projects import Project
2727
from plane.models.work_item_property_configurations import TextAttributeSettings
2828
from plane.models.work_items import CreateWorkItem
@@ -660,3 +660,39 @@ def test_create_request_with_work_item_ids_links_them(
660660
client.customers.requests.delete(workspace_slug, customer.id, req.id)
661661
except Exception:
662662
pass
663+
664+
665+
class TestCustomerPropertyTypesOffline:
666+
"""Customer properties take Plane's customer set, not the work item one."""
667+
668+
BASE = {"name": "tier", "display_name": "Tier"}
669+
670+
@pytest.mark.parametrize("property_type", ["FORMULA", "CASCADING"])
671+
def test_a_work_item_only_type_is_refused(self, property_type: str) -> None:
672+
with pytest.raises(ValueError, match="property_type"):
673+
CreateCustomerProperty(**self.BASE, property_type=property_type)
674+
675+
@pytest.mark.parametrize("relation_type", ["RELEASE", "RICH_TEXT"])
676+
def test_a_work_item_only_relation_is_refused(self, relation_type: str) -> None:
677+
with pytest.raises(ValueError, match="relation_type"):
678+
CreateCustomerProperty(
679+
**self.BASE, property_type="RELATION", relation_type=relation_type
680+
)
681+
682+
def test_the_shared_enums_still_build_a_customer_property(self) -> None:
683+
"""Code written against PropertyType and RelationType keeps working, and a
684+
property built that way still compares equal to the shared member."""
685+
prop = CreateCustomerProperty(
686+
**self.BASE, property_type=PropertyType.RELATION, relation_type=RelationType.USER
687+
)
688+
689+
assert prop.property_type == PropertyType.RELATION
690+
assert isinstance(prop.property_type, CustomerPropertyType)
691+
assert prop.model_dump(exclude_none=True, include={"property_type", "relation_type"}) == {
692+
"property_type": "RELATION",
693+
"relation_type": "USER",
694+
}
695+
696+
def test_a_shared_member_the_customer_set_lacks_is_still_refused(self) -> None:
697+
with pytest.raises(ValueError, match="property_type"):
698+
CreateCustomerProperty(**self.BASE, property_type=PropertyType.FORMULA)

‎tests/unit/test_work_item_properties.py‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@
88
from plane.models.work_item_properties import (
99
CreateWorkItemProperty,
1010
CreateWorkItemPropertyOption,
11+
CreateWorkItemPropertyValue,
12+
RichTextValue,
1113
UpdateWorkItemProperty,
14+
WorkItemProperty,
15+
WorkItemPropertyValueDetail,
1216
)
1317
from plane.models.work_item_property_configurations import (
1418
DateAttributeSettings,
@@ -776,3 +780,73 @@ def test_attach_detach_property_to_type(
776780
client.work_item_types.delete(workspace_slug, project.id, wit.id)
777781
except Exception:
778782
pass
783+
784+
785+
class TestWorkItemPropertyTypesOffline:
786+
"""What the models accept and send. Offline, so it runs without a Plane instance."""
787+
788+
def test_a_cascading_property_parses(self) -> None:
789+
"""Plane returns CASCADING properties; the enum lacked the value, so listing a
790+
workspace that had one raised ValidationError."""
791+
prop = WorkItemProperty.model_validate(
792+
{"id": "p", "property_type": "CASCADING", "display_name": "Region"}
793+
)
794+
795+
assert prop.property_type == PropertyType.CASCADING
796+
797+
def test_rich_text_is_a_relation_type_not_a_property_type(self) -> None:
798+
assert "RICH_TEXT" in {member.value for member in RelationType}
799+
assert "RICH_TEXT" not in {member.value for member in PropertyType}
800+
801+
802+
class TestRichTextValueOffline:
803+
"""A rich text value is an object with description_html -- Plane answers a bare
804+
string with 400 "Rich text value must be an object"."""
805+
806+
@pytest.fixture
807+
def sent(self, monkeypatch: pytest.MonkeyPatch):
808+
client = PlaneClient(api_key="k", base_url="http://plane.invalid")
809+
payloads: list[dict] = []
810+
811+
def post(endpoint: str, data: dict | None = None, params: dict | None = None) -> dict:
812+
payloads.append(data or {})
813+
return {"id": "v", "property_id": "p", "issue_id": "w", "value": "desc-1"}
814+
815+
monkeypatch.setattr(client.work_item_properties.values, "_post", post)
816+
817+
def send(value) -> dict:
818+
client.work_item_properties.values.create(
819+
"ws", "p", "w", "prop", CreateWorkItemPropertyValue(value=value)
820+
)
821+
return payloads[-1]
822+
823+
return send
824+
825+
def test_a_rich_text_value_is_sent_as_an_object(self, sent) -> None:
826+
assert sent(RichTextValue(description_html="<p>Notes</p>")) == {
827+
"value": {"description_html": "<p>Notes</p>"}
828+
}
829+
830+
def test_the_other_value_types_are_unchanged(self, sent) -> None:
831+
assert sent("plain") == {"value": "plain"}
832+
assert sent(True) == {"value": True}
833+
assert sent(["a", "b"]) == {"value": ["a", "b"]}
834+
835+
def test_a_rich_text_read_carries_its_html_in_value_detail(self) -> None:
836+
"""`value` is the stored content's ID; the HTML is beside it."""
837+
detail = WorkItemPropertyValueDetail.model_validate(
838+
{
839+
"id": "v",
840+
"property_id": "p",
841+
"issue_id": "w",
842+
"value": "desc-1",
843+
"value_detail": {
844+
"id": "desc-1",
845+
"description_html": "<p>Notes</p>",
846+
"description_stripped": "Notes",
847+
},
848+
}
849+
)
850+
851+
assert detail.value == "desc-1"
852+
assert detail.value_detail.description_html == "<p>Notes</p>"

0 commit comments

Comments
 (0)