├── tests
├── __init__.py
├── testapp
│ ├── __init__.py
│ ├── apps.py
│ ├── settings.py
│ └── models.py
├── conftest.py
├── test_schema_validator.py
└── test_fields.py
├── example
├── __init__.py
├── example
│ ├── __init__.py
│ ├── wsgi.py
│ ├── urls.py
│ └── settings.py
├── todos
│ ├── __init__.py
│ ├── migrations
│ │ ├── __init__.py
│ │ └── 0001_initial.py
│ ├── apps.py
│ ├── admin.py
│ ├── models.py
│ └── constants.py
├── tester
│ ├── migrations
│ │ ├── __init__.py
│ │ └── 0001_initial.py
│ ├── __init__.py
│ ├── apps.py
│ ├── forms.py
│ ├── fixtures
│ │ └── admin.json
│ ├── views.py
│ ├── admin.py
│ └── models.py
├── templates
│ ├── detail.html
│ └── form.html
├── manage.py
└── static
│ ├── css
│ └── extra.css
│ └── js
│ └── extra.js
├── images
└── simple.png
├── django_reactive
├── __init__.py
├── apps.py
├── widget
│ ├── fields.py
│ ├── __init__.py
│ └── widgets.py
├── templates
│ └── django_reactive.html
├── schema_validator.py
├── forms.py
├── fields.py
└── static
│ ├── css
│ └── django_reactive.css
│ ├── js
│ └── django_reactive.js
│ └── dist
│ └── react.js
├── setup.cfg
├── .github
├── dependabot.yml
├── ISSUE_TEMPLATE.md
└── workflows
│ ├── semgrep.yml
│ └── ci.yml
├── .coveragerc
├── .bumpversion.cfg
├── Dockerfile
├── pytest.ini
├── bin
├── test
├── upgrade_js_libs.sh
└── wait_for_it.py
├── .editorconfig
├── .gitignore
├── .pre-commit-config.yaml
├── HISTORY.rst
├── docker-compose.yaml
├── LICENSE
├── pyproject.toml
├── Makefile
└── README.rst
/tests/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/example/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/example/example/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/example/todos/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/tests/testapp/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/example/tester/migrations/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/example/todos/migrations/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/example/tester/__init__.py:
--------------------------------------------------------------------------------
1 | default_app_config = 'tester.apps.TesterConfig'
2 |
--------------------------------------------------------------------------------
/images/simple.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/CoverGenius/django-reactive/HEAD/images/simple.png
--------------------------------------------------------------------------------
/django_reactive/__init__.py:
--------------------------------------------------------------------------------
1 | default_app_config = 'django_reactive.apps.DjangoReactJsonSchemaFormConfig'
2 |
--------------------------------------------------------------------------------
/example/todos/apps.py:
--------------------------------------------------------------------------------
1 | from django.apps import AppConfig
2 |
3 |
4 | class TodosConfig(AppConfig):
5 | name = 'todos'
6 |
--------------------------------------------------------------------------------
/example/tester/apps.py:
--------------------------------------------------------------------------------
1 | from django.apps import AppConfig
2 |
3 |
4 | class TesterConfig(AppConfig):
5 | name = 'tester'
6 |
--------------------------------------------------------------------------------
/tests/testapp/apps.py:
--------------------------------------------------------------------------------
1 | from django.apps import AppConfig
2 |
3 |
4 | class TestAppConfig(AppConfig):
5 | name = 'testapp'
6 |
--------------------------------------------------------------------------------
/django_reactive/apps.py:
--------------------------------------------------------------------------------
1 | from django.apps import AppConfig
2 |
3 |
4 | class DjangoReactJsonSchemaFormConfig(AppConfig):
5 | name = 'django_reactive'
6 |
--------------------------------------------------------------------------------
/setup.cfg:
--------------------------------------------------------------------------------
1 | [flake8]
2 | ignore = D203
3 | exclude =
4 | django_reactive/migrations,
5 | .git,
6 | .github,
7 | build,
8 | dist,
9 | .venv
10 | max-line-length = 119
11 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | updates:
3 | - package-ecosystem: pip
4 | directory: "/"
5 | schedule:
6 | interval: daily
7 | time: "19:00"
8 | open-pull-requests-limit: 10
9 |
--------------------------------------------------------------------------------
/example/templates/detail.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Test {{ obj.pk }}
5 |
6 |
7 | {{ object.basic }}
8 |
9 |
10 |
--------------------------------------------------------------------------------
/.coveragerc:
--------------------------------------------------------------------------------
1 | [run]
2 | branch = true
3 |
4 | [report]
5 | omit =
6 | *site-packages*
7 | *tests*
8 | *.tox*
9 | show_missing = True
10 | exclude_lines =
11 | raise NotImplementedError
12 |
--------------------------------------------------------------------------------
/.bumpversion.cfg:
--------------------------------------------------------------------------------
1 | [bumpversion]
2 | current_version = 0.0.12
3 | commit = True
4 | tag = True
5 |
6 | [bumpversion:file:pyproject.toml]
7 | search = version = "{current_version}"
8 | replace = version = "{new_version}"
9 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM python:3.10
2 |
3 | ENV PYTHONUNBUFFERED 1
4 |
5 | RUN mkdir /code
6 | WORKDIR /code
7 |
8 | RUN pip install poetry
9 | RUN poetry config virtualenvs.create false
10 | RUN poetry install
11 |
--------------------------------------------------------------------------------
/pytest.ini:
--------------------------------------------------------------------------------
1 | [pytest]
2 | log_cli = 1
3 | log_cli_level = INFO
4 | log_cli_format = %(asctime)s [%(levelname)8s] %(message)s (%(filename)s:%(lineno)s)
5 | log_cli_date_format=%Y-%m-%d %H:%M:%S
6 | DJANGO_SETTINGS_MODULE = tests.testapp.settings
7 |
--------------------------------------------------------------------------------
/bin/test:
--------------------------------------------------------------------------------
1 | #!/bin/sh -e
2 |
3 | export PREFIX=""
4 | if [ -d 'venv' ] ; then
5 | export PREFIX=".venv/bin/"
6 | export PATH=".venv/bin:$PATH"
7 | fi
8 |
9 | set -x
10 |
11 | PYTHONPATH=. ${PREFIX}pytest --ignore venv --cov=django_reactive --cov-report=term-missing "${@}"
12 |
--------------------------------------------------------------------------------
/example/manage.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | import os
3 | import sys
4 |
5 | if __name__ == '__main__':
6 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'example.settings')
7 |
8 | from django.core.management import execute_from_command_line
9 |
10 | execute_from_command_line(sys.argv)
11 |
--------------------------------------------------------------------------------
/example/templates/form.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 | Test
5 |
6 |
7 | {{ form.media }}
8 |
13 |
14 |
15 |
--------------------------------------------------------------------------------
/example/todos/admin.py:
--------------------------------------------------------------------------------
1 | from django.contrib import admin
2 |
3 | from .models import Todo, TaskType
4 |
5 |
6 | class TodoAdmin(admin.ModelAdmin):
7 | pass
8 |
9 |
10 | class TaskTypeAdmin(admin.ModelAdmin):
11 | pass
12 |
13 |
14 | admin.site.register(Todo, TodoAdmin)
15 | admin.site.register(TaskType, TaskTypeAdmin)
16 |
--------------------------------------------------------------------------------
/example/static/css/extra.css:
--------------------------------------------------------------------------------
1 | .dynamic-task-item {
2 | /*
3 | Modify the background color of the schema element with this 'classNames' attribute defined
4 | */
5 | background: #f0f0f0;
6 | }
7 |
8 | .aligned .form-row input {
9 | /*
10 | Set the row width of all matching form inputs
11 | */
12 | width: 300px;
13 | }
14 |
--------------------------------------------------------------------------------
/tests/conftest.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 | from testapp.models import SchemaModel, OptionalSchemaModel
4 |
5 |
6 | @pytest.fixture
7 | def get_schema_model(optional=False):
8 | def _inner(optional=optional):
9 | if optional:
10 | return OptionalSchemaModel
11 | return SchemaModel
12 |
13 | return _inner
14 |
--------------------------------------------------------------------------------
/django_reactive/widget/fields.py:
--------------------------------------------------------------------------------
1 | from .widgets import ReactJSONSchemaFormWidget
2 |
3 | try:
4 | # DJANGO 3.1
5 | from django.forms import JSONField
6 | except ImportError:
7 | from django.contrib.postgres.forms.jsonb import JSONField
8 |
9 |
10 | class ReactJSONSchemaFormField(JSONField):
11 | widget = ReactJSONSchemaFormWidget
12 |
--------------------------------------------------------------------------------
/example/tester/forms.py:
--------------------------------------------------------------------------------
1 | from django.forms import ModelForm
2 |
3 | from .models import BasicExampleModel
4 |
5 |
6 | class ExampleModelForm(ModelForm):
7 | class Meta:
8 | model = BasicExampleModel
9 | exclude = ['id']
10 |
11 |
12 | class BasicTestModelForm(ModelForm):
13 | class Meta:
14 | model = BasicExampleModel
15 | fields = ['basic']
16 |
--------------------------------------------------------------------------------
/example/tester/fixtures/admin.json:
--------------------------------------------------------------------------------
1 | [
2 | { "model": "auth.user",
3 | "pk": 1,
4 | "fields": {
5 | "username": "admin",
6 | "password": "pbkdf2_sha256$10000$vkRy7QauoLLj$ry+3xm3YX+YrSXbri8s3EcXDIrx5ceM+xQjtpLdw2oE=",
7 | "is_superuser": true,
8 | "is_staff": true,
9 | "is_active": true
10 | }
11 | }
12 | ]
13 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE.md:
--------------------------------------------------------------------------------
1 | * django-reactive version:
2 | * Django version:
3 | * Python version:
4 | * Operating System:
5 |
6 | ### Description
7 |
8 | Describe what you were trying to get done.
9 | Tell us what happened, what went wrong, and what you expected to happen.
10 |
11 | ### What I Did
12 |
13 | ```
14 | Paste the command(s) you ran and the output.
15 | If there was a crash, please include the traceback here.
16 | ```
17 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | # http://editorconfig.org
2 |
3 | root = true
4 |
5 | [*]
6 | charset = utf-8
7 | end_of_line = lf
8 | insert_final_newline = true
9 | trim_trailing_whitespace = true
10 |
11 | [*.{py,rst,ini}]
12 | indent_style = space
13 | indent_size = 4
14 |
15 | [*.{html,css,scss,json,yml}]
16 | indent_style = space
17 | indent_size = 2
18 |
19 | [*.md]
20 | trim_trailing_whitespace = false
21 |
22 | [Makefile]
23 | indent_style = tab
24 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | *.py[cod]
2 | __pycache__
3 |
4 | *.egg
5 | *.egg-info
6 |
7 | # Installer logs
8 | pip-log.txt
9 |
10 | # Unit test / coverage reports
11 | .coverage
12 | .tox
13 | coverage.xml
14 | htmlcov
15 |
16 | # Mr Developer
17 | .mr.developer.cfg
18 | .project
19 | .pydevproject
20 |
21 | # Complexity
22 | output/*.html
23 | output/*/index.html
24 |
25 | # Sphinx
26 | docs/_build
27 |
28 | # Poetry
29 | poetry.lock
30 | /dist
31 | poetry.toml
32 |
--------------------------------------------------------------------------------
/example/static/js/extra.js:
--------------------------------------------------------------------------------
1 | document.addEventListener('DOMContentLoaded', function() {
2 |
3 | /*
4 | Modify the editor dynamically on page load.
5 |
6 | IMPORTANT: Changes defined here may be overwritten by React component state, so it
7 | may not be reliable for dynamic schema mutations.
8 | */
9 |
10 | let todoEditor = document.querySelector('#todos_editor')
11 | todoEditor.style.backgroundColor = '#f0f0f0'
12 | }, false);
13 |
--------------------------------------------------------------------------------
/example/example/wsgi.py:
--------------------------------------------------------------------------------
1 | """
2 | WSGI config for example project.
3 |
4 | It exposes the WSGI callable as a module-level variable named ``application``.
5 |
6 | For more information on this file, see
7 | https://docs.djangoproject.com/en/1.9/howto/deployment/wsgi/
8 | """
9 |
10 | import os
11 |
12 | from django.core.wsgi import get_wsgi_application
13 |
14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'example.settings')
15 |
16 | application = get_wsgi_application()
17 |
--------------------------------------------------------------------------------
/django_reactive/templates/django_reactive.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
6 |
7 |
17 |
--------------------------------------------------------------------------------
/.github/workflows/semgrep.yml:
--------------------------------------------------------------------------------
1 | on:
2 | pull_request: {}
3 | push:
4 | branches:
5 | - main
6 | - master
7 | paths:
8 | - .github/workflows/semgrep.yml
9 | schedule:
10 | - cron: '0 0 * * 0'
11 | name: Semgrep
12 | jobs:
13 | semgrep:
14 | name: Scan
15 | runs-on: ubuntu-20.04
16 | env:
17 | SEMGREP_APP_TOKEN: ${{ secrets.SEMGREP_APP_TOKEN }}
18 | container:
19 | image: returntocorp/semgrep
20 | steps:
21 | - uses: actions/checkout@v3
22 | - run: semgrep ci
23 |
--------------------------------------------------------------------------------
/django_reactive/schema_validator.py:
--------------------------------------------------------------------------------
1 | from jsonschema import Draft7Validator
2 | from typing import List, Tuple
3 |
4 |
5 | def validate_json_schema(schema: dict) -> Tuple[bool, List[str]]:
6 | """
7 | Validate a JSON schema using the Draft 7 validator.
8 | """
9 | validator = Draft7Validator(
10 | schema=Draft7Validator.META_SCHEMA,
11 | format_checker=Draft7Validator.FORMAT_CHECKER,
12 | )
13 | errors = [f"{'.'.join(e.path)}: {e.message}" for e in validator.iter_errors(schema)]
14 |
15 | return not bool(errors), errors
16 |
--------------------------------------------------------------------------------
/example/example/urls.py:
--------------------------------------------------------------------------------
1 | from django.contrib import admin
2 | from django.urls import path
3 |
4 | from django.conf import settings
5 | from django.contrib.staticfiles import views
6 |
7 | from tester.views import TestModelFormView, TestModelDetailView
8 |
9 |
10 | urlpatterns = [
11 | path('admin/', admin.site.urls),
12 | path('tester/create/', TestModelFormView.as_view(), name='create'),
13 | path('tester//', TestModelDetailView.as_view(), name='detail'),
14 | ]
15 |
16 |
17 | if settings.DEBUG:
18 | from django.urls import re_path
19 |
20 | urlpatterns += [re_path(r'^static/(?P.*)$', views.serve)]
21 |
--------------------------------------------------------------------------------
/django_reactive/forms.py:
--------------------------------------------------------------------------------
1 | from django.forms import ModelForm
2 |
3 | from .widget.fields import ReactJSONSchemaFormField
4 |
5 |
6 | class ReactJSONSchemaModelForm(ModelForm):
7 | """
8 | Provides the instance object of a ModelForm to all of the schema field widgets.
9 | """
10 |
11 | def __init__(self, *args, **kwargs):
12 | on_render_object = kwargs.get('instance', None)
13 | super().__init__(*args, **kwargs)
14 | for field_name, field_value in self.fields.items():
15 | if isinstance(field_value, ReactJSONSchemaFormField):
16 | self.fields[field_name].widget.on_render_object = on_render_object
17 |
--------------------------------------------------------------------------------
/django_reactive/widget/__init__.py:
--------------------------------------------------------------------------------
1 | from django.forms import ModelForm
2 |
3 | from django_reactive.widget.fields import ReactJSONSchemaFormField
4 |
5 |
6 | class ReactJSONSchemaModelForm(ModelForm):
7 | """
8 | Provides the instance object of a ModelForm to all of the schema field widgets.
9 | """
10 |
11 | def __init__(self, *args, **kwargs):
12 | on_render_object = kwargs.get('instance', None)
13 | super().__init__(*args, **kwargs)
14 | for field_name, field_value in self.fields.items():
15 | if isinstance(field_value, ReactJSONSchemaFormField):
16 | self.fields[field_name].widget.on_render_object = on_render_object
17 |
--------------------------------------------------------------------------------
/.pre-commit-config.yaml:
--------------------------------------------------------------------------------
1 | repos:
2 | - repo: https://github.com/pre-commit/pre-commit-hooks
3 | rev: v4.4.0
4 | hooks:
5 | - id: trailing-whitespace
6 | - id: end-of-file-fixer
7 | - id: check-yaml
8 | - id: debug-statements
9 | - id: double-quote-string-fixer
10 |
11 | - repo: https://github.com/asottile/pyupgrade
12 | rev: v3.3.1
13 | hooks:
14 | - id: pyupgrade
15 | args: [--py38-plus]
16 |
17 | - repo: https://github.com/pycqa/flake8
18 | rev: '6.0.0'
19 | hooks:
20 | - id: flake8
21 |
22 | - repo: https://github.com/psf/black
23 | rev: 22.12.0
24 | hooks:
25 | - id: black
26 | args: [--skip-string-normalization]
27 |
--------------------------------------------------------------------------------
/example/tester/views.py:
--------------------------------------------------------------------------------
1 | from django.views.generic import FormView, DetailView
2 | from django.urls import reverse
3 |
4 | from .models import BasicExampleModel
5 | from .forms import BasicTestModelForm
6 |
7 |
8 | class TestModelFormView(FormView):
9 | template_name = 'form.html'
10 | form_class = BasicTestModelForm
11 | model = BasicExampleModel
12 |
13 | def form_valid(self, form):
14 | self.obj = form.save()
15 |
16 | return super().form_valid(form)
17 |
18 | def get_success_url(self):
19 | return reverse('detail', kwargs={'pk': self.obj.pk})
20 |
21 |
22 | class TestModelDetailView(DetailView):
23 | template_name = 'detail.html'
24 | model = BasicExampleModel
25 |
--------------------------------------------------------------------------------
/bin/upgrade_js_libs.sh:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env bash
2 |
3 | DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)
4 | ROOT=$DIR/..
5 | DIST_DIR=${ROOT}/django_reactive/static/dist
6 |
7 | curl --request GET -sL \
8 | --url 'https://unpkg.com/react@16/umd/react.production.min.js' \
9 | --output "${DIST_DIR}/react.js"
10 |
11 | curl --request GET -sL \
12 | --url 'https://unpkg.com/react-dom@16/umd/react-dom.production.min.js' \
13 | --output "${DIST_DIR}/react-dom.js"
14 |
15 | curl --request GET -sL \
16 | --url 'https://unpkg.com/react-jsonschema-form@1.8/dist/react-jsonschema-form.js' \
17 | --output "${DIST_DIR}/react-jsonschema-form.js"
18 |
19 | curl --request GET -sL \
20 | --url 'https://unpkg.com/react-jsonschema-form@1.8/dist/react-jsonschema-form.js.map' \
21 | --output "${DIST_DIR}/react-jsonschema-form.js.map"
22 |
--------------------------------------------------------------------------------
/example/todos/models.py:
--------------------------------------------------------------------------------
1 | from django.db import models
2 |
3 | from django_reactive.fields import ReactJSONSchemaField
4 |
5 | from .constants import TODO_SCHEMA, TODO_UI_SCHEMA, set_task_types
6 |
7 |
8 | class Todo(models.Model):
9 | """
10 | A collection of task lists for a todo.
11 | """
12 |
13 | name = models.CharField(max_length=255)
14 | task_lists = ReactJSONSchemaField(
15 | help_text='Task lists',
16 | schema=TODO_SCHEMA,
17 | ui_schema=TODO_UI_SCHEMA,
18 | on_render=set_task_types,
19 | extra_css=['css/extra.css'],
20 | extra_js=['js/extra.js'],
21 | )
22 |
23 |
24 | class TaskType(models.Model):
25 | """
26 | A task type used to dynamically populate a todo list schema field dropdown.
27 | """
28 |
29 | name = models.CharField(max_length=255)
30 |
--------------------------------------------------------------------------------
/HISTORY.rst:
--------------------------------------------------------------------------------
1 | .. :changelog:
2 |
3 | History
4 | -------
5 |
6 | 0.0.2 (2018-12-17)
7 | ++++++++++++++++++
8 |
9 | * First release on PyPI.
10 |
11 | 0.0.4 (2020-02-02)
12 | ++++++++++++++++++
13 |
14 | * Switched to poetry
15 | * Updated React and React JSON Schema Form
16 | * Updated some other dependencies
17 | * Added support for Python 3.8
18 |
19 | 0.0.8 (2020-11-11)
20 | ++++++++++++++++++
21 |
22 | * Added *extra_css* and *extra_js* config options to ReactJSONSchemaField class
23 | that allows injecting extra assets required for the filed
24 | * Added *on_render* config option to ReactJSONSchemaField class that adds an ability to
25 | configure dynamic *schema* and *ui_schema* based on object fields
26 | * Updated some *react* amd *react-dom* dependencies
27 | * Added support for Python 3.9 and Django 2.2, 3.0 and 3.1 in testing environment
28 |
--------------------------------------------------------------------------------
/tests/test_schema_validator.py:
--------------------------------------------------------------------------------
1 | from django_reactive.schema_validator import validate_json_schema
2 |
3 |
4 | def test_valid_json_schema():
5 | schema = {
6 | 'type': 'object',
7 | 'properties': {
8 | 'name': {'type': 'string'},
9 | 'age': {'type': 'integer'},
10 | },
11 | }
12 | result, errors = validate_json_schema(schema)
13 | assert result
14 | assert errors == []
15 |
16 |
17 | def test_invalid_json_schema():
18 | schema = {
19 | 'type': 'object',
20 | 'properties': {
21 | 'name': {'type': 'string'},
22 | 'age': {'type': 'invalid'}, # Invalid type for age property
23 | },
24 | }
25 | result, errors = validate_json_schema(schema)
26 | assert not result
27 | assert errors == ["properties.age.type: 'invalid' is not valid under any of the given schemas"]
28 |
--------------------------------------------------------------------------------
/example/todos/migrations/0001_initial.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.2.16 on 2022-12-29 05:02
2 |
3 | from django.db import migrations, models
4 | import django_reactive.fields
5 |
6 |
7 | class Migration(migrations.Migration):
8 |
9 | initial = True
10 |
11 | dependencies = []
12 |
13 | operations = [
14 | migrations.CreateModel(
15 | name='TaskType',
16 | fields=[
17 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
18 | ('name', models.CharField(max_length=255)),
19 | ],
20 | ),
21 | migrations.CreateModel(
22 | name='Todo',
23 | fields=[
24 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
25 | ('name', models.CharField(max_length=255)),
26 | ('task_lists', django_reactive.fields.ReactJSONSchemaField(default=dict, help_text='Task lists')),
27 | ],
28 | ),
29 | ]
30 |
--------------------------------------------------------------------------------
/docker-compose.yaml:
--------------------------------------------------------------------------------
1 | version: "3.5"
2 |
3 | services:
4 |
5 | db:
6 | image: postgres:15-alpine
7 | restart: always
8 | volumes:
9 | - db_vol:/var/lib/postgresql/data
10 | ports:
11 | - 5432:5432
12 | environment:
13 | POSTGRES_PASSWORD: postgres
14 | POSTGRES_USER: postgres
15 | POSTGRES_DB: postgres
16 | PGDATA: /var/lib/postgresql/data/pgdata
17 | healthcheck:
18 | test: ["CMD-SHELL", "pg_isready -U postgres"]
19 | interval: 10s
20 | timeout: 5s
21 | retries: 5
22 | start_period: 10s
23 |
24 | web:
25 | build: .
26 | command: >
27 | sh -c 'python /code/bin/wait_for_it.py -a db -p 5432 --
28 | python example/manage.py makemigrations &&
29 | python example/manage.py migrate &&
30 | python example/manage.py loaddata admin &&
31 | python example/manage.py runserver 0.0.0.0:8000'
32 | volumes:
33 | - .:/code
34 | ports:
35 | - "8000:8000"
36 | depends_on:
37 | - db
38 |
39 | volumes:
40 | db_vol: ~
41 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020, Artem Kolesnikov
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6 |
7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8 |
9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
10 |
--------------------------------------------------------------------------------
/pyproject.toml:
--------------------------------------------------------------------------------
1 | [tool.black]
2 | line-length = 119
3 | target-version = ['py38']
4 | include = '\.pyi?$'
5 | extend-exclude = '''
6 | (
7 | migrations/*.py
8 | )
9 | '''
10 |
11 | [tool.poetry]
12 | name = "django-reactive"
13 | version = "0.0.12"
14 | description = "Django JSON form field on steroids"
15 | authors = ["Artem Kolesnikov "]
16 | license = "MIT"
17 | readme = "README.rst"
18 | homepage = "https://github.com/tyomo4ka/django-reactive"
19 | repository = "https://github.com/tyomo4ka/django-reactive"
20 | documentation = "https://github.com/tyomo4ka/django-reactive"
21 | keywords = ["django",
22 | "postgres",
23 | "json",
24 | "jsonschema",
25 | "react"]
26 |
27 |
28 | [tool.poetry.dependencies]
29 | python = ">=3.8.1,<4"
30 | jsonschema = "^3.0||^4.0"
31 | psycopg2-binary = "^2.7||^3.0"
32 | django = "^3.2||^4.0"
33 |
34 |
35 | [tool.poetry.dev-dependencies]
36 | bump2version = "^1.0.1"
37 | mock = "^5.0"
38 | pytest = "^7.2"
39 | pytest-cov = "^4.0"
40 | pytest-django = "^4.5"
41 | pre-commit = "^3.0.4"
42 |
43 |
44 | [build-system]
45 | requires = ["poetry-core"]
46 | build-backend = "poetry.core.masonry.api"
47 |
--------------------------------------------------------------------------------
/example/tester/admin.py:
--------------------------------------------------------------------------------
1 | from django.contrib import admin
2 | from django.contrib.admin import ModelAdmin
3 |
4 | from .models import (
5 | BasicExampleModel,
6 | NestedExampleModel,
7 | ArraysExampleModel,
8 | NumbersExampleModel,
9 | WidgetExampleModel,
10 | OrderingExampleModel,
11 | ReferencesExampleModel,
12 | ErrorsExampleModel,
13 | LargeExampleModel,
14 | DateAndTimeExampleModel,
15 | ValidationExampleModel,
16 | FileTestModel,
17 | AlternativesExample,
18 | PropertyDependenciesExample,
19 | SchemaDependenciesExampleModel,
20 | MultipleExampleModel,
21 | )
22 | from .forms import ExampleModelForm
23 |
24 |
25 | class TestModelAdmin(ModelAdmin):
26 | form = ExampleModelForm
27 |
28 |
29 | admin.site.register(BasicExampleModel, TestModelAdmin)
30 | admin.site.register(NestedExampleModel)
31 | admin.site.register(ArraysExampleModel)
32 | admin.site.register(NumbersExampleModel)
33 | admin.site.register(WidgetExampleModel)
34 | admin.site.register(OrderingExampleModel)
35 | admin.site.register(ReferencesExampleModel)
36 | admin.site.register(ErrorsExampleModel)
37 | admin.site.register(LargeExampleModel)
38 | admin.site.register(DateAndTimeExampleModel)
39 | admin.site.register(ValidationExampleModel)
40 | admin.site.register(FileTestModel)
41 | admin.site.register(AlternativesExample)
42 | admin.site.register(PropertyDependenciesExample)
43 | admin.site.register(SchemaDependenciesExampleModel)
44 | admin.site.register(MultipleExampleModel)
45 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | .PHONY: clean-pyc clean-build docs help
2 | .DEFAULT_GOAL := help
3 | define BROWSER_PYSCRIPT
4 | import os, webbrowser, sys
5 | try:
6 | from urllib import pathname2url
7 | except:
8 | from urllib.request import pathname2url
9 |
10 | webbrowser.open("file://" + pathname2url(os.path.abspath(sys.argv[1])))
11 | endef
12 | export BROWSER_PYSCRIPT
13 | BROWSER := python -c "$$BROWSER_PYSCRIPT"
14 |
15 | help:
16 | @grep '^[a-zA-Z]' $(MAKEFILE_LIST) | sort | awk -F ':.*?## ' 'NF==2 {printf "\033[36m %-25s\033[0m %s\n", $$1, $$2}'
17 |
18 | clean: clean-build clean-pyc
19 |
20 | clean-build: ## remove build artifacts
21 | rm -fr build/
22 | rm -fr dist/
23 | rm -fr *.egg-info
24 |
25 | clean-pyc: ## remove Python file artifacts
26 | find . -name '*.pyc' -exec rm -f {} +
27 | find . -name '*.pyo' -exec rm -f {} +
28 | find . -name '*~' -exec rm -f {} +
29 |
30 | lint: ## check style with flake8
31 | flake8 django_reactive tests example
32 |
33 | test: ## run tests quickly with the default Python
34 | pytest tests
35 |
36 | test-all: ## run tests on every Python version with tox
37 | tox
38 |
39 | coverage: ## check code coverage quickly with the default Python
40 | coverage run -m pytest tests
41 | coverage report -m
42 | coverage html
43 | open htmlcov/index.html
44 |
45 | docs: ## generate Sphinx HTML documentation, including API docs
46 | rm -f docs/django-reactive.rst
47 | rm -f docs/modules.rst
48 | sphinx-apidoc -o docs/ django_reactive
49 | $(MAKE) -C docs clean
50 | $(MAKE) -C docs html
51 | $(BROWSER) docs/_build/html/index.html
52 |
53 | release: clean ## package and upload a release
54 | poetry publish
55 |
56 | sdist: clean ## package
57 | poetry build
58 |
--------------------------------------------------------------------------------
/django_reactive/widget/widgets.py:
--------------------------------------------------------------------------------
1 | import json
2 | import logging
3 |
4 | from django.forms import Media
5 | from django.forms.widgets import Widget
6 | from django.template.loader import render_to_string
7 | from django.utils.safestring import mark_safe
8 |
9 |
10 | logger = logging.getLogger(__name__)
11 |
12 |
13 | class ReactJSONSchemaFormWidget(Widget):
14 |
15 | template_name = 'django_reactive.html'
16 |
17 | def __init__(self, schema, ui_schema=None, on_render=None, extra_css=None, extra_js=None, **kwargs):
18 | self.schema = schema
19 | self.ui_schema = ui_schema
20 | self.on_render = on_render
21 | self.on_render_object = None
22 | self.extra_css = extra_css
23 | self.extra_js = extra_js
24 | super().__init__(**kwargs)
25 |
26 | @property
27 | def media(self):
28 | css = ['css/django_reactive.css']
29 | if self.extra_css:
30 | css.extend(self.extra_css)
31 | js = [
32 | 'dist/react.js',
33 | 'dist/react-dom.js',
34 | 'dist/react-jsonschema-form.js',
35 | 'js/django_reactive.js',
36 | ]
37 | if self.extra_js:
38 | js.extend(self.extra_js)
39 |
40 | return Media(css={'all': css}, js=js)
41 |
42 | def mutate(self):
43 | kwargs = {}
44 | if self.on_render_object:
45 | kwargs['instance'] = self.on_render_object
46 | try:
47 | self.on_render(self.schema, self.ui_schema, **kwargs)
48 | except BaseException as exc:
49 | logger.error('Error applying JSON schema hooks: %s', exc, exc_info=True)
50 |
51 | def render(self, name, value, attrs=None, renderer=None):
52 | if self.on_render:
53 | self.mutate()
54 |
55 | context = {
56 | 'data': value,
57 | 'name': name,
58 | 'schema': json.dumps(self.schema),
59 | 'ui_schema': json.dumps(self.ui_schema) if self.ui_schema else '{}',
60 | }
61 |
62 | return mark_safe(render_to_string(self.template_name, context))
63 |
--------------------------------------------------------------------------------
/django_reactive/fields.py:
--------------------------------------------------------------------------------
1 | from django.core.exceptions import ValidationError
2 | from django.core import checks
3 | from jsonschema import validate, ValidationError as JSONSchemaValidationError
4 |
5 | from .widget.fields import ReactJSONSchemaFormField
6 | from .widget.widgets import ReactJSONSchemaFormWidget
7 | from .schema_validator import validate_json_schema
8 |
9 | try:
10 | # DJANGO 3.1
11 | from django.db.models import JSONField as BaseJSONField
12 | except ImportError:
13 | from django.contrib.postgres.fields import JSONField as BaseJSONField
14 |
15 |
16 | class ReactJSONSchemaField(BaseJSONField):
17 | def __init__(self, schema=None, ui_schema=None, on_render=None, extra_css=None, extra_js=None, **kwargs):
18 | kwargs.setdefault('default', dict)
19 | super().__init__(**kwargs)
20 | self.schema = schema
21 | self.ui_schema = ui_schema
22 | self.on_render = on_render
23 | self.extra_css = extra_css
24 | self.extra_js = extra_js
25 |
26 | def formfield(self, **kwargs):
27 | defaults = {
28 | 'required': not self.blank,
29 | }
30 | defaults.update(**kwargs)
31 | return ReactJSONSchemaFormField(
32 | widget=ReactJSONSchemaFormWidget(
33 | schema=self.schema,
34 | ui_schema=self.ui_schema,
35 | on_render=self.on_render,
36 | extra_css=self.extra_css,
37 | extra_js=self.extra_js,
38 | ),
39 | **defaults,
40 | )
41 |
42 | def validate(self, value, model_instance):
43 | super().validate(value, model_instance)
44 | try:
45 | validate(value, self.schema)
46 | except JSONSchemaValidationError:
47 | raise ValidationError('This field has errors.')
48 |
49 | def check(self, **kwargs):
50 | errors = super().check(**kwargs)
51 | res, schema_errors = validate_json_schema(self.schema)
52 | if not res:
53 | msg = ','.join(schema_errors)
54 | errors = [
55 | checks.Error(
56 | f'JSON schema is not valid: {msg}',
57 | obj=self.model,
58 | id='fields.JSON_SCHEMA_ERROR',
59 | )
60 | ]
61 |
62 | return errors
63 |
--------------------------------------------------------------------------------
/example/todos/constants.py:
--------------------------------------------------------------------------------
1 | TODO_SCHEMA = {
2 | 'type': 'object',
3 | 'properties': {
4 | 'description': {'title': 'Description', 'type': 'string'},
5 | 'task_lists': {
6 | 'title': 'Task lists',
7 | 'type': 'array',
8 | 'uniqueItems': True,
9 | 'items': {'$ref': '#/definitions/TaskList'},
10 | },
11 | },
12 | 'required': ['description', 'task_lists'],
13 | 'definitions': {
14 | 'Task': {
15 | 'title': 'Task',
16 | 'type': 'object',
17 | 'properties': {
18 | 'name': {'title': 'Name', 'type': 'string'},
19 | 'task_type': {'type': 'string', 'enum': []},
20 | },
21 | 'required': ['name'],
22 | },
23 | 'TaskList': {
24 | 'title': 'Task lists',
25 | 'type': 'object',
26 | 'properties': {
27 | 'name': {'title': 'Name', 'type': 'string'},
28 | 'tasks': {
29 | 'title': 'Tasks',
30 | 'type': 'array',
31 | 'items': {'$ref': '#/definitions/Task'},
32 | },
33 | },
34 | 'required': ['name', 'tasks'],
35 | },
36 | },
37 | }
38 |
39 |
40 | TODO_UI_SCHEMA = {
41 | 'ui:title': 'Todo lists',
42 | 'description': {
43 | 'ui:autofocus': True,
44 | 'ui:emptyValue': '',
45 | 'ui:help': 'A summary of all the tasks lists',
46 | 'ui:widget': 'textarea',
47 | },
48 | 'task_lists': {
49 | 'items': {
50 | 'classNames': 'dynamic-task-list',
51 | 'name': {'ui:help': 'A descriptive name for the task list'},
52 | 'tasks': {
53 | 'items': {
54 | 'classNames': 'dynamic-task-item',
55 | 'name': {'ui:help': 'A descriptive name for the task'},
56 | 'task_type': {
57 | 'classNames': 'dynamic-task-field',
58 | 'ui:help': 'The task type',
59 | 'ui:widget': 'select',
60 | },
61 | }
62 | },
63 | },
64 | },
65 | }
66 |
67 |
68 | def set_task_types(schema, ui_schema):
69 | from todos.models import TaskType
70 |
71 | task_types = list(TaskType.objects.all().values_list('name', flat=True))
72 | schema['definitions']['Task']['properties']['task_type']['enum'] = task_types
73 | ui_schema['task_lists']['items']['tasks']['items']['task_type'][
74 | 'ui:help'
75 | ] = f'Select 1 of {len(task_types)} task types'
76 |
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: CI
2 |
3 | on:
4 | push:
5 | branches:
6 | - master
7 | tags:
8 | - v** # Run on all version tags
9 | pull_request:
10 |
11 | jobs:
12 | pytest:
13 | name: pytest
14 | runs-on: ubuntu-latest
15 | strategy:
16 | fail-fast: false
17 | matrix:
18 | python: ["3.8", "3.9", "3.10", "3.11"]
19 | django: ["3.2.16", "4.0.8", "4.1.4" ]
20 | exclude:
21 | # Excludes Python 3.11 for Django < 4.1
22 | - python: "3.11"
23 | django: "3.2.16"
24 | - python: "3.11"
25 | django: "4.0.8"
26 |
27 | services:
28 | postgres:
29 | image: postgres
30 | env:
31 | POSTGRES_PASSWORD: postgres
32 | options: >-
33 | --health-cmd pg_isready
34 | --health-interval 5s
35 | --health-timeout 1s
36 | --health-retries 5
37 | ports:
38 | - 5432:5432
39 |
40 | steps:
41 | - uses: actions/checkout@master
42 | - uses: actions/setup-python@v2
43 | with:
44 | python-version: ${{ matrix.python }}
45 | architecture: x64
46 | - run: pip install --upgrade pip
47 | - run: pip install poetry
48 | - run: poetry config --local virtualenvs.in-project true
49 | - run: poetry install
50 | - run: pip install -U django==${{ matrix.django }}
51 | - run: PGPASSWORD=postgres psql -c 'create database tests;' -U postgres -h localhost -p 5432
52 | - run: poetry run ./bin/test
53 |
54 | pre-commit:
55 | runs-on: ubuntu-latest
56 | steps:
57 | - uses: actions/checkout@v3
58 | - uses: actions/setup-python@v3
59 | - uses: pre-commit/action@v3.0.0
60 |
61 | publish:
62 | if: startsWith(github.event.ref, 'refs/tags')
63 | name: publish
64 | needs: pytest
65 | runs-on: ubuntu-latest
66 | steps:
67 | - uses: actions/checkout@master
68 | - uses: actions/setup-python@v2
69 | with:
70 | python-version: "3.10"
71 | architecture: x64
72 | - run: pip install --upgrade pip
73 | - run: pip install poetry
74 | - run: poetry build
75 | - uses: pypa/gh-action-pypi-publish@master
76 | with:
77 | password: ${{ secrets.pypi_password_test }}
78 | repository_url: https://test.pypi.org/legacy/
79 | - uses: pypa/gh-action-pypi-publish@master
80 | with:
81 | password: ${{ secrets.pypi_password }}
82 | - uses: actions/create-release@v1
83 | env:
84 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
85 | with:
86 | tag_name: ${{ github.ref }}
87 | release_name: ${{ github.ref }}
88 | body: |
89 | Changes:
90 | - ...
91 | - ...
92 | draft: true
93 | prerelease: false
94 |
--------------------------------------------------------------------------------
/django_reactive/static/css/django_reactive.css:
--------------------------------------------------------------------------------
1 | /* Hide description for now. TODO: find a way do display it in the form. */
2 | .form-row .rjsf .field-description {
3 | display: none;
4 | }
5 |
6 | /* Add bare minimum of styles to look like a normal Django field */
7 | .form-row .rjsf .form-group {
8 | padding: 10px 0 0 20px;
9 | }
10 |
11 | .form-row .rjsf .text-danger {
12 | color: #ba2121;
13 | }
14 |
15 | .form-row .rjsf .error-detail li {
16 | list-style-type: square;
17 | }
18 |
19 | .form-row .rjsf input[type='checkbox'], .form-row .rjsf input[type='radio'] {
20 | margin-right: 5px;
21 | }
22 |
23 | /* Arrays */
24 | .form-row .rjsf .array-item {
25 | padding: 10px;
26 | }
27 |
28 | .form-row .rjsf .array-item-toolbox {
29 | position: relative;
30 | float: right;
31 | bottom: 20px;
32 | }
33 |
34 | .form-row .rjsf button {
35 | background: dimgray;
36 | height: 20px;
37 | line-height: 20px;
38 | text-align: center;
39 | padding: 0 6px;
40 | border: none;
41 | border-radius: 4px;
42 | color: #fff;
43 | cursor: pointer;
44 | margin: 0 0 0 5px;
45 | }
46 |
47 | .form-row .rjsf button:disabled {
48 | background: lightgray;
49 | }
50 |
51 | .form-row .rjsf i.glyphicon {
52 | display: none;
53 | }
54 |
55 | .form-row .rjsf .btn-add::after {
56 | content: 'Add item';
57 | }
58 |
59 | .form-row .rjsf .array-item-move-up::after {
60 | content: '⬆';
61 | }
62 |
63 | .form-row .rjsf .array-item-move-down::after {
64 | content: '⬇';
65 | }
66 |
67 | .form-row .rjsf .array-item-remove::after {
68 | content: '✘';
69 | }
70 |
71 | /* Fields */
72 | .form-row .rjsf .field-array .checkboxes {
73 | display: inline-block;
74 | }
75 |
76 | .form-row .rjsf .field-radio-group label {
77 | display: block;
78 | width: auto;
79 | height: 38px;
80 | line-height: 38px;
81 | }
82 |
83 | .form-row .rjsf .field-radio-group span {
84 | height: 38px;
85 | line-height: 38px;
86 | }
87 |
88 | .form-row .rjsf .radio-inline > span {
89 | display: inline-block;
90 | vertical-align: middle;
91 | }
92 |
93 | .form-row .rjsf .field-radio-group {
94 | display: inline-block;
95 | line-height: 38px;
96 | vertical-align: top;
97 | }
98 |
99 | .form-row .rjsf .field-integer .field-integer {
100 | height: 38px;
101 | line-height: 38px;
102 | }
103 |
104 | .form-row .rjsf .field-integer .form-input .field-range-wrapper .range-view {
105 | margin-left: 5px;
106 | }
107 |
108 | .form-row .rjsf .field-integer .form-input .field-range-wrapper .form-control {
109 | height: 32px;
110 | width: calc(100% - 200px);
111 | }
112 |
113 | .form-row .rjsf .field-boolean .form-input .checkbox {
114 | height: 32px;
115 | }
116 |
117 | .form-row .rjsf .field-array .form-input select {
118 | min-width: 200px;
119 | }
120 |
121 | .form-row .rjsf .list-inline .btn-clear,
122 | .form-row .rjsf .list-inline .btn-now {
123 | margin: 4px;
124 | }
125 |
126 | .form-row .rjsf .list-inline li {
127 | display: inline;
128 | }
129 |
--------------------------------------------------------------------------------
/example/example/settings.py:
--------------------------------------------------------------------------------
1 | import os
2 |
3 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
4 |
5 | SECRET_KEY = ''
6 |
7 | DEBUG = True
8 |
9 | ALLOWED_HOSTS = []
10 |
11 | INSTALLED_APPS = [
12 | 'django.contrib.admin',
13 | 'django.contrib.auth',
14 | 'django.contrib.contenttypes',
15 | 'django.contrib.sessions',
16 | 'django.contrib.messages',
17 | 'django.contrib.staticfiles',
18 | 'tester',
19 | 'todos',
20 | 'django_reactive',
21 | ]
22 |
23 | MIDDLEWARE = [
24 | 'django.middleware.security.SecurityMiddleware',
25 | 'django.contrib.sessions.middleware.SessionMiddleware',
26 | 'django.middleware.common.CommonMiddleware',
27 | 'django.middleware.csrf.CsrfViewMiddleware',
28 | 'django.contrib.auth.middleware.AuthenticationMiddleware',
29 | 'django.contrib.messages.middleware.MessageMiddleware',
30 | 'django.middleware.clickjacking.XFrameOptionsMiddleware',
31 | ]
32 |
33 | ROOT_URLCONF = 'example.urls'
34 |
35 | TEMPLATES = [
36 | {
37 | 'BACKEND': 'django.template.backends.django.DjangoTemplates',
38 | 'DIRS': [os.path.join(BASE_DIR, 'templates')],
39 | 'APP_DIRS': True,
40 | 'OPTIONS': {
41 | 'context_processors': [
42 | 'django.template.context_processors.debug',
43 | 'django.template.context_processors.request',
44 | 'django.contrib.auth.context_processors.auth',
45 | 'django.contrib.messages.context_processors.messages',
46 | ]
47 | },
48 | }
49 | ]
50 |
51 | WSGI_APPLICATION = 'example.wsgi.application'
52 |
53 | # Database
54 | # https://docs.djangoproject.com/en/1.9/ref/settings/#databases
55 |
56 | DATABASES = {
57 | 'default': {
58 | 'ENGINE': 'django.db.backends.postgresql',
59 | 'NAME': 'postgres',
60 | 'USER': 'postgres',
61 | 'PASSWORD': 'postgres',
62 | 'HOST': 'db',
63 | 'PORT': 5432,
64 | }
65 | }
66 |
67 | # Password validation
68 | # https://docs.djangoproject.com/en/1.9/ref/settings/#auth-password-validators
69 |
70 | AUTH_PASSWORD_VALIDATORS = [
71 | {'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'},
72 | {'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'},
73 | {'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'},
74 | {'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'},
75 | ]
76 |
77 | # Internationalization
78 | # https://docs.djangoproject.com/en/1.9/topics/i18n/
79 |
80 | LANGUAGE_CODE = 'en-us'
81 |
82 | TIME_ZONE = 'UTC'
83 |
84 | USE_I18N = True
85 |
86 | USE_L10N = True
87 |
88 | USE_TZ = True
89 |
90 | # Static files (CSS, JavaScript, Images)
91 | # https://docs.djangoproject.com/en/1.9/howto/static-files/
92 |
93 | STATIC_URL = '/static/'
94 | STATICFILES_DIRS = (os.path.join(BASE_DIR, 'static'),)
95 | STATICFILES_FINDERS = (
96 | 'django.contrib.staticfiles.finders.FileSystemFinder',
97 | 'django.contrib.staticfiles.finders.AppDirectoriesFinder',
98 | )
99 |
100 | DEFAULT_AUTO_FIELD = 'django.db.models.AutoField'
101 |
--------------------------------------------------------------------------------
/bin/wait_for_it.py:
--------------------------------------------------------------------------------
1 | #!/bin/usr/env python
2 |
3 | from optparse import OptionParser
4 | import socket
5 | import time
6 | import sys
7 |
8 |
9 | class OptionException(Exception):
10 | def __init__(self, value):
11 | self.value = value
12 |
13 |
14 | class wait_for_app:
15 | def log(self, loginfo):
16 | if self.options.quiet is not False:
17 | print(loginfo)
18 |
19 | def build_log(self, type, app, time=0):
20 | # 1=enable_timeout,2=disable_timeout,3=success_msg,4=unavailable,5=timeout_msg
21 | loginfo = {
22 | 1: '%s: waiting %d seconds for %s' % (sys.argv[0], time, app),
23 | 2: f'{sys.argv[0]}: waiting for {app} without a timeout',
24 | 3: '%s: %s is available after %d seconds' % (sys.argv[0], app, time),
25 | 4: f'{sys.argv[0]}: {app} is unavailable',
26 | 5: '%s: timeout occurred after waiting %d seconds for %s' % (sys.argv[0], time, app),
27 | }.get(type)
28 | return loginfo
29 |
30 | def wait_for(self, host, port, timeout):
31 | self.app = ('%s:%d') % (host, port)
32 | sk = socket.socket()
33 | logmsg = self.build_log(2, self.app, timeout)
34 | if timeout != 0:
35 | logmsg = self.build_log(1, self.app, timeout)
36 | sk.settimeout(timeout)
37 | self.log(logmsg)
38 | start_ts = int(time.time())
39 | sk.connect((host, port))
40 | end_ts = int(time.time())
41 | diff_ts = end_ts - start_ts
42 | logmsg = self.build_log(3, self.app, diff_ts)
43 | self.log(logmsg)
44 |
45 | def get_parser(self):
46 | parser = OptionParser()
47 | parser.add_option('-a', '--address', dest='address', help='Host or IP under test')
48 | parser.add_option('-p', '--port', dest='port', help='TCP port under test')
49 | parser.add_option(
50 | '-t', '--timeout', dest='timeout', default='15', help='Timeout in seconds, zero for no timeout'
51 | )
52 | parser.add_option('-q', '--quiet', dest='quiet', action='store_false', help="Don't output any status messages")
53 | return parser
54 |
55 | def verify_options(self):
56 | if self.options.address is None:
57 | raise OptionException('The address must be set!')
58 | elif self.options.port is None:
59 | raise OptionException('The port must be set!')
60 | elif str(self.options.port).isnumeric() is False:
61 | raise OptionException('The value of port must be number!')
62 |
63 | def start_up(self):
64 | try:
65 | parser = self.get_parser()
66 | self.options, self.args = parser.parse_args()
67 | self.verify_options()
68 | self.wait_for(self.options.address, int(self.options.port), int(self.options.timeout))
69 | except OptionException as err:
70 | print(err)
71 | parser.print_help()
72 | except socket.timeout:
73 | logmsg = self.build_log(5, self.app, int(self.options.timeout))
74 | self.log(logmsg)
75 | except ConnectionRefusedError:
76 | logmsg = self.build_log(4, self.app)
77 | self.log(logmsg)
78 |
79 |
80 | if __name__ == '__main__':
81 | w = wait_for_app()
82 | w.start_up()
83 |
--------------------------------------------------------------------------------
/django_reactive/static/js/django_reactive.js:
--------------------------------------------------------------------------------
1 | function djangoReactiveRenderForm(name, schema, ui_schema, data) {
2 |
3 | var Form = JSONSchemaForm.default, // required by json-react-schema-form
4 | textarea = document.getElementById('id_' + name),
5 | save = document.getElementsByName('_save')[0],
6 | _el = React.createElement;
7 |
8 | function transformErrors(errors) {
9 | save.disabled = errors.length;
10 |
11 | return errors;
12 | }
13 |
14 | function Label(props) {
15 | var id = props.id,
16 | label = props.label,
17 | required = props.required;
18 |
19 | if (!label) {
20 | // See #312: Ensure compatibility with old versions of React.
21 | return _el('div');
22 | }
23 |
24 | return _el("label", {className: "control-label", htmlFor: id},
25 | label,
26 | required && _el("span",
27 | {className: "required"},
28 | '*'
29 | )
30 | );
31 | }
32 |
33 | function TitleField(props) {
34 | var title = props.title, required = props.required;
35 | var legend = required ? title + '*' : title;
36 |
37 | return _el('h2', {}, legend);
38 | }
39 |
40 | function DescriptionField(props) {
41 | var id = props.id, description = props.description;
42 | return _el('p', {id: id, className: 'field-description'}, description);
43 | }
44 |
45 | function FieldTemplate(props) {
46 | var id = props.id,
47 | classNames = props.classNames,
48 | label = props.label,
49 | children = props.children,
50 | errors = props.errors,
51 | help = props.help,
52 | description = props.description,
53 | hidden = props.hidden,
54 | required = props.required,
55 | displayLabel = props.displayLabel;
56 |
57 | if (hidden) {
58 | return children;
59 | }
60 |
61 | return _el(
62 | "div",
63 | {className: classNames},
64 | displayLabel && _el(Label, {label: label, required: required, id: id}),
65 | displayLabel && description ? description : null,
66 | _el(
67 | "div",
68 | {className: "form-input"},
69 | children
70 | ),
71 | errors,
72 | help
73 | );
74 | }
75 |
76 | ReactDOM.render((
77 | _el(Form, {
78 | schema: schema,
79 | formData: data,
80 | uiSchema: ui_schema,
81 | liveValidate: true,
82 | showErrorList: false,
83 | onChange: function (form) {
84 | textarea.value = JSON.stringify(form.formData);
85 | },
86 | transformErrors: transformErrors,
87 | fields: {
88 | TitleField: TitleField,
89 | DescriptionField: DescriptionField,
90 | },
91 | idPrefix: "id_" + name + "_form",
92 | FieldTemplate: FieldTemplate,
93 | }, _el("span") // render an empty span as child in order to avoid displaying submit button
94 | )
95 | ), document.getElementById(name + "_editor"));
96 | }
97 |
--------------------------------------------------------------------------------
/tests/testapp/settings.py:
--------------------------------------------------------------------------------
1 | """
2 | Django settings for project project.
3 | Generated by 'django-admin startproject' using Django 3.0.8.
4 | For more information on this file, see
5 | https://docs.djangoproject.com/en/3.0/topics/settings/
6 | For the full list of settings and their values, see
7 | https://docs.djangoproject.com/en/3.0/ref/settings/
8 | """
9 |
10 | import os
11 | import sys
12 |
13 | PROJECT_ROOT = os.path.dirname(os.path.abspath(os.path.dirname(__file__)))
14 | sys.path.insert(0, PROJECT_ROOT)
15 |
16 | # Build paths inside the project like this: os.path.join(BASE_DIR, ...)
17 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
18 |
19 |
20 | # Quick-start development settings - unsuitable for production
21 | # See https://docs.djangoproject.com/en/3.0/howto/deployment/checklist/
22 |
23 | # SECURITY WARNING: keep the secret key used in production secret!
24 | SECRET_KEY = 'secret'
25 |
26 | # SECURITY WARNING: don't run with debug turned on in production!
27 | DEBUG = True
28 |
29 | ALLOWED_HOSTS = []
30 |
31 | DEFAULT_AUTO_FIELD = 'django.db.models.AutoField'
32 |
33 | # Application definition
34 |
35 | INSTALLED_APPS = [
36 | 'django.contrib.admin',
37 | 'django.contrib.auth',
38 | 'django.contrib.contenttypes',
39 | 'django.contrib.sessions',
40 | 'django.contrib.messages',
41 | 'django.contrib.staticfiles',
42 | 'testapp.apps.TestAppConfig',
43 | ]
44 |
45 | MIDDLEWARE = [
46 | 'django.middleware.security.SecurityMiddleware',
47 | 'django.contrib.sessions.middleware.SessionMiddleware',
48 | 'django.middleware.common.CommonMiddleware',
49 | 'django.middleware.csrf.CsrfViewMiddleware',
50 | 'django.contrib.auth.middleware.AuthenticationMiddleware',
51 | 'django.contrib.messages.middleware.MessageMiddleware',
52 | 'django.middleware.clickjacking.XFrameOptionsMiddleware',
53 | ]
54 |
55 | ROOT_URLCONF = 'project.urls'
56 |
57 | TEMPLATES = [
58 | {
59 | 'BACKEND': 'django.template.backends.django.DjangoTemplates',
60 | 'DIRS': [],
61 | 'APP_DIRS': True,
62 | 'OPTIONS': {
63 | 'context_processors': [
64 | 'django.template.context_processors.debug',
65 | 'django.template.context_processors.request',
66 | 'django.contrib.auth.context_processors.auth',
67 | 'django.contrib.messages.context_processors.messages',
68 | ]
69 | },
70 | }
71 | ]
72 |
73 | WSGI_APPLICATION = 'project.wsgi.application'
74 |
75 |
76 | # Database
77 | # https://docs.djangoproject.com/en/3.0/ref/settings/#databases
78 |
79 | DATABASES = {
80 | 'default': {
81 | 'ENGINE': 'django.db.backends.postgresql',
82 | 'NAME': 'postgres',
83 | 'USER': 'postgres',
84 | 'PASSWORD': 'postgres',
85 | 'HOST': 'localhost',
86 | 'PORT': 5432,
87 | }
88 | }
89 |
90 |
91 | # Password validation
92 | # https://docs.djangoproject.com/en/3.0/ref/settings/#auth-password-validators
93 |
94 | AUTH_PASSWORD_VALIDATORS = [
95 | {'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'},
96 | {'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator'},
97 | {'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'},
98 | {'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'},
99 | ]
100 |
101 |
102 | # Internationalization
103 | # https://docs.djangoproject.com/en/3.0/topics/i18n/
104 |
105 | LANGUAGE_CODE = 'en-us'
106 |
107 | TIME_ZONE = 'UTC'
108 |
109 | USE_I18N = True
110 |
111 | USE_L10N = True
112 |
113 | USE_TZ = True
114 |
115 |
116 | # Static files (CSS, JavaScript, Images)
117 | # https://docs.djangoproject.com/en/3.0/howto/static-files/
118 |
119 | STATIC_URL = '/static/'
120 |
--------------------------------------------------------------------------------
/tests/testapp/models.py:
--------------------------------------------------------------------------------
1 | from django.db import models
2 |
3 | from django_reactive.fields import ReactJSONSchemaField
4 |
5 |
6 | def modify_max_length(schema, ui_schema):
7 | import random
8 |
9 | max_length = random.randint(20, 30)
10 | schema['properties']['test_field']['maxLength'] = max_length
11 | ui_schema['test_field']['ui:help'] = f'Max {max_length}'
12 |
13 |
14 | def modify_help_text(schema, ui_schema, instance=None):
15 | if instance:
16 | if instance.is_some_condition:
17 | ui_schema['test_field']['ui:help'] = 'Condition is set'
18 | else:
19 | ui_schema['test_field']['ui:help'] = 'Condition is unset'
20 |
21 |
22 | class RenderMethodWithObjectSchemaModel(models.Model):
23 | is_some_condition = models.BooleanField(default=True)
24 | json_field = ReactJSONSchemaField(
25 | schema={
26 | 'title': 'TestSchema',
27 | 'type': 'object',
28 | 'required': ['test_field'],
29 | 'properties': {
30 | 'test_field': {
31 | 'type': 'string',
32 | 'maxLength': 10,
33 | 'minLength': 5,
34 | },
35 | 'another_test_field': {
36 | 'type': 'string',
37 | },
38 | },
39 | 'additionalProperties': False,
40 | },
41 | ui_schema={
42 | 'test_field': {'ui:help': 'Max 10'},
43 | },
44 | on_render=modify_help_text,
45 | )
46 |
47 |
48 | class RenderMethodSchemaModel(models.Model):
49 | json_field = ReactJSONSchemaField(
50 | schema={
51 | 'title': 'TestSchema',
52 | 'type': 'object',
53 | 'required': ['test_field'],
54 | 'properties': {
55 | 'test_field': {
56 | 'type': 'string',
57 | 'maxLength': 10,
58 | 'minLength': 5,
59 | },
60 | 'another_test_field': {
61 | 'type': 'string',
62 | },
63 | },
64 | 'additionalProperties': False,
65 | },
66 | ui_schema={
67 | 'test_field': {'ui:help': 'Max 10'},
68 | },
69 | on_render=modify_max_length,
70 | )
71 |
72 |
73 | class SchemaModel(models.Model):
74 | json_field = ReactJSONSchemaField(
75 | schema={
76 | 'title': 'TestSchema',
77 | 'type': 'object',
78 | 'required': ['test_field'],
79 | 'properties': {
80 | 'test_field': {
81 | 'type': 'string',
82 | 'maxLength': 10,
83 | 'minLength': 5,
84 | },
85 | 'another_test_field': {
86 | 'type': 'string',
87 | },
88 | },
89 | 'additionalProperties': False,
90 | }
91 | )
92 |
93 |
94 | class OptionalSchemaModel(models.Model):
95 | json_field = ReactJSONSchemaField(
96 | schema={
97 | 'type': 'object',
98 | 'required': ['test_field'],
99 | 'properties': {'test_field': {'type': 'string'}},
100 | },
101 | blank=True,
102 | )
103 |
104 |
105 | class ExtraMediaSchemaModel(models.Model):
106 | json_field = ReactJSONSchemaField(
107 | schema={
108 | 'type': 'object',
109 | 'required': ['test_field'],
110 | 'properties': {'test_field': {'type': 'string'}},
111 | },
112 | blank=True,
113 | extra_css=['path/to/my/css/file.css'],
114 | extra_js=['path/to/my/js/file.js'],
115 | )
116 |
117 |
118 | class InvalidSchemaModel(models.Model):
119 | invalid_json_schema_field = ReactJSONSchemaField(
120 | schema={
121 | 'type': 'object',
122 | 'properties': {'test_field': {'type': 'incorrect'}},
123 | }
124 | )
125 |
--------------------------------------------------------------------------------
/tests/test_fields.py:
--------------------------------------------------------------------------------
1 | import pytest
2 |
3 | from django.core.exceptions import ValidationError
4 |
5 | from testapp.models import (
6 | SchemaModel,
7 | ExtraMediaSchemaModel,
8 | RenderMethodSchemaModel,
9 | RenderMethodWithObjectSchemaModel,
10 | InvalidSchemaModel,
11 | )
12 |
13 |
14 | @pytest.mark.django_db
15 | @pytest.mark.parametrize(
16 | 'field_name,field_value',
17 | [
18 | ('test_field', 1),
19 | ('test_field', '3ch'),
20 | ('test_field', '12characters'),
21 | ('unknown_field', True),
22 | ],
23 | )
24 | def test_validation(field_name, field_value):
25 | obj = SchemaModel(json_field={'test_field': '6chars'})
26 | obj.full_clean()
27 |
28 | obj.json_field[field_name] = field_value
29 | with pytest.raises(ValidationError) as excinfo:
30 | obj.full_clean()
31 |
32 | assert str(excinfo.value) == "{'json_field': ['This field has errors.']}"
33 |
34 |
35 | @pytest.mark.django_db
36 | @pytest.mark.parametrize('blank_value', [None, {}, []])
37 | def test_blank_values(blank_value, get_schema_model):
38 | model = get_schema_model()
39 | obj = model(json_field=blank_value)
40 | with pytest.raises(ValidationError) as excinfo:
41 | obj.full_clean()
42 |
43 | assert str(excinfo.value) in (
44 | "{'json_field': ['This field cannot be blank.']}",
45 | "{'json_field': ['This field cannot be null.']}",
46 | )
47 |
48 | model = get_schema_model(optional=True)
49 | obj = model(json_field=blank_value)
50 |
51 |
52 | @pytest.mark.django_db
53 | def test_extra_form_media():
54 | obj = ExtraMediaSchemaModel(json_field={'test_field': '6chars'})
55 | widget = obj._meta.get_field('json_field').formfield().widget
56 | assert widget.media._css == {'all': ['css/django_reactive.css', 'path/to/my/css/file.css']}
57 | assert widget.media._js == [
58 | 'dist/react.js',
59 | 'dist/react-dom.js',
60 | 'dist/react-jsonschema-form.js',
61 | 'js/django_reactive.js',
62 | 'path/to/my/js/file.js',
63 | ]
64 |
65 |
66 | @pytest.mark.django_db
67 | def test_on_render():
68 | obj = RenderMethodSchemaModel(json_field={'test_field': 'testing'})
69 | widget = obj._meta.get_field('json_field').formfield().widget
70 | initial_max_length = 10
71 | initial_schema = {
72 | 'title': 'TestSchema',
73 | 'type': 'object',
74 | 'required': ['test_field'],
75 | 'properties': {
76 | 'test_field': {
77 | 'type': 'string',
78 | 'maxLength': initial_max_length,
79 | 'minLength': 5,
80 | },
81 | 'another_test_field': {
82 | 'type': 'string',
83 | },
84 | },
85 | 'additionalProperties': False,
86 | }
87 | initial_ui_schema = {
88 | 'test_field': {'ui:help': 'Max 10'},
89 | }
90 | assert widget.schema == initial_schema
91 | assert widget.ui_schema == initial_ui_schema
92 |
93 | widget.mutate()
94 |
95 | assert widget.schema['properties']['test_field']['maxLength'] > initial_max_length
96 | assert int(widget.ui_schema['test_field']['ui:help'].split()[1]) > initial_max_length
97 |
98 |
99 | @pytest.mark.django_db
100 | @pytest.mark.parametrize('condition', [True, False])
101 | def test_on_render_object(condition):
102 | obj = RenderMethodWithObjectSchemaModel.objects.create(
103 | is_some_condition=condition, json_field={'test_field': 'testing'}
104 | )
105 | widget = obj._meta.get_field('json_field').formfield().widget
106 | widget.on_render_object = obj
107 | widget.mutate()
108 |
109 | help_text = 'Condition is set' if condition else 'Condition is unset'
110 | assert widget.ui_schema == {'test_field': {'ui:help': help_text}}
111 |
112 |
113 | def test_schema_validation():
114 | obj = InvalidSchemaModel(invalid_json_schema_field={'test_field': '6chars'})
115 | field = obj._meta.get_field('invalid_json_schema_field')
116 | errors = field.check()
117 | assert len(errors) == 1
118 | assert (
119 | errors[0].msg == "JSON schema is not valid: properties.test_field.type: 'incorrect' is not valid under "
120 | 'any of the given schemas'
121 | )
122 |
--------------------------------------------------------------------------------
/example/tester/migrations/0001_initial.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 3.2.16 on 2022-12-29 05:02
2 |
3 | from django.db import migrations, models
4 | import django_reactive.fields
5 |
6 |
7 | class Migration(migrations.Migration):
8 |
9 | initial = True
10 |
11 | dependencies = []
12 |
13 | operations = [
14 | migrations.CreateModel(
15 | name='AlternativesExample',
16 | fields=[
17 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
18 | ('alternatives', django_reactive.fields.ReactJSONSchemaField(default=dict, help_text='Alternatives')),
19 | ],
20 | ),
21 | migrations.CreateModel(
22 | name='ArraysExampleModel',
23 | fields=[
24 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
25 | ('arrays', django_reactive.fields.ReactJSONSchemaField(default=dict, help_text='Arrays')),
26 | ],
27 | ),
28 | migrations.CreateModel(
29 | name='BasicExampleModel',
30 | fields=[
31 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
32 | ('basic', django_reactive.fields.ReactJSONSchemaField(default=dict, help_text='Basic example')),
33 | ],
34 | ),
35 | migrations.CreateModel(
36 | name='DateAndTimeExampleModel',
37 | fields=[
38 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
39 | (
40 | 'date_and_time',
41 | django_reactive.fields.ReactJSONSchemaField(default=dict, help_text='Date and time'),
42 | ),
43 | ],
44 | ),
45 | migrations.CreateModel(
46 | name='ErrorsExampleModel',
47 | fields=[
48 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
49 | ('errors', django_reactive.fields.ReactJSONSchemaField(default=dict, help_text='Errors')),
50 | ],
51 | ),
52 | migrations.CreateModel(
53 | name='FileTestModel',
54 | fields=[
55 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
56 | ('file', django_reactive.fields.ReactJSONSchemaField(default=dict, help_text='Files')),
57 | ],
58 | ),
59 | migrations.CreateModel(
60 | name='LargeExampleModel',
61 | fields=[
62 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
63 | ('large', django_reactive.fields.ReactJSONSchemaField(default=dict, help_text='Large')),
64 | ],
65 | ),
66 | migrations.CreateModel(
67 | name='MultipleExampleModel',
68 | fields=[
69 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
70 | ('first', django_reactive.fields.ReactJSONSchemaField(default=dict, help_text='First field')),
71 | ('second', django_reactive.fields.ReactJSONSchemaField(default=dict, help_text='Second field')),
72 | ],
73 | ),
74 | migrations.CreateModel(
75 | name='NestedExampleModel',
76 | fields=[
77 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
78 | ('nested', django_reactive.fields.ReactJSONSchemaField(default=dict, help_text='Nested')),
79 | ],
80 | ),
81 | migrations.CreateModel(
82 | name='NumbersExampleModel',
83 | fields=[
84 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
85 | ('numbers', django_reactive.fields.ReactJSONSchemaField(default=dict, help_text='Numbers')),
86 | ],
87 | ),
88 | migrations.CreateModel(
89 | name='OrderingExampleModel',
90 | fields=[
91 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
92 | ('ordering', django_reactive.fields.ReactJSONSchemaField(default=dict, help_text='Ordering')),
93 | ],
94 | ),
95 | migrations.CreateModel(
96 | name='PropertyDependenciesExample',
97 | fields=[
98 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
99 | (
100 | 'property_dependencies',
101 | django_reactive.fields.ReactJSONSchemaField(default=dict, help_text='Property dependencies'),
102 | ),
103 | ],
104 | ),
105 | migrations.CreateModel(
106 | name='ReferencesExampleModel',
107 | fields=[
108 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
109 | ('references', django_reactive.fields.ReactJSONSchemaField(default=dict)),
110 | ],
111 | ),
112 | migrations.CreateModel(
113 | name='SchemaDependenciesExampleModel',
114 | fields=[
115 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
116 | (
117 | 'schema_dependencies',
118 | django_reactive.fields.ReactJSONSchemaField(default=dict, help_text='Schema dependencies'),
119 | ),
120 | ],
121 | ),
122 | migrations.CreateModel(
123 | name='ValidationExampleModel',
124 | fields=[
125 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
126 | ('validation', django_reactive.fields.ReactJSONSchemaField(default=dict, help_text='Validation')),
127 | ],
128 | ),
129 | migrations.CreateModel(
130 | name='WidgetExampleModel',
131 | fields=[
132 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
133 | ('widgets', django_reactive.fields.ReactJSONSchemaField(default=dict, help_text='Widgets')),
134 | ],
135 | ),
136 | ]
137 |
--------------------------------------------------------------------------------
/README.rst:
--------------------------------------------------------------------------------
1 | =============================
2 | django-reactive
3 | =============================
4 |
5 | .. image:: https://badge.fury.io/py/django-reactive.svg
6 | :target: https://badge.fury.io/py/django-reactive
7 |
8 | .. image:: https://github.com/tyomo4ka/django-reactive/workflows/CI/badge.svg?branch=master
9 | :target: https://github.com/tyomo4ka/django-reactive/actions
10 |
11 | .. image:: https://codecov.io/gh/tyomo4ka/django-reactive/branch/master/graph/badge.svg
12 | :target: https://codecov.io/gh/tyomo4ka/django-reactive
13 |
14 | django-reactive integrates `react-jsonschema-form `_ (RJSF)
15 | in Django projects.
16 |
17 | Motivation
18 | ----------
19 |
20 | `JSON types `_ in Postgres allow combining both relational
21 | and non-relational approaches to storing data. That can lead to a simpler database design in the most cases.
22 |
23 | Django provides ORM support for JSON types in Postgres and other databases via the
24 | `JSONField model field `_. Also the
25 | `JSONField form field `_ allows basic support of JSON in forms.
26 | Django ORM even `allows querying `_ against the data stored
27 | inside the JSON structures. Moreover, it is possible to improve performance of these queries using
28 | `GIN indexes `_ with **jsonb** types
29 | `in Django `_, which
30 | makes opens up a wide range of possibilities for simplifying application design, such as polymorphic collections, storing complex hierarchies in JSON, lists of related entities, etc.
31 |
32 | However, the main limitation of JSONField in Django is the lack of good support of UI for JSON structures as defining JSON objects
33 | inside the textarea inputs is not practical for most use cases. django-reactive tries to address this problem by offering an
34 | integration between JSONField and the awesome `react-jsonschema-form `_
35 | (RJSF) JavaScript library.
36 |
37 | django-reactive also uses Python `jsonschema ` library for backend validation. Such integration
38 | can significantly reduce the amount of work needed for building custom forms for JSONField types.
39 |
40 | In most cases it only requires a JSON schema configuration for such field and optionally a UI schema
41 | to modify some representation parameters.
42 |
43 | A basic example of this is demonstrated below:
44 |
45 | .. code-block:: python
46 |
47 | from django.db import models
48 |
49 | from django_reactive.fields import ReactJSONSchemaField
50 |
51 |
52 | class Registration(models.Model):
53 | basic = ReactJSONSchemaField(
54 | help_text="Registration form",
55 | schema={
56 | "title": "Register now!",
57 | "description": "Fill out the form to register.",
58 | "type": "object",
59 | "required": [
60 | "firstName",
61 | "lastName"
62 | ],
63 | "properties": {
64 | "firstName": {
65 | "type": "string",
66 | "title": "First name"
67 | },
68 | "lastName": {
69 | "type": "string",
70 | "title": "Last name"
71 | },
72 | "age": {
73 | "type": "integer",
74 | "title": "Age"
75 | },
76 | "bio": {
77 | "type": "string",
78 | "title": "Bio"
79 | },
80 | "password": {
81 | "type": "string",
82 | "title": "Password",
83 | "minLength": 3
84 | },
85 | "telephone": {
86 | "type": "string",
87 | "title": "Telephone",
88 | "minLength": 10
89 | }
90 | }
91 | },
92 | ui_schema={
93 | "firstName": {
94 | "ui:autofocus": True,
95 | "ui:emptyValue": ""
96 | },
97 | "age": {
98 | "ui:widget": "updown",
99 | "ui:title": "Age of person",
100 | "ui:description": "(earthian year)"
101 | },
102 | "bio": {
103 | "ui:widget": "textarea"
104 | },
105 | "password": {
106 | "ui:widget": "password",
107 | "ui:help": "Hint: Make it strong!"
108 | },
109 | "date": {
110 | "ui:widget": "alt-datetime"
111 | },
112 | "telephone": {
113 | "ui:options": {
114 | "inputType": "tel"
115 | }
116 | }
117 | },
118 | )
119 |
120 | It will generate a form like this:
121 |
122 | .. image:: images/simple.png
123 |
124 | Quick start
125 | -----------
126 |
127 | Install django-reactive::
128 |
129 | pip install django-reactive
130 |
131 | Add it to your `INSTALLED_APPS`:
132 |
133 | .. code-block:: python
134 |
135 | INSTALLED_APPS = (
136 | ...
137 | 'django_reactive',
138 | ...
139 | )
140 |
141 | Running the example
142 | -------------------
143 |
144 | Build the docker image for the Django application in `example/`:
145 |
146 | * Run `docker compose up -d`
147 |
148 | This will automatically create the database, run migrations, import the default superuser, and run the Django development server on `http://127.0.0.1:8000`.
149 |
150 | Django admin example
151 | ====================
152 |
153 | * Open http://127.0.0.1:8000/admin/ and login with username `admin` and password `test`.
154 | * Go to the "Test models" admin section to see the example forms.
155 |
156 | Normal Django view example
157 | ==========================
158 |
159 | * Open http://127.0.0.1:8000/create/ to create a basic form example.
160 |
161 | You will be redirected to the detail view of the created object after the form saves.
162 |
163 | Usage outside of Django admin
164 | -----------------------------
165 |
166 | To use outside of the Django admin, the following are required in the template:
167 |
168 | * A call to the form media property using {{ form.media }}
169 |
170 | * An HTML submit input with `name="_save"`.
171 |
172 | .. code-block:: html
173 |
174 |
175 |
176 |
177 | Homepage
178 |
179 |
180 | {{ form.media }}
181 |
186 |
187 |
188 |
189 | Optional configuration
190 | ----------------------
191 |
192 | Schema fields accept the following parameters for additional configuration:
193 |
194 | * ``extra_css``: Include additional static CSS files available in the widget.
195 | * ``extra_js``: Include additional static JavaScript files available in the widget.
196 | * ``on_render``: A python method to make dynamic schema modifications at render-time.
197 |
198 | Extra CSS and JSS files should be accessible using Django's staticfiles configurations and passed as a list of strings.
199 |
200 | Render methods require both ``schema`` and ``ui_schema`` as arguments to allow dynamic schema modification when rendering the widget. An optional ``instance`` keyword argument may also be used for referencing an object instance (must be set on the widget in the form). This method does not return anything.
201 |
202 | Example usage
203 | =============
204 |
205 | The example below demonstrates a use-case in which the options available for a particular field may be dynamic and unavailable in the initial schema definition. These would be populated at render-time and made available in the form UI.
206 |
207 | .. code-block:: python
208 |
209 | def set_task_types(schema, ui_schema):
210 | from todos.models import TaskType
211 |
212 | task_types = list(TaskType.objects.all().values_list("name", flat=True))
213 | schema["definitions"]["Task"]["properties"]["task_type"]["enum"] = task_types
214 | ui_schema["task_lists"]["items"]["tasks"]["items"]["task_type"][
215 | "ui:help"
216 | ] = f"Select 1 of {len(task_types)} task types"
217 |
218 | class Todo(models.Model):
219 | """
220 | A collection of task lists for a todo.
221 | """
222 |
223 | name = models.CharField(max_length=255)
224 | task_lists = ReactJSONSchemaField(
225 | help_text="Task lists",
226 | schema=TODO_SCHEMA,
227 | ui_schema=TODO_UI_SCHEMA,
228 | on_render=set_task_types,
229 | extra_css=["css/extra.css"],
230 | extra_js=["js/extra.js"],
231 | )
232 |
233 | Schema model form class
234 | =======================
235 |
236 | The form class ``ReactJSONSchemaModelForm`` (subclassed from Django's ``ModelForm``) can be used to provide the model form's instance object to the schema field widgets:
237 |
238 | .. code-block:: python
239 |
240 | from django_reactive.forms import ReactJSONSchemaModelForm
241 | class MyModelForm(ReactJSONSchemaModelForm):
242 | ...
243 |
244 | This allows the ``on_render`` method set for a schema field to reference the instance like this:
245 |
246 | .. code-block:: python
247 |
248 | def update_the_schema(schema, ui_schema, instance=None):
249 | if instance and instance.some_condition:
250 | ui_schema["my_schema_prop"]["ui:help"] = "Some extra help text"
251 |
252 | Features
253 | --------
254 |
255 | * React, RJSF and other JS assets are bundled with the package.
256 | * Integration with default Django admin theme.
257 | * Backend and frontend validation.
258 | * Configurable static media assets.
259 | * Dynamic schema mutation in widget renders.
260 |
261 | Limitations
262 | -----------
263 |
264 | * `Additional properties `_ ( a feature of RJSF) is not supported.
265 |
266 | To implement this behavior you can define an array schema with one property serving as a key of the object and do
267 | transformation in the Django form.
268 |
269 | * An outdated version (1.8) of RJSF is used in this project. Not all features of RJSF 1.8 are compatible with JSON Schema 4.0. Please, refer to the documentation if any issues.
270 |
271 | Future development
272 | ------------------
273 |
274 | * At the moment there is no plans to add new features or support a newer version of RJSF.
275 | * Probably, it is a good idea to replace RJSF with a more Django-friendly solution. It would require significant development effort though, that's why the idea is put on back burner at the moment.
276 |
--------------------------------------------------------------------------------
/django_reactive/static/dist/react.js:
--------------------------------------------------------------------------------
1 | /** @license React v16.14.0
2 | * react.production.min.js
3 | *
4 | * Copyright (c) Facebook, Inc. and its affiliates.
5 | *
6 | * This source code is licensed under the MIT license found in the
7 | * LICENSE file in the root directory of this source tree.
8 | */
9 | 'use strict';(function(d,r){"object"===typeof exports&&"undefined"!==typeof module?r(exports):"function"===typeof define&&define.amd?define(["exports"],r):(d=d||self,r(d.React={}))})(this,function(d){function r(a){for(var b="https://reactjs.org/docs/error-decoder.html?invariant="+a,c=1;cC.length&&C.push(a)}function O(a,b,c,g){var e=typeof a;if("undefined"===e||"boolean"===e)a=null;var d=!1;if(null===a)d=!0;else switch(e){case "string":case "number":d=!0;break;case "object":switch(a.$$typeof){case x:case xa:d=!0}}if(d)return c(g,a,""===b?"."+P(a,0):b),1;d=0;b=""===b?".":b+":";if(Array.isArray(a))for(var f=0;f>>1,e=a[g];if(void 0!==
15 | e&&0D(f,c))void 0!==k&&0>D(k,f)?(a[g]=k,a[h]=c,g=h):(a[g]=f,a[d]=c,g=d);else if(void 0!==k&&0>D(k,c))a[g]=k,a[h]=c,g=h;else break a}}return b}return null}function D(a,b){var c=a.sortIndex-b.sortIndex;return 0!==c?c:a.id-b.id}function F(a){for(var b=n(u);null!==
16 | b;){if(null===b.callback)E(u);else if(b.startTime<=a)E(u),b.sortIndex=b.expirationTime,S(p,b);else break;b=n(u)}}function T(a){y=!1;F(a);if(!v)if(null!==n(p))v=!0,z(U);else{var b=n(u);null!==b&&G(T,b.startTime-a)}}function U(a,b){v=!1;y&&(y=!1,V());H=!0;var c=m;try{F(b);for(l=n(p);null!==l&&(!(l.expirationTime>b)||a&&!W());){var g=l.callback;if(null!==g){l.callback=null;m=l.priorityLevel;var e=g(l.expirationTime<=b);b=q();"function"===typeof e?l.callback=e:l===n(p)&&E(p);F(b)}else E(p);l=n(p)}if(null!==
17 | l)var d=!0;else{var f=n(u);null!==f&&G(T,f.startTime-b);d=!1}return d}finally{l=null,m=c,H=!1}}function oa(a){switch(a){case 1:return-1;case 2:return 250;case 5:return 1073741823;case 4:return 1E4;default:return 5E3}}var f="function"===typeof Symbol&&Symbol.for,x=f?Symbol.for("react.element"):60103,xa=f?Symbol.for("react.portal"):60106,Aa=f?Symbol.for("react.fragment"):60107,Ba=f?Symbol.for("react.strict_mode"):60108,Ca=f?Symbol.for("react.profiler"):60114,Da=f?Symbol.for("react.provider"):60109,
18 | Ea=f?Symbol.for("react.context"):60110,Fa=f?Symbol.for("react.forward_ref"):60112,Ga=f?Symbol.for("react.suspense"):60113,Ha=f?Symbol.for("react.memo"):60115,Ia=f?Symbol.for("react.lazy"):60116,la="function"===typeof Symbol&&Symbol.iterator,pa=Object.getOwnPropertySymbols,Ja=Object.prototype.hasOwnProperty,Ka=Object.prototype.propertyIsEnumerable,I=function(){try{if(!Object.assign)return!1;var a=new String("abc");a[5]="de";if("5"===Object.getOwnPropertyNames(a)[0])return!1;var b={};for(a=0;10>a;a++)b["_"+
19 | String.fromCharCode(a)]=a;if("0123456789"!==Object.getOwnPropertyNames(b).map(function(a){return b[a]}).join(""))return!1;var c={};"abcdefghijklmnopqrst".split("").forEach(function(a){c[a]=a});return"abcdefghijklmnopqrst"!==Object.keys(Object.assign({},c)).join("")?!1:!0}catch(g){return!1}}()?Object.assign:function(a,b){if(null===a||void 0===a)throw new TypeError("Object.assign cannot be called with null or undefined");var c=Object(a);for(var g,e=1;e=ua};f=function(){};X=function(a){0>a||125d?(a.sortIndex=e,S(u,a),null===n(p)&&a===n(u)&&(y?V():y=!0,G(T,e-d))):(a.sortIndex=c,S(p,a),v||H||(v=!0,z(U)));return a},unstable_cancelCallback:function(a){a.callback=null},unstable_wrapCallback:function(a){var b=m;return function(){var c=m;m=b;try{return a.apply(this,arguments)}finally{m=c}}},unstable_getCurrentPriorityLevel:function(){return m},
27 | unstable_shouldYield:function(){var a=q();F(a);var b=n(p);return b!==l&&null!==l&&null!==b&&null!==b.callback&&b.startTime<=a&&b.expirationTime