Skip to content

Commit

Permalink
Merge pull request #59 from surenkov/feature/55-django-jsonform-widget
Browse files Browse the repository at this point in the history
Add `django-jsonform` widget incorporation for v2 fields
  • Loading branch information
surenkov committed Apr 9, 2024
2 parents d3b1f38 + 0921dd3 commit 3c8b4ff
Show file tree
Hide file tree
Showing 9 changed files with 139 additions and 19 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/python-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ jobs:
run: |
python -m pip install -e .[dev,test]
- name: Lint package
run: mypy .
run: python -m mypy .

pre-commit:
runs-on: ubuntu-latest
Expand Down
10 changes: 8 additions & 2 deletions Makefile
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
DJANGO_SETTINGS_MODULE ?= "tests.settings.django_test_settings"

.PHONY: install build test lint upload upload-test clean

install:
Expand All @@ -8,15 +10,19 @@ build:
python3 -m build

migrations:
DJANGO_SETTINGS_MODULE="tests.settings.django_test_settings" python3 -m django makemigrations --noinput
python3 -m django makemigrations --noinput

runserver:
python3 -m django migrate && \
python3 -m django runserver

test: A=
test:
pytest $(A)

lint: A=.
lint:
mypy $(A)
python3 -m mypy $(A)

upload:
python3 -m twine upload dist/*
Expand Down
28 changes: 28 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,34 @@ assert form.cleaned_data["field"] == Foo(slug="bar_baz")

Note, that forward references would be resolved until field is being bound to the form instance.

### `django-jsonform` widgets
[`django-jsonform`](https://django-jsonform.readthedocs.io) offers a dynamic form construction based on the specified JSONSchema.
`django_pydantic_field.forms.SchemaField` plays nicely with its widgets, but only for Pydantic v2:

``` python
from django_pydantic_field.forms import SchemaField
from django_jsonform.widgets import JSONFormWidget

class FooForm(forms.Form):
field = SchemaField(Foo, widget=JSONFormWidget)
```

It is also possible to override the default form widget for Django Admin site, without writing custom admin forms:

``` python
from django.contrib import admin
from django_jsonform.widgets import JSONFormWidget

# NOTE: Importing direct field class instead of `SchemaField` wrapper.
from django_pydantic_field.v2.fields import PydanticSchemaField

@admin.site.register(SchemaModel)
class SchemaModelAdmin(admin.ModelAdmin):
formfield_overrides = {
PydanticSchemaField: {"widget": JSONFormWidget},
}
```

## Django REST Framework support

``` python
Expand Down
15 changes: 7 additions & 8 deletions django_pydantic_field/v2/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ class PydanticSchemaField(JSONField, ty.Generic[types.ST]):
def __init__(
self,
*args,
schema: type[types.ST] | BaseContainer | ty.ForwardRef | str | None = None,
schema: type[types.ST] | te.Annotated[type[types.ST], ...] | BaseContainer | ty.ForwardRef | str | None = None,
config: pydantic.ConfigDict | None = None,
**kwargs,
):
Expand Down Expand Up @@ -116,13 +116,12 @@ def check(self, **kwargs: ty.Any) -> list[checks.CheckMessage]:
message = f"Cannot resolve the schema. Original error: \n{exc.args[0]}"
performed_checks.append(checks.Error(message, obj=self, id="pydantic.E001"))

if self.has_default():
try:
# Test that the default value conforms to the schema.
self.get_prep_value(self.get_default())
except pydantic.ValidationError as exc:
message = f"Default value cannot be adapted to the schema. Pydantic error: \n{str(exc)}"
performed_checks.append(checks.Error(message, obj=self, id="pydantic.E002"))
try:
# Test that the default value conforms to the schema.
self.get_prep_value(self.get_default())
except pydantic.ValidationError as exc:
message = f"Default value cannot be adapted to the schema. Pydantic error: \n{str(exc)}"
performed_checks.append(checks.Error(message, obj=self, id="pydantic.E002"))

if {"include", "exclude"} & self.export_kwargs.keys():
# Try to prepare the default value to test export ability against it.
Expand Down
66 changes: 63 additions & 3 deletions django_pydantic_field/v2/forms.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from __future__ import annotations

import typing as ty
import warnings

import pydantic
from django.core.exceptions import ValidationError
Expand All @@ -11,18 +12,23 @@

from . import types

__all__ = ("SchemaField",)
if ty.TYPE_CHECKING:
import typing_extensions as te
from django.forms.widgets import Widget


__all__ = ("SchemaField", "JSONFormSchemaWidget")


class SchemaField(JSONField, ty.Generic[types.ST]):
adapter: types.SchemaAdapter
adapter: types.SchemaAdapter[types.ST]
default_error_messages = {
"schema_error": _("Schema didn't match for %(title)s."),
}

def __init__(
self,
schema: type[types.ST] | ty.ForwardRef | str,
schema: type[types.ST] | te.Annotated[type[types.ST], ...] | ty.ForwardRef | str,
config: pydantic.ConfigDict | None = None,
allow_null: bool | None = None,
*args,
Expand All @@ -34,6 +40,11 @@ def __init__(
self.config = config
self.export_kwargs = types.SchemaAdapter.extract_export_kwargs(kwargs)
self.adapter = types.SchemaAdapter(schema, config, None, None, allow_null, **self.export_kwargs)

widget = kwargs.get("widget")
if widget is not None:
kwargs["widget"] = _prepare_jsonform_widget(widget, self.adapter)

super().__init__(*args, **kwargs)

def get_bound_field(self, form: ty.Any, field_name: str):
Expand Down Expand Up @@ -92,3 +103,52 @@ def _try_coerce(self, value):
value = self.adapter.validate_json(value)

return value


try:
from django_jsonform.widgets import JSONFormWidget as _JSONFormWidget # type: ignore[import-untyped]
except ImportError:
from django.forms.widgets import Textarea

def _prepare_jsonform_widget(widget, adapter: types.SchemaAdapter[types.ST]) -> Widget | type[Widget]:
return widget

class JSONFormSchemaWidget(Textarea):
def __init__(self, *args, **kwargs):
warnings.warn(
"The 'django_jsonform' package is not installed. Please install it to use the widget.",
ImportWarning,
)
super().__init__(*args, **kwargs)

else:

def _prepare_jsonform_widget(widget, adapter: types.SchemaAdapter[types.ST]) -> Widget | type[Widget]: # type: ignore[no-redef]
if not isinstance(widget, type):
return widget

if issubclass(widget, JSONFormSchemaWidget):
widget = widget(
schema=adapter.prepared_schema,
config=adapter.config,
export_kwargs=adapter.export_kwargs,
allow_null=adapter.allow_null,
)
elif issubclass(widget, _JSONFormWidget):
widget = widget(schema=adapter.json_schema()) # type: ignore[call-arg]

return widget

class JSONFormSchemaWidget(_JSONFormWidget, ty.Generic[types.ST]): # type: ignore[no-redef]
def __init__(
self,
schema: type[types.ST] | te.Annotated[type[types.ST], ...] | ty.ForwardRef | str,
config: pydantic.ConfigDict | None = None,
allow_null: bool | None = None,
export_kwargs: types.ExportKwargs | None = None,
**kwargs,
):
if export_kwargs is None:
export_kwargs = {}
adapter = types.SchemaAdapter[types.ST](schema, config, None, None, allow_null, **export_kwargs)
super().__init__(adapter.json_schema(), **kwargs)
4 changes: 2 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ dependencies = [
[project.optional-dependencies]
openapi = ["uritemplate", "inflection"]
coreapi = ["coreapi"]
jsonform = ["django_jsonform>=2.0,<3"]
dev = [
"build",
"black",
Expand All @@ -63,7 +64,7 @@ dev = [
"pytest-django>=4.5,<5",
]
test = [
"django_pydantic_field[openapi,coreapi]",
"django_pydantic_field[openapi,coreapi,jsonform]",
"dj-database-url~=2.0",
"djangorestframework>=3,<4",
"pyyaml",
Expand Down Expand Up @@ -108,7 +109,6 @@ plugins = [
"mypy_drf_plugin.main"
]
exclude = [".env", ".venv", "tests"]
enable_incomplete_feature = ["Unpack"]

[tool.django-stubs]
django_settings_module = "tests.settings.django_test_settings"
Expand Down
12 changes: 11 additions & 1 deletion tests/settings/django_test_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
SITE_ID = 1
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
STATIC_URL = "/static/"
DEBUG = True

INSTALLED_APPS = [
"django.contrib.contenttypes",
Expand All @@ -18,10 +19,18 @@
"tests.test_app",
]

try:
import django_jsonform # type: ignore[import-untyped]
except ImportError:
pass
else:
INSTALLED_APPS.append("django_jsonform")


MIDDLEWARE = [
"django.contrib.sessions.middleware.SessionMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
]
TEMPLATES = [
{
Expand Down Expand Up @@ -56,3 +65,4 @@
CURRENT_TEST_DB = "default"

REST_FRAMEWORK = {"COMPACT_JSON": True}
ROOT_URLCONF = "tests.settings.urls"
6 changes: 6 additions & 0 deletions tests/settings/urls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from django.urls import path
from django.contrib import admin

urlpatterns = [
path("admin/", admin.site.urls),
]
15 changes: 13 additions & 2 deletions tests/test_app/admin.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,16 @@
from django.contrib import admin

try:
from django_jsonform.widgets import JSONFormWidget
from django_pydantic_field.v2.fields import PydanticSchemaField
from django_pydantic_field.v2.forms import JSONFormSchemaWidget

json_formfield_overrides = {PydanticSchemaField: {"widget": JSONFormWidget}}
json_schema_formfield_overrides = {PydanticSchemaField: {"widget": JSONFormSchemaWidget}}
except ImportError:
json_formfield_overrides = {}
json_schema_formfield_overrides = {}

from . import models


Expand All @@ -10,12 +21,12 @@ class SampleModelAdmin(admin.ModelAdmin):

@admin.register(models.SampleForwardRefModel)
class SampleForwardRefModelAdmin(admin.ModelAdmin):
pass
formfield_overrides = json_formfield_overrides # type: ignore


@admin.register(models.SampleModelWithRoot)
class SampleModelWithRootAdmin(admin.ModelAdmin):
pass
formfield_overrides = json_schema_formfield_overrides # type: ignore


@admin.register(models.ExampleModel)
Expand Down

0 comments on commit 3c8b4ff

Please sign in to comment.