├── 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 |
9 | {% csrf_token %} 10 | {{ form }} 11 | 12 |
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 |
182 | {% csrf_token %} 183 | {{ form }} 184 | 185 |
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