Coverage for polar/models/custom_field.py: 85%
88 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 12:42 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-10-07 12:42 +0000
1from datetime import datetime
2from enum import StrEnum
3from typing import TYPE_CHECKING, Annotated, Any, Literal, NotRequired, TypedDict
4from uuid import UUID
6from annotated_types import Ge, Len, MinLen
7from pydantic import AfterValidator, Field, ValidationInfo
8from sqlalchemy import ForeignKey, String, UniqueConstraint, Uuid
9from sqlalchemy.dialects.postgresql import CITEXT, JSONB
10from sqlalchemy.orm import Mapped, declared_attr, mapped_column, relationship
12from polar.kit.db.models import RecordModel
13from polar.kit.metadata import MetadataMixin
15if TYPE_CHECKING: 15 ↛ 16line 15 didn't jump to line 16 because the condition on line 15 was never true
16 from polar.models import Organization
19class CustomFieldType(StrEnum):
20 text = "text"
21 number = "number"
22 date = "date"
23 checkbox = "checkbox"
24 select = "select"
26 def get_model(self) -> type["CustomField"]:
27 return {
28 CustomFieldType.text: CustomFieldText,
29 CustomFieldType.number: CustomFieldNumber,
30 CustomFieldType.date: CustomFieldDate,
31 CustomFieldType.checkbox: CustomFieldCheckbox,
32 CustomFieldType.select: CustomFieldSelect,
33 }[self]
36PositiveInt = Annotated[int, Ge(0)]
37NonEmptyString = Annotated[str, Len(min_length=1)]
40def validate_ge_le(v: int, info: ValidationInfo) -> int:
41 """Validate that le is greater than or equal to ge when both are provided."""
42 ge = info.data.get("ge")
43 if ge is not None and v is not None and ge > v:
44 raise ValueError(
45 "Greater than or equal (ge) must be less than or equal to "
46 "Less than or equal (le)"
47 )
48 return v
51class CustomFieldProperties(TypedDict):
52 form_label: NotRequired[NonEmptyString]
53 form_help_text: NotRequired[NonEmptyString]
54 form_placeholder: NotRequired[NonEmptyString]
57class CustomFieldTextProperties(CustomFieldProperties):
58 textarea: NotRequired[bool]
59 min_length: NotRequired[PositiveInt]
60 max_length: NotRequired[PositiveInt]
63class ComparableProperties(TypedDict):
64 ge: NotRequired[int]
65 le: NotRequired[Annotated[int, AfterValidator(validate_ge_le)]]
68class CustomFieldNumberProperties(CustomFieldProperties, ComparableProperties):
69 pass
72class CustomFieldDateProperties(CustomFieldProperties, ComparableProperties):
73 pass
76class CustomFieldCheckboxProperties(CustomFieldProperties):
77 pass
80class CustomFieldSelectOption(TypedDict):
81 value: NonEmptyString
82 label: NonEmptyString
85class CustomFieldSelectProperties(CustomFieldProperties):
86 options: Annotated[list[CustomFieldSelectOption], MinLen(1)]
89class CustomField(MetadataMixin, RecordModel):
90 __tablename__ = "custom_fields"
91 __table_args__ = (UniqueConstraint("slug", "organization_id"),)
93 type: Mapped[CustomFieldType] = mapped_column(String, nullable=False, index=True)
94 slug: Mapped[str] = mapped_column(
95 CITEXT,
96 nullable=False,
97 # Don't create an index for slug
98 # as it's covered by the unique constraint, being the leading column of it
99 index=False,
100 )
101 name: Mapped[str] = mapped_column(String, nullable=False)
102 properties: Mapped[CustomFieldProperties] = mapped_column(
103 JSONB, nullable=False, default=dict
104 )
106 organization_id: Mapped[UUID] = mapped_column(
107 Uuid,
108 ForeignKey("organizations.id", ondelete="cascade"),
109 nullable=False,
110 index=True,
111 )
113 @declared_attr
114 def organization(cls) -> Mapped["Organization"]:
115 return relationship("Organization", lazy="raise")
117 def get_field_definition(self, required: bool) -> tuple[Any, Any]:
118 raise NotImplementedError()
120 __mapper_args__ = {
121 "polymorphic_on": "type",
122 }
125class CustomFieldText(CustomField):
126 type: Mapped[Literal[CustomFieldType.text]] = mapped_column(
127 use_existing_column=True
128 )
129 properties: Mapped[CustomFieldTextProperties] = mapped_column(
130 use_existing_column=True
131 )
133 __mapper_args__ = {
134 "polymorphic_identity": CustomFieldType.text,
135 "polymorphic_load": "inline",
136 }
138 def get_field_definition(self, required: bool) -> tuple[Any, Any]:
139 return (
140 str if required else str | None,
141 Field(
142 default=None if not required else ...,
143 min_length=self.properties.get("min_length"),
144 max_length=self.properties.get("max_length"),
145 ),
146 )
149class CustomFieldNumber(CustomField):
150 type: Mapped[Literal[CustomFieldType.number]] = mapped_column(
151 use_existing_column=True
152 )
153 properties: Mapped[CustomFieldNumberProperties] = mapped_column(
154 use_existing_column=True
155 )
157 __mapper_args__ = {
158 "polymorphic_identity": CustomFieldType.number,
159 "polymorphic_load": "inline",
160 }
162 def get_field_definition(self, required: bool) -> tuple[Any, Any]:
163 return (
164 int if required else int | None,
165 Field(
166 default=None if not required else ...,
167 ge=self.properties.get("ge"),
168 le=self.properties.get("le"),
169 ),
170 )
173class CustomFieldDate(CustomField):
174 type: Mapped[Literal[CustomFieldType.date]] = mapped_column(
175 use_existing_column=True
176 )
177 properties: Mapped[CustomFieldDateProperties] = mapped_column(
178 use_existing_column=True
179 )
181 __mapper_args__ = {
182 "polymorphic_identity": CustomFieldType.date,
183 "polymorphic_load": "inline",
184 }
186 def get_field_definition(self, required: bool) -> tuple[Any, Any]:
187 ge = self.properties.get("ge")
188 ge_date = datetime.fromtimestamp(ge).date() if ge else None
189 le = self.properties.get("le")
190 le_date = datetime.fromtimestamp(le).date() if le else None
191 return (
192 datetime if required else datetime | None,
193 Field(default=None if not required else ..., ge=ge_date, le=le_date),
194 )
197class CustomFieldCheckbox(CustomField):
198 type: Mapped[Literal[CustomFieldType.checkbox]] = mapped_column(
199 use_existing_column=True
200 )
201 properties: Mapped[CustomFieldCheckboxProperties] = mapped_column(
202 use_existing_column=True
203 )
205 __mapper_args__ = {
206 "polymorphic_identity": CustomFieldType.checkbox,
207 "polymorphic_load": "inline",
208 }
210 def get_field_definition(self, required: bool) -> tuple[Any, Any]:
211 return (
212 Literal[True] if required else bool,
213 Field(default=False if not required else ...),
214 )
217class CustomFieldSelect(CustomField):
218 type: Mapped[Literal[CustomFieldType.select]] = mapped_column(
219 use_existing_column=True
220 )
221 properties: Mapped[CustomFieldSelectProperties] = mapped_column(
222 use_existing_column=True
223 )
225 __mapper_args__ = {
226 "polymorphic_identity": CustomFieldType.select,
227 "polymorphic_load": "inline",
228 }
230 def get_field_definition(self, required: bool) -> tuple[Any, Any]:
231 literal_type = Literal[ # type: ignore
232 tuple(option["value"] for option in self.properties["options"])
233 ]
234 return (
235 literal_type if required else literal_type | None, # pyright: ignore
236 Field(
237 default=None if not required else ...,
238 ),
239 )