From c3e2432796d4c3139366325c693f4211f088b092 Mon Sep 17 00:00:00 2001 From: sinisaos Date: Sat, 25 Jan 2025 13:52:48 +0100 Subject: [PATCH 1/6] add enum support to custom forms --- admin_ui/src/components/NewForm.vue | 1 + e2e/test_forms.py | 26 ++++++++++++++++++ piccolo_admin/endpoints.py | 15 +++++++++++ piccolo_admin/example/forms/__init__.py | 2 ++ piccolo_admin/example/forms/enum.py | 35 +++++++++++++++++++++++++ piccolo_admin/utils.py | 13 +++++++++ tests/test_endpoints.py | 12 ++++++++- 7 files changed, 103 insertions(+), 1 deletion(-) create mode 100644 piccolo_admin/example/forms/enum.py create mode 100644 piccolo_admin/utils.py diff --git a/admin_ui/src/components/NewForm.vue b/admin_ui/src/components/NewForm.vue index d31a7703..0128faae 100644 --- a/admin_ui/src/components/NewForm.vue +++ b/admin_ui/src/components/NewForm.vue @@ -12,6 +12,7 @@ v-bind:type="getType(property)" v-bind:value="property.default" v-bind:isNullable="isNullable(property)" + v-bind:choices="property.extra?.choices" v-bind:timeResolution=" schema?.extra?.time_resolution[columnName] " diff --git a/e2e/test_forms.py b/e2e/test_forms.py index 9af3cd92..9d265390 100644 --- a/e2e/test_forms.py +++ b/e2e/test_forms.py @@ -1,6 +1,7 @@ from playwright.sync_api import Page from piccolo_admin.example.forms.csv import FORM as CSV_FORM +from piccolo_admin.example.forms.enum import FORM as ENUM_FORM from piccolo_admin.example.forms.image import FORM as IMAGE_FORM from piccolo_admin.example.forms.nullable import FORM as NULLABLE_FORM @@ -79,3 +80,28 @@ def test_nullable_form(page: Page, dev_server): and response.status == 200 ): form_page.submit_form() + + +def test_form_enum(page: Page, dev_server): + """ + Make sure custom forms support the usage of Enum's. + """ + login_page = LoginPage(page=page) + login_page.reset() + login_page.login() + + form_page = FormPage( + page=page, + form_slug=ENUM_FORM.slug, + ) + form_page.reset() + page.locator('input[name="username"]').fill("piccolo") + page.locator('input[name="email"]').fill("piccolo@example.com") + page.locator('select[name="permissions"]').select_option("admissions") + + with page.expect_response( + lambda response: response.url == f"{BASE_URL}/api/forms/enum-form/" + and response.request.method == "POST" + and response.status == 200 + ): + form_page.submit_form() diff --git a/piccolo_admin/endpoints.py b/piccolo_admin/endpoints.py index 6405a070..5ba8f361 100644 --- a/piccolo_admin/endpoints.py +++ b/piccolo_admin/endpoints.py @@ -62,6 +62,7 @@ TranslationListItem, TranslationListResponse, ) +from .utils import convert_enum_to_choices from .version import __VERSION__ as PICCOLO_ADMIN_VERSION logger = logging.getLogger(__name__) @@ -395,6 +396,7 @@ def __init__( t.Union[FormResponse, t.Coroutine[None, None, FormResponse]], ], description: t.Optional[str] = None, + choices: t.Optional[t.Dict[str, t.Any]] = None, form_group: t.Optional[str] = None, ): self.name = name @@ -403,6 +405,19 @@ def __init__( self.description = description self.form_group = form_group self.slug = self.name.replace(" ", "-").lower() + if choices is not None: + for field_name, field_value in choices.items(): + # update model_fields, field annotation and + # rebuild the model for the changes to take effect + pydantic_model.model_fields[field_name] = Field( + json_schema_extra={ + "extra": { + "choices": convert_enum_to_choices(field_value) + } + }, + ) + pydantic_model.model_fields[field_name].annotation = str + pydantic_model.model_rebuild(force=True) class FormConfigResponseModel(BaseModel): diff --git a/piccolo_admin/example/forms/__init__.py b/piccolo_admin/example/forms/__init__.py index 6cf310ae..f009d574 100644 --- a/piccolo_admin/example/forms/__init__.py +++ b/piccolo_admin/example/forms/__init__.py @@ -1,6 +1,7 @@ from .calculator import FORM as CALCULATOR_FORM from .csv import FORM as CSV_FORM from .email import FORM as EMAIL_FORM +from .enum import FORM as ENUM_FORM from .image import FORM as IMAGE_FORM from .nullable import FORM as MEGA_FORM @@ -10,4 +11,5 @@ EMAIL_FORM, IMAGE_FORM, MEGA_FORM, + ENUM_FORM, ] diff --git a/piccolo_admin/example/forms/enum.py b/piccolo_admin/example/forms/enum.py new file mode 100644 index 00000000..1bb9f4a6 --- /dev/null +++ b/piccolo_admin/example/forms/enum.py @@ -0,0 +1,35 @@ +import enum + +from pydantic import BaseModel, EmailStr +from starlette.requests import Request + +from piccolo_admin.endpoints import FormConfig + + +# An example of using Python enum in custom forms +class Permission(str, enum.Enum): + admissions = "admissions" + gallery = "gallery" + notices = "notices" + uploads = "uploads" + + +class NewStaffModel(BaseModel): + username: str + email: EmailStr + superuser: bool + + +def new_staff_endpoint(request: Request, data: NewStaffModel) -> str: + print(data) + return "A new staff member has been successfully created." + + +FORM = FormConfig( + name="Enum form", + pydantic_model=NewStaffModel, + endpoint=new_staff_endpoint, + description="Make a enum form.", + choices={"permissions": Permission}, + form_group="Text forms", +) diff --git a/piccolo_admin/utils.py b/piccolo_admin/utils.py new file mode 100644 index 00000000..2eaa1e85 --- /dev/null +++ b/piccolo_admin/utils.py @@ -0,0 +1,13 @@ +from __future__ import annotations + +import typing as t + + +def convert_enum_to_choices(enum_data: t.Any) -> t.Dict[str, t.Any]: + choices = {} + for item in enum_data: + choices[item.name] = { + "display_name": item.name, + "value": item.value, + } + return choices diff --git a/tests/test_endpoints.py b/tests/test_endpoints.py index 6eadb312..b76fdee8 100644 --- a/tests/test_endpoints.py +++ b/tests/test_endpoints.py @@ -311,6 +311,11 @@ def test_forms(self): "name": "Nullable fields", "slug": "nullable-fields", }, + { + "name": "Enum form", + "slug": "enum-form", + "description": "Make a enum form.", + }, ], ) @@ -526,7 +531,12 @@ def test_forms_grouped(self): "description": "Make a booking for a customer.", "name": "Booking form", "slug": "booking-form", - } + }, + { + "name": "Enum form", + "slug": "enum-form", + "description": "Make a enum form.", + }, ], "Test forms": [ { From 05dc4c8b86ec45af77e77c4914681e2d836ca5a9 Mon Sep 17 00:00:00 2001 From: sinisaos Date: Fri, 23 May 2025 10:06:51 +0200 Subject: [PATCH 2/6] applied @dantownsend suggestion --- docs/source/custom_forms/index.rst | 10 ++++++++++ piccolo_admin/endpoints.py | 13 +++++++++---- piccolo_admin/example/forms/enum.py | 5 ++++- piccolo_admin/translations/data.py | 6 +++--- 4 files changed, 26 insertions(+), 8 deletions(-) diff --git a/docs/source/custom_forms/index.rst b/docs/source/custom_forms/index.rst index 60489e2e..33166a6c 100644 --- a/docs/source/custom_forms/index.rst +++ b/docs/source/custom_forms/index.rst @@ -39,6 +39,16 @@ Here's a more advanced example where we send an email, then return a string: .. literalinclude:: ../../../piccolo_admin/example/forms/email.py +``Enum`` +-------- + +Custom forms support ``Enum`` type. Here's a example: + +.. literalinclude:: ../../../piccolo_admin/example/forms/enum.py + +.. warning:: + We need to do the ``Enum`` type conversion from the form data ourselves. + ``FileResponse`` ---------------- diff --git a/piccolo_admin/endpoints.py b/piccolo_admin/endpoints.py index 5ba8f361..7b72110d 100644 --- a/piccolo_admin/endpoints.py +++ b/piccolo_admin/endpoints.py @@ -4,6 +4,7 @@ from __future__ import annotations +import enum import inspect import io import itertools @@ -396,7 +397,6 @@ def __init__( t.Union[FormResponse, t.Coroutine[None, None, FormResponse]], ], description: t.Optional[str] = None, - choices: t.Optional[t.Dict[str, t.Any]] = None, form_group: t.Optional[str] = None, ): self.name = name @@ -405,14 +405,19 @@ def __init__( self.description = description self.form_group = form_group self.slug = self.name.replace(" ", "-").lower() - if choices is not None: - for field_name, field_value in choices.items(): + for ( + field_name, + field_value, + ) in self.pydantic_model.model_fields.items(): + if isinstance(field_value.annotation, enum.EnumType): # update model_fields, field annotation and # rebuild the model for the changes to take effect pydantic_model.model_fields[field_name] = Field( json_schema_extra={ "extra": { - "choices": convert_enum_to_choices(field_value) + "choices": convert_enum_to_choices( + field_value.annotation + ) } }, ) diff --git a/piccolo_admin/example/forms/enum.py b/piccolo_admin/example/forms/enum.py index 1bb9f4a6..52bb9ed8 100644 --- a/piccolo_admin/example/forms/enum.py +++ b/piccolo_admin/example/forms/enum.py @@ -18,9 +18,13 @@ class NewStaffModel(BaseModel): username: str email: EmailStr superuser: bool + permissions: Permission def new_staff_endpoint(request: Request, data: NewStaffModel) -> str: + # We need to do the enum type conversion ourselves like this: + # data.permissions = Permission(int(data.permissions)) # for int enum + # data.permissions = Permission(data.permissions) # for str enum print(data) return "A new staff member has been successfully created." @@ -30,6 +34,5 @@ def new_staff_endpoint(request: Request, data: NewStaffModel) -> str: pydantic_model=NewStaffModel, endpoint=new_staff_endpoint, description="Make a enum form.", - choices={"permissions": Permission}, form_group="Text forms", ) diff --git a/piccolo_admin/translations/data.py b/piccolo_admin/translations/data.py index 2f5ab9d5..23c8a295 100644 --- a/piccolo_admin/translations/data.py +++ b/piccolo_admin/translations/data.py @@ -1098,7 +1098,7 @@ FARSI = Translation( language_name="FARSI", language_code="fa", - translations = { + translations={ "About": "درباره", "Add Row": "افزودن ردیف", "Add": "افزودن", @@ -1115,7 +1115,7 @@ "Dark Mode": "حالت تاریک", "Days": "روز", "Delete": "حذف", - "Delimiter": "جداکننده", + "Delimiter": "جداکننده", "Descending": "نزولی", "Download": "دانلود", "Download failed": "دانلود ناموفق بود", @@ -1191,7 +1191,7 @@ TURKISH, UKRAINIAN, WELSH, - FARSI + FARSI, ] # We use some translations outside of the HTML templates (e.g. within From e62465ea7704f6358c7986f180a7590bc4f7dbc6 Mon Sep 17 00:00:00 2001 From: sinisaos Date: Fri, 23 May 2025 11:19:30 +0200 Subject: [PATCH 3/6] enable Enum type/meta in different Python versions --- piccolo_admin/endpoints.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/piccolo_admin/endpoints.py b/piccolo_admin/endpoints.py index 7b72110d..c4cb0447 100644 --- a/piccolo_admin/endpoints.py +++ b/piccolo_admin/endpoints.py @@ -11,6 +11,7 @@ import json import logging import os +import sys import typing as t from dataclasses import dataclass from datetime import timedelta @@ -66,6 +67,12 @@ from .utils import convert_enum_to_choices from .version import __VERSION__ as PICCOLO_ADMIN_VERSION +if sys.version_info < (3, 11): + ENUMTYPE = enum.EnumMeta +else: + ENUMTYPE = enum.EnumType + + logger = logging.getLogger(__name__) @@ -409,7 +416,7 @@ def __init__( field_name, field_value, ) in self.pydantic_model.model_fields.items(): - if isinstance(field_value.annotation, enum.EnumType): + if isinstance(field_value.annotation, ENUMTYPE): # update model_fields, field annotation and # rebuild the model for the changes to take effect pydantic_model.model_fields[field_name] = Field( From b08950e50d6f9904a0893cdbba8a0d37586b46ac Mon Sep 17 00:00:00 2001 From: sinisaos Date: Fri, 30 May 2025 21:51:47 +0200 Subject: [PATCH 4/6] applied @dantownsend suggestion --- piccolo_admin/endpoints.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/piccolo_admin/endpoints.py b/piccolo_admin/endpoints.py index c4cb0447..2c14f51d 100644 --- a/piccolo_admin/endpoints.py +++ b/piccolo_admin/endpoints.py @@ -11,7 +11,6 @@ import json import logging import os -import sys import typing as t from dataclasses import dataclass from datetime import timedelta @@ -67,12 +66,6 @@ from .utils import convert_enum_to_choices from .version import __VERSION__ as PICCOLO_ADMIN_VERSION -if sys.version_info < (3, 11): - ENUMTYPE = enum.EnumMeta -else: - ENUMTYPE = enum.EnumType - - logger = logging.getLogger(__name__) @@ -416,8 +409,10 @@ def __init__( field_name, field_value, ) in self.pydantic_model.model_fields.items(): - if isinstance(field_value.annotation, ENUMTYPE): - # update model_fields, field annotation and + if inspect.isclass(field_value.annotation) and issubclass( + field_value.annotation, enum.Enum + ): + # update model fields, field annotation and # rebuild the model for the changes to take effect pydantic_model.model_fields[field_name] = Field( json_schema_extra={ From decc3158d0696b7fa368bc146414c7a94f6f8fa0 Mon Sep 17 00:00:00 2001 From: sinisaos Date: Mon, 9 Jun 2025 08:30:13 +0200 Subject: [PATCH 5/6] use built-in types --- piccolo_admin/utils.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/piccolo_admin/utils.py b/piccolo_admin/utils.py index 2eaa1e85..b972cc62 100644 --- a/piccolo_admin/utils.py +++ b/piccolo_admin/utils.py @@ -1,9 +1,9 @@ from __future__ import annotations -import typing as t +from typing import Any -def convert_enum_to_choices(enum_data: t.Any) -> t.Dict[str, t.Any]: +def convert_enum_to_choices(enum_data: Any) -> dict[str, Any]: choices = {} for item in enum_data: choices[item.name] = { From 7c1198f26f909283506172ed08464315132077e6 Mon Sep 17 00:00:00 2001 From: sinisaos Date: Mon, 9 Jun 2025 14:15:56 +0200 Subject: [PATCH 6/6] Update docs/source/custom_forms/index.rst Co-authored-by: Ethan <47520067+Skelmis@users.noreply.github.com> --- docs/source/custom_forms/index.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/source/custom_forms/index.rst b/docs/source/custom_forms/index.rst index 33166a6c..09e67f1c 100644 --- a/docs/source/custom_forms/index.rst +++ b/docs/source/custom_forms/index.rst @@ -47,7 +47,7 @@ Custom forms support ``Enum`` type. Here's a example: .. literalinclude:: ../../../piccolo_admin/example/forms/enum.py .. warning:: - We need to do the ``Enum`` type conversion from the form data ourselves. + We need to do the ``Enum`` type conversion from the form data ourselves as a result of how this feature is implemented. If you don't do this conversion, then the field with be provided as a ``str`` instead of the ``Enum``. ``FileResponse`` ----------------