Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add django-jsonform widget incorporation for v2 fields #59

Merged
merged 3 commits into from
Apr 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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