├── tests ├── __init__.py ├── test_editor.py ├── test_renderers.py └── test_api.py ├── example ├── __init__.py ├── albums │ ├── __init__.py │ ├── migrations │ │ ├── __init__.py │ │ └── 0001_initial.py │ ├── apps.py │ ├── admin.py │ ├── models.py │ ├── templates │ │ └── albums │ │ │ ├── base.html │ │ │ └── albums.html │ ├── views.py │ ├── serializers.py │ └── fixtures │ │ └── test_data.json ├── example │ ├── __init__.py │ ├── wsgi.py │ ├── urls.py │ └── settings.py ├── db.sqlite3 ├── manage.py └── static │ ├── js │ ├── editor.foundation.min.js │ ├── editor.semanticui.min.js │ ├── editor.jqueryui.min.js │ ├── editor.bootstrap.min.js │ ├── editor.jqueryui.js │ ├── editor.bootstrap4.min.js │ ├── editor.foundation.js │ ├── editor.semanticui.js │ ├── editor.bootstrap.js │ └── editor.bootstrap4.js │ └── css │ ├── editor.bootstrap4.min.css │ └── editor.semanticui.css ├── rest_framework_datatables_editor ├── __init__.py ├── viewsets.py ├── pagination.py ├── renderers.py └── filters.py ├── requirements-docs.txt ├── requirements.txt ├── .coveragerc ├── .pycharmrc ├── setup.cfg ├── requirements-dev.txt ├── MANIFEST.in ├── docs ├── _static │ └── screenshot.jpg ├── Makefile ├── make.bat ├── example-app.rst ├── index.rst ├── quickstart.rst ├── conf.py ├── introduction.rst ├── changelog.rst └── tutorial.rst ├── .gitignore ├── .github └── workflows │ ├── codecov.yml │ ├── pythonpublish.yml │ └── build.yml ├── LICENSE ├── setup.py └── README.rst /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/albums/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/example/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /example/albums/migrations/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /rest_framework_datatables_editor/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements-docs.txt: -------------------------------------------------------------------------------- 1 | Sphinx==3.0.3 2 | sphinx-rtd-theme==0.4.3 -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | django>=1.9 2 | djangorestframework>=3.9.1 3 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source=rest_framework_datatables_editor 3 | -------------------------------------------------------------------------------- /.pycharmrc: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | source ~/.bashrc 3 | source ./env/bin/activate -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [wheel] 2 | universal = 1 3 | [flake8] 4 | per-file-ignores = tests/test_api.py: E501 -------------------------------------------------------------------------------- /requirements-dev.txt: -------------------------------------------------------------------------------- 1 | coverage==4.5.1 2 | Django>=2.0 3 | djangorestframework>=3.9.1 4 | pycodestyle>=2.3 5 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst LICENSE 2 | recursive-exclude * __pycache__ 3 | recursive-exclude * *.py[co] 4 | -------------------------------------------------------------------------------- /example/db.sqlite3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vertliba/django-rest-framework-datatables-editor/HEAD/example/db.sqlite3 -------------------------------------------------------------------------------- /example/albums/apps.py: -------------------------------------------------------------------------------- 1 | from django.apps import AppConfig 2 | 3 | 4 | class AlbumsConfig(AppConfig): 5 | name = 'albums' 6 | -------------------------------------------------------------------------------- /docs/_static/screenshot.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vertliba/django-rest-framework-datatables-editor/HEAD/docs/_static/screenshot.jpg -------------------------------------------------------------------------------- /example/albums/admin.py: -------------------------------------------------------------------------------- 1 | from django.contrib import admin 2 | 3 | from albums.models import Genre, Artist, Album 4 | 5 | 6 | admin.site.register(Genre) 7 | admin.site.register(Artist) 8 | admin.site.register(Album) 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | *.swp 3 | *.swo 4 | *.old 5 | __pycache__ 6 | .tox/ 7 | .coverage 8 | coverage/ 9 | htmlcov/ 10 | build/ 11 | dist/ 12 | *.egg-info/ 13 | MANIFEST 14 | docs/_build 15 | /.idea 16 | /venv* 17 | /env* -------------------------------------------------------------------------------- /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 | try: 8 | from django.core.management import execute_from_command_line 9 | except ImportError: 10 | raise 11 | execute_from_command_line(sys.argv) 12 | -------------------------------------------------------------------------------- /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/2.0/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 | -------------------------------------------------------------------------------- /example/example/urls.py: -------------------------------------------------------------------------------- 1 | from django.conf.urls import url, include 2 | from django.contrib import admin 3 | from rest_framework import routers 4 | 5 | from albums import views 6 | 7 | # router = DatatablesDefaultRouter() 8 | router = routers.DefaultRouter() 9 | router.register(r'albums', views.AlbumViewSet) 10 | router.register(r'artists', views.ArtistViewSet) 11 | 12 | 13 | urlpatterns = [ 14 | url('^admin/', admin.site.urls), 15 | url('^api/', include(router.urls)), 16 | url('^$', views.index, name='albums') 17 | ] 18 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | SOURCEDIR = . 8 | BUILDDIR = _build 9 | 10 | # Put it first so that "make" without argument is like "make help". 11 | help: 12 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 13 | 14 | .PHONY: help Makefile 15 | 16 | # Catch-all target: route all unknown targets to Sphinx using the new 17 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 18 | %: Makefile 19 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) -------------------------------------------------------------------------------- /.github/workflows/codecov.yml: -------------------------------------------------------------------------------- 1 | name: codecov 2 | on: [push, pull_request] 3 | jobs: 4 | run: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@master 8 | - name: Setup Python 9 | uses: actions/setup-python@master 10 | with: 11 | python-version: 3.8 12 | - name: Generate coverage report 13 | run: | 14 | pip install -r requirements.txt 15 | pip install coverage 16 | pip install -q -e . 17 | coverage run example/manage.py test tests -v 1 --noinput 18 | coverage xml 19 | - name: Upload coverage to Codecov 20 | uses: codecov/codecov-action@v1 21 | with: 22 | token: ${{ secrets.CODECOV_TOKEN }} 23 | file: ./coverage.xml 24 | flags: unittests 25 | name: codecov-umbrella 26 | fail_ci_if_error: true -------------------------------------------------------------------------------- /.github/workflows/pythonpublish.yml: -------------------------------------------------------------------------------- 1 | # This workflow will upload a Python Package using Twine when a release is created 2 | 3 | name: Upload Python Package 4 | 5 | on: 6 | release: 7 | types: [published] 8 | 9 | jobs: 10 | deploy: 11 | 12 | runs-on: ubuntu-latest 13 | 14 | steps: 15 | - uses: actions/checkout@v2 16 | - name: Set up Python 17 | uses: actions/setup-python@v1 18 | with: 19 | python-version: '3.x' 20 | - name: Install dependencies 21 | run: | 22 | python -m pip install --upgrade pip 23 | pip install setuptools wheel twine 24 | - name: Build and publish 25 | env: 26 | TWINE_USERNAME: '__token__' 27 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 28 | run: | 29 | python setup.py sdist bdist_wheel 30 | twine upload dist/* 31 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=. 11 | set BUILDDIR=_build 12 | 13 | if "%1" == "" goto help 14 | 15 | %SPHINXBUILD% >NUL 2>NUL 16 | if errorlevel 9009 ( 17 | echo. 18 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 19 | echo.installed, then set the SPHINXBUILD environment variable to point 20 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 21 | echo.may add the Sphinx directory to PATH. 22 | echo. 23 | echo.If you don't have Sphinx installed, grab it from 24 | echo.http://sphinx-doc.org/ 25 | exit /b 1 26 | ) 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /docs/example-app.rst: -------------------------------------------------------------------------------- 1 | The example app 2 | =============== 3 | 4 | django-rest-framework-datatables comes with an example application (the Rolling Stone top 500 albums of all time). 5 | It's a great start for understanding how things work, you can play with several options of Datatables, modify the python code (serializers, views) and test a lot of possibilities. 6 | 7 | We encourage you to give it a try with a few commandline calls: 8 | 9 | .. code:: bash 10 | 11 | $ git clone https://github.com/VVyacheslav/django-rest-framework-datatables-editor.git 12 | $ cd django-rest-framework-datatables-editor 13 | $ pip install -r requirements-dev.txt 14 | 15 | You need to download `Datatables Editor `_, the JS+CSS version, and unpack the downloaded archive in 16 | ``django-rest-framework-datatables-editor/static`` 17 | 18 | .. code:: bash 19 | 20 | $ python example/manage.py runserver 21 | $ firefox http://127.0.0.1:8000 22 | 23 | A screenshot of the example app: 24 | 25 | .. image:: _static/screenshot.jpg 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2018 David Jean Louis. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /example/albums/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | 4 | class Genre(models.Model): 5 | name = models.CharField('Name', max_length=80) 6 | 7 | class Meta: 8 | verbose_name = 'Genre' 9 | verbose_name_plural = 'Genres' 10 | ordering = ['name'] 11 | 12 | def __str__(self): 13 | return self.name 14 | 15 | 16 | class Artist(models.Model): 17 | name = models.CharField('Name', max_length=80) 18 | 19 | class Meta: 20 | verbose_name = 'Artist' 21 | verbose_name_plural = 'Artists' 22 | ordering = ['name'] 23 | 24 | def __str__(self): 25 | return self.name 26 | 27 | 28 | class Album(models.Model): 29 | name = models.CharField('Name', max_length=80) 30 | rank = models.PositiveIntegerField('Rank') 31 | year = models.PositiveIntegerField('Year') 32 | artist = models.ForeignKey( 33 | Artist, 34 | models.CASCADE, 35 | verbose_name='Artist', 36 | related_name='albums' 37 | ) 38 | genres = models.ManyToManyField( 39 | Genre, 40 | verbose_name='Genres', 41 | related_name='albums' 42 | ) 43 | 44 | class Meta: 45 | verbose_name = 'Album' 46 | verbose_name_plural = 'Albums' 47 | ordering = ['name'] 48 | 49 | def __str__(self): 50 | return self.name 51 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | .. django-rest-framework-datatables-editor documentation master file, created by 2 | sphinx-quickstart on Sat Apr 27 14:24:31 2019. 3 | You can adapt this file completely to your liking, but it should at least 4 | contain the root `toctree` directive. 5 | 6 | Welcome to django-rest-framework-datatables-editor's documentation! 7 | =================================================================== 8 | 9 | Seamless integration between Django REST framework and Datatables with supporting Datatables Editor. 10 | 11 | **Django Rest Framework + Datatables + Editor = Awesome :)** 12 | 13 | The project is based on the project `django-rest-framework-datatables `_ by `David Jean Louis `_ 14 | 15 | .. image:: _static/screenshot.jpg 16 | 17 | .. toctree:: 18 | :maxdepth: 2 19 | :caption: Contents: 20 | 21 | introduction 22 | quickstart 23 | tutorial 24 | example-app 25 | changelog 26 | 27 | Useful links 28 | ------------ 29 | 30 | - `Github project page `_ 31 | - `Bugtracker `_ 32 | - `Documentation `_ 33 | - `Pypi page `_ 34 | 35 | 36 | Indices and tables 37 | ================== 38 | 39 | * :ref:`genindex` 40 | * :ref:`modindex` 41 | * :ref:`search` 42 | -------------------------------------------------------------------------------- /example/albums/templates/albums/base.html: -------------------------------------------------------------------------------- 1 | {% load static %} 2 | 3 | 4 | 5 | 6 | Rolling Stone Top 500 albums of all time 7 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | {% block content %}{% endblock %} 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | {% block extra_js %}{% endblock %} 29 | 30 | 31 | -------------------------------------------------------------------------------- /docs/quickstart.rst: -------------------------------------------------------------------------------- 1 | Quickstart 2 | ========== 3 | 4 | Installation 5 | ------------ 6 | 7 | Just use ``pip``: 8 | 9 | .. code:: bash 10 | 11 | $ pip install djangorestframework-datatables-editor 12 | 13 | Configuration 14 | ------------- 15 | 16 | To enable Datatables support in your project, add ``'rest_framework_datatables'`` to your ``INSTALLED_APPS``, and modify your ``REST_FRAMEWORK`` settings like this: 17 | 18 | .. code:: python 19 | 20 | REST_FRAMEWORK = { 21 | 'DEFAULT_RENDERER_CLASSES': ( 22 | 'rest_framework.renderers.JSONRenderer', 23 | 'rest_framework.renderers.BrowsableAPIRenderer', 24 | 'rest_framework_datatables_editor.renderers.DatatablesRenderer', 25 | ), 26 | 'DEFAULT_FILTER_BACKENDS': ( 27 | 'rest_framework_datatables_editor.filters.DatatablesFilterBackend', 28 | ), 29 | 'DEFAULT_PAGINATION_CLASS': 'rest_framework_datatables.pagination.DatatablesPageNumberPagination', 30 | 'PAGE_SIZE': 50, 31 | } 32 | 33 | What have we done so far ? 34 | 35 | - we added the ``rest_framework_datatables.renderers.DatatablesRenderer`` to existings renderers 36 | - we added the ``rest_framework_datatables.filters.DatatablesFilterBackend`` to the filter backends 37 | - we replaced the pagination class by ``rest_framework_datatables.pagination.DatatablesPageNumberPagination`` 38 | 39 | .. note:: 40 | 41 | If you are using ``rest_framework.pagination.LimitOffsetPagination`` as pagination class, relax and don't panic ! 42 | django-rest-framework-datatables can handle that, just replace it with ``rest_framework_datatables.pagination.DatatablesLimitOffsetPagination``. 43 | 44 | And that's it ! 45 | --------------- 46 | 47 | Your API is now fully compatible with Datatables and Datatables Editor and will provide searching, filtering, ordering and pagination and editing, to continue, follow the :doc:`tutorial`. 48 | -------------------------------------------------------------------------------- /example/albums/views.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | from rest_framework import viewsets 3 | from rest_framework.response import Response 4 | 5 | from rest_framework_datatables_editor.filters import DatatablesFilterBackend 6 | from rest_framework_datatables_editor.pagination import ( 7 | DatatablesPageNumberPagination) 8 | from rest_framework_datatables_editor.renderers import (DatatablesRenderer) 9 | from rest_framework_datatables_editor.viewsets import ( 10 | DatatablesEditorModelViewSet) 11 | from .models import Album, Artist, Genre 12 | from .serializers import AlbumSerializer, ArtistSerializer 13 | 14 | 15 | def index(request): 16 | return render(request, 'albums/albums.html') 17 | 18 | 19 | def get_album_options(): 20 | return "options", { 21 | "artist.id": [{'label': obj.name, 'value': obj.pk} 22 | for obj in Artist.objects.all()], 23 | "genre": [{'label': obj.name, 'value': obj.pk} 24 | for obj in Genre.objects.all()] 25 | } 26 | 27 | 28 | class AlbumViewSet(DatatablesEditorModelViewSet): 29 | queryset = Album.objects.all().order_by('rank') 30 | serializer_class = AlbumSerializer 31 | 32 | def get_options(self): 33 | return get_album_options() 34 | 35 | class Meta: 36 | datatables_extra_json = ('get_options', ) 37 | 38 | 39 | class ArtistViewSet(viewsets.ViewSet): 40 | queryset = Artist.objects.all().order_by('name') 41 | serializer_class = ArtistSerializer 42 | 43 | filter_backends = (DatatablesFilterBackend,) 44 | pagination_class = DatatablesPageNumberPagination 45 | renderer_classes = (DatatablesRenderer,) 46 | 47 | def list(self, request): 48 | serializer = self.serializer_class(self.queryset, many=True) 49 | return Response(serializer.data) 50 | 51 | def get_options(self): 52 | return get_album_options() 53 | 54 | class Meta: 55 | datatables_extra_json = ('get_options', ) 56 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: [push, pull_request] 4 | 5 | jobs: 6 | flake8-linter: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - uses: actions/checkout@v1 10 | - uses: grantmcconnaughey/lintly-flake8-github-action@v1.0 11 | if: github.event_name == 'pull_request' 12 | with: 13 | # The GitHub API token to create reviews with 14 | token: ${{ secrets.GITHUB_TOKEN }} 15 | # Fail if "new" violations detected or "any", default "new" 16 | failIf: any 17 | # Additional arguments to pass to flake8, default "." (current directory) 18 | args: "--extend-exclude=migrations ." 19 | build: 20 | runs-on: ubuntu-latest 21 | strategy: 22 | fail-fast: true 23 | matrix: 24 | python-version: ['3.6', '3.7', '3.8'] 25 | django-version: ['1.11', '2.0', '2.1', '2.2', '3.0'] 26 | drf-version: ['3.9', '3.10', '3.11'] 27 | include: 28 | - python-version: '2.7' 29 | django-version: '1.11' 30 | drf-version: '3.9' 31 | exclude: 32 | - django-version: '3.0' 33 | drf-version: '3.9' 34 | steps: 35 | - uses: actions/checkout@v2 36 | - name: Set up Python ${{ matrix.python-version }} 37 | uses: actions/setup-python@v1 38 | with: 39 | python-version: ${{ matrix.python-version }} 40 | - name: Install Dependencies 41 | run: | 42 | python -m pip install --upgrade pip 43 | echo "Python ${{ matrix.python-version }} -> Django ${{ matrix.django-version }} -> DRF ${{ matrix.drf-version }}" 44 | python -m pip install "Django~=${{ matrix.django-version }}.0" 45 | python -m pip install "djangorestframework~=${{ matrix.drf-version }}.0" 46 | echo "Django: `django-admin --version`" 47 | python --version 48 | - name: Run Tests 49 | run: | 50 | python example/manage.py test tests -v 1 --noinput 51 | -------------------------------------------------------------------------------- /example/albums/serializers.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import get_object_or_404 2 | from rest_framework import serializers 3 | 4 | from .models import Album, Artist 5 | 6 | 7 | class ArtistSerializer(serializers.ModelSerializer): 8 | id = serializers.IntegerField(read_only=True) 9 | 10 | # if we need to edit a field that is a nested serializer, 11 | # we must override to_internal_value method 12 | def to_internal_value(self, data): 13 | return get_object_or_404(Artist, pk=data['id']) 14 | 15 | class Meta: 16 | model = Artist 17 | fields = ( 18 | 'id', 'name', 19 | ) 20 | # Specifying fields in datatables_always_serialize 21 | # will also force them to always be serialized. 22 | datatables_always_serialize = ('id',) 23 | 24 | 25 | class AlbumSerializer(serializers.ModelSerializer): 26 | artist_name = serializers.ReadOnlyField(source='artist.name') 27 | # DRF-Datatables can deal with nested serializers as well. 28 | artist = ArtistSerializer() 29 | genres = serializers.SerializerMethodField() 30 | artist_view = ArtistSerializer(source="artist", read_only=True) 31 | 32 | @staticmethod 33 | def get_genres(album): 34 | return ', '.join([str(genre) for genre in album.genres.all()]) 35 | 36 | # If you want, you can add special fields understood by Datatables, 37 | # the fields starting with DT_Row will always be serialized. 38 | # See: https://datatables.net/manual/server-side#Returned-data 39 | DT_RowId = serializers.SerializerMethodField() 40 | DT_RowAttr = serializers.SerializerMethodField() 41 | 42 | @staticmethod 43 | def get_DT_RowId(album): 44 | return album.pk 45 | 46 | @staticmethod 47 | def get_DT_RowAttr(album): 48 | return {'data-pk': album.pk} 49 | 50 | class Meta: 51 | model = Album 52 | fields = ( 53 | 'DT_RowId', 'DT_RowAttr', 'rank', 'name', 54 | 'year', 'artist_name', 'genres', 'artist', 55 | 'artist_view' 56 | ) 57 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # This file only contains a selection of the most common options. For a full 4 | # list see the documentation: 5 | # http://www.sphinx-doc.org/en/master/config 6 | 7 | # -- Path setup -------------------------------------------------------------- 8 | 9 | # If extensions (or modules to document with autodoc) are in another directory, 10 | # add these directories to sys.path here. If the directory is relative to the 11 | # documentation root, use os.path.abspath to make it absolute, like shown here. 12 | # 13 | # import os 14 | # import sys 15 | # sys.path.insert(0, os.path.abspath('.')) 16 | 17 | 18 | # -- Project information ----------------------------------------------------- 19 | 20 | project = 'django-rest-framework-datatables-editor' 21 | copyright = '2019, Vyacheslav VV' 22 | author = 'Vyacheslav VV' 23 | 24 | # The full version, including alpha/beta/rc tags 25 | release = '0.3.0' 26 | 27 | 28 | # -- General configuration --------------------------------------------------- 29 | 30 | # Add any Sphinx extension module names here, as strings. They can be 31 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 32 | # ones. 33 | extensions = [ 34 | ] 35 | 36 | # Add any paths that contain templates here, relative to this directory. 37 | templates_path = ['_templates'] 38 | 39 | # List of patterns, relative to source directory, that match files and 40 | # directories to ignore when looking for source files. 41 | # This pattern also affects html_static_path and html_extra_path. 42 | exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] 43 | 44 | 45 | # -- Options for HTML output ------------------------------------------------- 46 | 47 | # The theme to use for HTML and HTML Help pages. See the documentation for 48 | # a list of builtin themes. 49 | # 50 | html_theme = 'alabaster' 51 | 52 | # Add any paths that contain custom static files (such as style sheets) here, 53 | # relative to this directory. They are copied after the builtin static files, 54 | # so a file named "default.css" will overwrite the builtin "default.css". 55 | html_static_path = ['_static'] 56 | -------------------------------------------------------------------------------- /example/static/js/editor.foundation.min.js: -------------------------------------------------------------------------------- 1 | /*! 2 | Foundation integration for DataTables' Editor 3 | ©2015 SpryMedia Ltd - datatables.net/license 4 | */ 5 | (function(c){"function"===typeof define&&define.amd?define(["jquery","datatables.net-zf","datatables.net-editor"],function(b){return c(b,window,document)}):"object"===typeof exports?module.exports=function(b,e){b||(b=window);e&&e.fn.dataTable||(e=require("datatables.net-zf")(b,e).$);e.fn.dataTable.Editor||require("datatables.net-editor")(b,e);return c(e,b,b.document)}:c(jQuery,window,document)})(function(c,b,e,d){d=c.fn.dataTable;d.Editor.defaults.display="foundation";c.extend(!0,c.fn.dataTable.Editor.classes, 6 | {field:{wrapper:"DTE_Field row",label:"small-4 columns inline",input:"small-8 columns",error:"error",multiValue:"panel radius multi-value",multiInfo:"small",multiRestore:"panel radius multi-restore","msg-labelInfo":"label secondary","msg-info":"label secondary","msg-message":"label secondary","msg-error":"label alert"},form:{button:"button small",buttonInternal:"button small"}});d.Editor.display.foundation=c.extend(!0,{},d.Editor.models.displayController,{init:function(b){a._dom.content=c(''); 7 | a._dom.close=c('×');a._dom.close.click(function(){a._dte.close("icon")});return a},open:function(f,d,g){a._shown?g&&g():(a._dte=f,a._shown=!0,f=a._dom.content,f.children().detach(),f.append(d),f.prepend(a._dom.close),c(a._dom.content).one("open.zf.reveal",function(){g&&g()}).one("closed.zf.reveal",function(){a._shown=!1}),b.Foundation&&b.Foundation.Reveal?(a._reveal||(a._reveal=new b.Foundation.Reveal(a._dom.content,{closeOnClick:!1})),a._reveal.open()): 8 | c(a._dom.content).foundation("reveal","open"),c(e).on("click.dte-zf","div.reveal-modal-bg, div.reveal-overlay",function(b){c(b.target).closest(a._dom.content).length||a._dte.background()}))},close:function(b,d){a._shown&&(a._reveal?a._reveal.close():c(a._dom.content).foundation("reveal","close"),c(e).off("click.dte-zf"),a._dte=b,a._shown=!1);d&&d()},node:function(b){return a._dom.content[0]},_shown:!1,_dte:null,_dom:{}});var a=d.Editor.display.foundation;return d.Editor}); 9 | -------------------------------------------------------------------------------- /example/albums/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 2.2 on 2019-04-29 09:41 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | 6 | 7 | class Migration(migrations.Migration): 8 | 9 | initial = True 10 | 11 | dependencies = [ 12 | ] 13 | 14 | operations = [ 15 | migrations.CreateModel( 16 | name='Artist', 17 | fields=[ 18 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 19 | ('name', models.CharField(max_length=80, verbose_name='Name')), 20 | ], 21 | options={ 22 | 'verbose_name': 'Artist', 23 | 'verbose_name_plural': 'Artists', 24 | 'ordering': ['name'], 25 | }, 26 | ), 27 | migrations.CreateModel( 28 | name='Genre', 29 | fields=[ 30 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 31 | ('name', models.CharField(max_length=80, verbose_name='Name')), 32 | ], 33 | options={ 34 | 'verbose_name': 'Genre', 35 | 'verbose_name_plural': 'Genres', 36 | 'ordering': ['name'], 37 | }, 38 | ), 39 | migrations.CreateModel( 40 | name='Album', 41 | fields=[ 42 | ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), 43 | ('name', models.CharField(max_length=80, verbose_name='Name')), 44 | ('rank', models.PositiveIntegerField(verbose_name='Rank')), 45 | ('year', models.PositiveIntegerField(verbose_name='Year')), 46 | ('artist', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='albums', to='albums.Artist', verbose_name='Artist')), 47 | ('genres', models.ManyToManyField(related_name='albums', to='albums.Genre', verbose_name='Genres')), 48 | ], 49 | options={ 50 | 'verbose_name': 'Album', 51 | 'verbose_name_plural': 'Albums', 52 | 'ordering': ['name'], 53 | }, 54 | ), 55 | ] 56 | -------------------------------------------------------------------------------- /example/static/js/editor.semanticui.min.js: -------------------------------------------------------------------------------- 1 | /*! 2 | Semantic UI integration for DataTables' Editor 3 | ©2018 SpryMedia Ltd - datatables.net/license 4 | */ 5 | (function(c){"function"===typeof define&&define.amd?define(["jquery","datatables.net-se","datatables.net-editor"],function(a){return c(a,window,document)}):"object"===typeof exports?module.exports=function(a,d){a||(a=window);d&&d.fn.dataTable||(d=require("datatables.net-se")(a,d).$);d.fn.dataTable.Editor||require("datatables.net-editor")(a,d);return c(d,a,a.document)}:c(jQuery,window,document)})(function(c,a,d,g){a=c.fn.dataTable;a.Editor.defaults.display="semanticui";c.extend(!0,c.fn.dataTable.Editor.classes, 6 | {header:{wrapper:"DTE_Header header"},body:{wrapper:"DTE_Body content"},footer:{wrapper:"DTE_Footer actions"},form:{tag:"ui form",button:"ui button",buttonInternal:"ui button",content:"DTE_Form_Content"},field:{wrapper:"DTE_Field inline fields",label:"right aligned five wide field",input:"eight wide field DTE_Field_Input",error:"error has-error","msg-labelInfo":"ui small","msg-info":"ui small","msg-message":"ui message small","msg-error":"ui error message small",multiValue:"ui message multi-value", 7 | multiInfo:"small",multiRestore:"ui message multi-restore"},inline:{wrapper:"DTE DTE_Inline ui form"},bubble:{table:"DTE_Bubble_Table ui form",bg:"ui dimmer modals page transition visible active"}});c.extend(!0,a.ext.buttons,{create:{formButtons:{className:"primary"}},edit:{formButtons:{className:"primary"}},remove:{formButtons:{className:"negative"}}});a.Editor.display.semanticui=c.extend(!0,{},a.Editor.models.displayController,{init:function(a){b._dom.modal||(b._dom.modal=c(''), 8 | b._dom.close=c('').click(function(a){b._dte.close("icon");return!1}),c(d).on("click","div.ui.dimmer.modals",function(a){c(a.target).hasClass("modal")&&b._shown&&b._dte.background()}));return b},open:function(a,d,e){if(b._shown)e&&e();else{b._dte=a;b._shown=!0;a=b._dom.modal;var f=c(d).children();a.children().detach();a.append(f).prepend(a.children(".header")).addClass(d.className).prepend(b._dom.close);c(b._dom.modal).modal("setting",{dimmerSettings:{closable:!1},onVisible:function(){b._dte.s.setFocus&& 9 | b._dte.s.setFocus.focus();e&&e()},onHidden:function(){c(d).append(f);b._shown=!1}}).modal("show")}},close:function(a,c){var d=b._dom.modal;b._shown&&(d.modal("hide"),b._dte=a,b._shown=!1);c&&c()},node:function(a){return b._dom.modal[0]},_shown:!1,_dte:null,_dom:{}});var b=a.Editor.display.semanticui;return a.Editor}); 10 | -------------------------------------------------------------------------------- /example/static/js/editor.jqueryui.min.js: -------------------------------------------------------------------------------- 1 | /*! 2 | jQuery UI integration for DataTables' Editor 3 | ©2015 SpryMedia Ltd - datatables.net/license 4 | */ 5 | var $jscomp=$jscomp||{};$jscomp.scope={};$jscomp.findInternal=function(a,b,c){a instanceof String&&(a=String(a));for(var e=a.length,d=0;d').css("display","none").appendTo("body").dialog(a.extend(!0,d.display.jqueryui.modalOptions,{autoOpen:!1,buttons:{A:function(){}},closeOnEscape:!1}));a(b.__dialouge).on("dialogclose",function(a){f||b.close()}); 10 | return d.display.jqueryui},open:function(b,c,d){b.__dialouge.append(c).dialog("open");a(b.dom.formError).appendTo(b.__dialouge.parent().find("div.ui-dialog-buttonpane"));b.__dialouge.parent().find(".ui-dialog-title").html(b.dom.header.innerHTML);b.__dialouge.parent().addClass("DTED");c=a(b.dom.buttons).children().addClass("ui-button ui-widget ui-state-default ui-corner-all ui-button-text-only").each(function(){a(this).wrapInner('')});b.__dialouge.parent().find("div.ui-dialog-buttonset").empty().append(c.parent()); 11 | d&&d()},close:function(a,b){a.__dialouge&&(f=!0,a.__dialouge.dialog("close"),f=!1);b&&b()},node:function(a){return a.__dialouge[0]},captureFocus:!1});d.display.jqueryui.modalOptions={width:600,modal:!0};return b.Editor}); 12 | -------------------------------------------------------------------------------- /docs/introduction.rst: -------------------------------------------------------------------------------- 1 | Introduction 2 | ============ 3 | 4 | View tables 5 | ~~~~~~~~~~~ 6 | 7 | django-rest-framework-datatables provides seamless integration between `Django REST framework `_ and `Datatables `_. 8 | 9 | Just call your API with ``?format=datatables``, and you will get a JSON structure that is fully compatible with what Datatables expects. 10 | 11 | A "normal" call to your existing API will look like this: 12 | 13 | .. code:: bash 14 | 15 | $ curl http://127.0.0.1:8000/api/albums/ | python -m "json.tool" 16 | 17 | .. code:: json 18 | 19 | { 20 | "count": 2, 21 | "next": null, 22 | "previous": null, 23 | "results": [ 24 | { 25 | "rank": 1, 26 | "name": "Sgt. Pepper's Lonely Hearts Club Band", 27 | "year": 1967, 28 | "artist_name": "The Beatles", 29 | "genres": "Psychedelic Rock, Rock & Roll" 30 | }, 31 | { 32 | "rank": 2, 33 | "name": "Pet Sounds", 34 | "year": 1966, 35 | "artist_name": "The Beach Boys", 36 | "genres": "Pop Rock, Psychedelic Rock" 37 | } 38 | ] 39 | } 40 | 41 | The same call with ``datatables`` format will look a bit different: 42 | 43 | .. code:: bash 44 | 45 | $ curl http://127.0.0.1:8000/api/albums/?format=datatables | python -m "json.tool" 46 | 47 | .. code:: json 48 | 49 | { 50 | "recordsFiltered": 2, 51 | "recordsTotal": 2, 52 | "draw": 1, 53 | "data": [ 54 | { 55 | "rank": 1, 56 | "name": "Sgt. Pepper's Lonely Hearts Club Band", 57 | "year": 1967, 58 | "artist_name": "The Beatles", 59 | "genres": "Psychedelic Rock, Rock & Roll" 60 | }, 61 | { 62 | "rank": 2, 63 | "name": "Pet Sounds", 64 | "year": 1966, 65 | "artist_name": "The Beach Boys", 66 | "genres": "Pop Rock, Psychedelic Rock" 67 | } 68 | ] 69 | } 70 | 71 | As you can see, django-rest-framework-datatables automatically adapt the JSON structure to what Datatables expects. And you don't have to create a different API, your API will still work as usual unless you specify the ``datatables`` format on your request. 72 | 73 | But django-rest-framework-datatables can do much more ! As you will learn in the tutorial, it speaks the Datatables language and can handle searching, filtering, ordering, pagination, etc. 74 | Read the :doc:`quickstart guide` for instructions on how to install and configure django-rest-framework-datatables. 75 | 76 | 77 | Editing tables 78 | ~~~~~~~~~~~~~~ 79 | 80 | The URL for interaction with the Datatables Editor: http://127.0.0.1:8000/api/albums/editor for this view. 81 | 82 | You must set the parameter ``ajax: "/api/albums/editor/`` and that's it! 83 | -------------------------------------------------------------------------------- /docs/changelog.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | Version 0.3.3 (2020-05-17): 5 | --------------------------- 6 | 7 | - Added support for Django 3.0 8 | 9 | 10 | Version 0.3.2 (2019-05-23): 11 | --------------------------- 12 | 13 | - Fixed checking fields when deleting 14 | 15 | 16 | Version 0.3.1 (2019-05-22): 17 | --------------------------- 18 | 19 | - Fixed requirements 20 | 21 | Version 0.3.0 (2019-05-06): 22 | --------------------------- 23 | 24 | - Added checking of the writable fields of Datatables Editor passed to Django 25 | - Added information about CSRF authorization to the documentation 26 | 27 | Version 0.2.1 (2019-04-29): 28 | --------------------------- 29 | 30 | - Added documentation 31 | 32 | Version 0.2.0 (2019-04-20): 33 | --------------------------- 34 | 35 | - Added tests for editor functionality 36 | 37 | Version 0.1.0 (2019-04-15): 38 | --------------------------- 39 | 40 | - Initial release. 41 | - New project released with supporting `Datatables editor `_. 42 | - The project is based on `django-rest-framework-datatables `_ by `David Jean Louis `_) 43 | 44 | --------------------------- 45 | 46 | Version 0.5.0 (2019-03-31): 47 | --------------------------- 48 | 49 | The changelog bellow is the changelog of `django-rest-framework-datatables `_ by `David Jean Louis `_) 50 | 51 | - Fixed total number of rows when view is using multiple filter back-ends 52 | - New meta option ``datatables_extra_json`` on view for adding key/value pairs to rendered JSON 53 | - Minor docs fixes 54 | 55 | Version 0.4.1 (2018-11-16): 56 | --------------------------- 57 | 58 | - Added support for Django 2.1 and DRF 3.9 59 | - Updated README 60 | 61 | Version 0.4.0 (2018-06-22): 62 | --------------------------- 63 | 64 | - Added top level filtering for nested serializers 65 | - Added multiple field filtering 66 | - Added a ?keep= parameter that allows to bypass the filtering of unused fields 67 | - Better detection of the requested format 68 | - Fixed typo in Queryset.count() method name 69 | 70 | 71 | Version 0.3.0 (2018-05-11): 72 | --------------------------- 73 | 74 | - Added a serializer Meta option ``datatables_always_serialize`` that allows to specify a tuple of fields that should always be serialized in the response, regardless of what fields are requested in the Datatables request 75 | - Optimize filters 76 | - Use AND operator for column filtering instead of OR, to be consistant with the client-side behavior of Datatables 77 | 78 | Version 0.2.1 (2018-04-11): 79 | --------------------------- 80 | 81 | - This version replaces the 0.2.0 who was broken (bad setup.py) 82 | 83 | Version 0.2.0 (2018-04-11): 84 | --------------------------- 85 | 86 | - Added full documentation 87 | - Removed serializers, they are no longer necessary, filtering of columns is made by the renderer 88 | 89 | Version 0.1.0 (2018-04-10): 90 | --------------------------- 91 | 92 | Initial release. 93 | -------------------------------------------------------------------------------- /example/example/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | 4 | BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 5 | sys.path.insert(0, os.path.dirname(BASE_DIR)) 6 | 7 | SECRET_KEY = '&sccg=dcd*la_pd8@^4d18!-u_@nen4zze2e@2%^ox*h_*$^x+' 8 | 9 | # SECURITY WARNING: don't run with debug turned on in production! 10 | DEBUG = True 11 | 12 | ALLOWED_HOSTS = [] 13 | 14 | # Application definition 15 | 16 | INSTALLED_APPS = [ 17 | 'django.contrib.admin', 18 | 'django.contrib.auth', 19 | 'django.contrib.contenttypes', 20 | 'django.contrib.sessions', 21 | 'django.contrib.messages', 22 | 'django.contrib.staticfiles', 23 | 24 | 'rest_framework', 25 | 'rest_framework_datatables_editor', 26 | 'albums', 27 | ] 28 | 29 | MIDDLEWARE = [ 30 | 'django.middleware.security.SecurityMiddleware', 31 | 'django.contrib.sessions.middleware.SessionMiddleware', 32 | 'django.middleware.common.CommonMiddleware', 33 | 'django.middleware.csrf.CsrfViewMiddleware', 34 | 'django.contrib.auth.middleware.AuthenticationMiddleware', 35 | 'django.contrib.messages.middleware.MessageMiddleware', 36 | 'django.middleware.clickjacking.XFrameOptionsMiddleware', 37 | ] 38 | 39 | ROOT_URLCONF = 'example.urls' 40 | 41 | TEMPLATES = [ 42 | { 43 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 44 | 'DIRS': [], 45 | 'APP_DIRS': True, 46 | 'OPTIONS': { 47 | 'context_processors': [ 48 | 'django.template.context_processors.debug', 49 | 'django.template.context_processors.request', 50 | 'django.contrib.auth.context_processors.auth', 51 | 'django.contrib.messages.context_processors.messages', 52 | ], 53 | }, 54 | }, 55 | ] 56 | 57 | WSGI_APPLICATION = 'example.wsgi.application' 58 | 59 | DATABASES = { 60 | 'default': { 61 | 'ENGINE': 'django.db.backends.sqlite3', 62 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 63 | 'TEST': {'NAME': os.path.join(BASE_DIR, 'test.sqlite3')}, 64 | } 65 | } 66 | 67 | validation = 'django.contrib.auth.password_validation' 68 | 69 | AUTH_PASSWORD_VALIDATORS = [ 70 | { 71 | 'NAME': '%s.UserAttributeSimilarityValidator' % validation, 72 | }, 73 | { 74 | 'NAME': '%s.MinimumLengthValidator' % validation, 75 | }, 76 | { 77 | 'NAME': '%s.CommonPasswordValidator' % validation, 78 | }, 79 | { 80 | 'NAME': '%s.NumericPasswordValidator' % validation, 81 | }, 82 | ] 83 | 84 | LANGUAGE_CODE = 'en-us' 85 | 86 | TIME_ZONE = 'UTC' 87 | 88 | USE_I18N = True 89 | 90 | USE_L10N = True 91 | 92 | USE_TZ = True 93 | 94 | STATIC_URL = '/static/' 95 | 96 | STATICFILES_DIRS = [ 97 | os.path.join(BASE_DIR, "static"), 98 | ] 99 | 100 | # DRF 101 | REST_FRAMEWORK = { 102 | 'DEFAULT_RENDERER_CLASSES': ( 103 | 'rest_framework.renderers.JSONRenderer', 104 | 'rest_framework.renderers.BrowsableAPIRenderer', 105 | 'rest_framework_datatables_editor.renderers.DatatablesRenderer', 106 | ), 107 | 'DEFAULT_FILTER_BACKENDS': ( 108 | 'rest_framework_datatables_editor.filters.DatatablesFilterBackend', 109 | ), 110 | 'DEFAULT_PAGINATION_CLASS': 'rest_framework_datatables_editor.pagination.' 111 | 'DatatablesPageNumberPagination', 112 | 'PAGE_SIZE': 50, 113 | } 114 | -------------------------------------------------------------------------------- /rest_framework_datatables_editor/viewsets.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from django.http import JsonResponse 4 | from rest_framework.decorators import action 5 | from rest_framework.exceptions import ValidationError 6 | from rest_framework.generics import get_object_or_404 7 | from rest_framework.viewsets import ModelViewSet 8 | 9 | 10 | def check_fields(serializer, data): 11 | # _writable_fields 12 | list_fields_in_data = set(list(data.values())[0].keys()) 13 | list_of_writable_fields = set( 14 | [field.field_name for field in serializer()._writable_fields] 15 | ) 16 | invalid_fields = list_fields_in_data - list_of_writable_fields 17 | if len(invalid_fields): 18 | raise ValidationError( 19 | "The following fields are present in the request," 20 | " but they are not writable: " + 21 | ','.join(str(field) for field in invalid_fields) 22 | ) 23 | 24 | 25 | class EditorModelMixin(object): 26 | 27 | @staticmethod 28 | def get_post_date(post): 29 | def read_date(data_in, data_out, rest_of_line): 30 | field_name = data_in[0] 31 | if not isinstance(data_out.get(field_name), dict): 32 | new_data_point = {} 33 | data_out[field_name] = new_data_point 34 | else: 35 | new_data_point = data_out[field_name] 36 | if len(data_in) == 2: 37 | new_data_point[data_in[1]] = rest_of_line 38 | else: 39 | read_date(data_in[1:], new_data_point, rest_of_line) 40 | 41 | data = {} 42 | for (line, value) in post.items(): 43 | if line.startswith('data'): 44 | line_data = re.findall(r"\[([^\[\]]*)\]", line) 45 | read_date(line_data, data, value) 46 | return data 47 | 48 | @action(detail=False, url_name='editor', methods=['post']) 49 | def editor(self, request): 50 | post = request.POST 51 | act = post['action'] 52 | data = self.get_post_date(post) 53 | 54 | return_data = [] 55 | if act == 'edit' or act == 'remove' or act == 'create': 56 | for elem_id, changes in data.items(): 57 | if act == 'create': 58 | check_fields(self.serializer_class, data) 59 | serializer = self.serializer_class( 60 | data=changes, 61 | context={'request': request} 62 | ) 63 | if not serializer.is_valid(): # pragma: no cover 64 | raise ValidationError(serializer.errors) 65 | serializer.save() 66 | return_data.append(serializer.data) 67 | continue 68 | 69 | elem = get_object_or_404(self.get_queryset(), pk=elem_id) 70 | if act == 'edit': 71 | check_fields(self.serializer_class, data) 72 | serializer = self.serializer_class( 73 | instance=elem, data=changes, 74 | partial=True, context={'request': request} 75 | ) 76 | if not serializer.is_valid(): # pragma: no cover 77 | raise ValidationError(serializer.errors) 78 | serializer.save() 79 | return_data.append(serializer.data) 80 | elif act == 'remove': 81 | elem.delete() 82 | 83 | return JsonResponse({'data': return_data}) 84 | 85 | 86 | class DatatablesEditorModelViewSet(EditorModelMixin, ModelViewSet): 87 | pass 88 | -------------------------------------------------------------------------------- /rest_framework_datatables_editor/pagination.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from collections import OrderedDict 3 | 4 | from django.core.paginator import InvalidPage 5 | from rest_framework.exceptions import NotFound 6 | from rest_framework.pagination import ( 7 | PageNumberPagination, LimitOffsetPagination 8 | ) 9 | from rest_framework.response import Response 10 | 11 | if sys.version_info < (3, 0): 12 | # noinspection PyUnresolvedReferences 13 | text_type = unicode # pragma: no cover # noqa: F821 14 | else: 15 | text_type = str 16 | 17 | 18 | class DatatablesMixin(object): 19 | def get_paginated_response(self, data): 20 | if not self.is_datatable_request: 21 | return super(DatatablesMixin, self).get_paginated_response(data) 22 | 23 | return Response(OrderedDict([ 24 | ('recordsTotal', self.total_count), 25 | ('recordsFiltered', self.count), 26 | ('data', data) 27 | ])) 28 | 29 | def get_count_and_total_count(self, queryset, view): 30 | if hasattr(view, '_datatables_filtered_count'): 31 | count = view._datatables_filtered_count 32 | del view._datatables_filtered_count 33 | else: # pragma: no cover 34 | count = queryset.count() 35 | if hasattr(view, '_datatables_total_count'): 36 | total_count = view._datatables_total_count 37 | del view._datatables_total_count 38 | else: # pragma: no cover 39 | total_count = count 40 | return count, total_count 41 | 42 | 43 | class DatatablesPageNumberPagination(DatatablesMixin, PageNumberPagination): 44 | def paginate_queryset(self, queryset, request, view=None): 45 | if request.accepted_renderer.format != 'datatables': 46 | self.is_datatable_request = False 47 | return super( 48 | DatatablesPageNumberPagination, self 49 | ).paginate_queryset(queryset, request, view) 50 | if request.query_params.get('length') is None: 51 | return None 52 | self.count, self.total_count = self.get_count_and_total_count( 53 | queryset, view 54 | ) 55 | self.is_datatable_request = True 56 | self.page_size_query_param = 'length' 57 | page_size = self.get_page_size(request) 58 | if not page_size: # pragma: no cover 59 | return None 60 | 61 | paginator = self.django_paginator_class(queryset, page_size) 62 | start = int(request.query_params.get('start', 0)) 63 | page_number = int(start / page_size) + 1 64 | 65 | try: 66 | self.page = paginator.page(page_number) 67 | except InvalidPage as exc: 68 | msg = self.invalid_page_message.format( 69 | page_number=page_number, message=text_type(exc) 70 | ) 71 | raise NotFound(msg) 72 | self.request = request 73 | return list(self.page) 74 | 75 | 76 | class DatatablesLimitOffsetPagination(DatatablesMixin, LimitOffsetPagination): 77 | def paginate_queryset(self, queryset, request, view=None): 78 | if request.accepted_renderer.format == 'datatables': 79 | self.is_datatable_request = True 80 | if request.query_params.get('length') is None: 81 | return None 82 | self.limit_query_param = 'length' 83 | self.offset_query_param = 'start' 84 | self.count, self.total_count = self.get_count_and_total_count( 85 | queryset, view 86 | ) 87 | else: 88 | self.is_datatable_request = False 89 | return super( 90 | DatatablesLimitOffsetPagination, self 91 | ).paginate_queryset(queryset, request, view) 92 | -------------------------------------------------------------------------------- /example/static/js/editor.bootstrap.min.js: -------------------------------------------------------------------------------- 1 | /*! 2 | Bootstrap integration for DataTables' Editor 3 | ©2015 SpryMedia Ltd - datatables.net/license 4 | */ 5 | var $jscomp=$jscomp||{};$jscomp.scope={};$jscomp.findInternal=function(a,b,d){a instanceof String&&(a=String(a));for(var e=a.length,c=0;c"+e.create.title+"";e.edit.title=""+e.edit.title+"";e.remove.title=""+e.remove.title+"";if(e=b.TableTools)e.BUTTONS.editor_create.formButtons[0].className="btn btn-primary",e.BUTTONS.editor_edit.formButtons[0].className="btn btn-primary",e.BUTTONS.editor_remove.formButtons[0].className="btn btn-danger";a.extend(!0,a.fn.dataTable.Editor.classes,{header:{wrapper:"DTE_Header modal-header"},body:{wrapper:"DTE_Body modal-body"},footer:{wrapper:"DTE_Footer modal-footer"},form:{tag:"form-horizontal", 10 | button:"btn btn-default",buttonInternal:"btn btn-default"},field:{wrapper:"DTE_Field",label:"col-lg-4 control-label",input:"col-lg-8 controls",error:"error has-error","msg-labelInfo":"help-block","msg-info":"help-block","msg-message":"help-block","msg-error":"help-block",multiValue:"well well-sm multi-value",multiInfo:"small",multiRestore:"well well-sm multi-restore"}});a.extend(!0,b.ext.buttons,{create:{formButtons:{className:"btn-primary"}},edit:{formButtons:{className:"btn-primary"}},remove:{formButtons:{className:"btn-danger"}}}); 11 | b.Editor.display.bootstrap=a.extend(!0,{},b.Editor.models.displayController,{init:function(b){c._dom.content||(c._dom.content=a(''),c._dom.close=a('×'),c._dom.modalContent=c._dom.content.find("div.modal-content"),c._dom.close.click(function(){c._dte.close("icon")}),a(d).on("click","div.modal",function(b){a(b.target).hasClass("modal")&&c._shown&&c._dte.background()}));b.on("displayOrder.dtebs", 12 | function(c,d,e,f){a.each(b.s.fields,function(c,b){a("input:not([type=checkbox]):not([type=radio]), select, textarea",b.node()).addClass("form-control")})});return c},open:function(b,d,e){c._shown?e&&e():(c._dte=b,c._shown=!0,b=c._dom.modalContent,b.children().detach(),b.append(d),a("div.modal-header",d).prepend(c._dom.close),a(c._dom.content).one("shown.bs.modal",function(){c._dte.s.setFocus&&c._dte.s.setFocus.focus();e&&e()}).one("hidden",function(){c._shown=!1}).appendTo("body").modal({backdrop:"static", 13 | keyboard:!1}))},close:function(b,d){c._shown&&(a(c._dom.content).one("hidden.bs.modal",function(){a(this).detach()}).modal("hide"),c._dte=b,c._shown=!1);d&&d()},node:function(a){return c._dom.content[0]},_shown:!1,_dte:null,_dom:{}});var c=b.Editor.display.bootstrap;return b.Editor}); 14 | -------------------------------------------------------------------------------- /example/static/js/editor.jqueryui.js: -------------------------------------------------------------------------------- 1 | /*! jQuery UI integration for DataTables' Editor 2 | * ©2015 SpryMedia Ltd - datatables.net/license 3 | */ 4 | 5 | (function( factory ){ 6 | if ( typeof define === 'function' && define.amd ) { 7 | // AMD 8 | define( ['jquery', 'datatables.net-jqui', 'datatables.net-editor'], function ( $ ) { 9 | return factory( $, window, document ); 10 | } ); 11 | } 12 | else if ( typeof exports === 'object' ) { 13 | // CommonJS 14 | module.exports = function (root, $) { 15 | if ( ! root ) { 16 | root = window; 17 | } 18 | 19 | if ( ! $ || ! $.fn.dataTable ) { 20 | $ = require('datatables.net-jqui')(root, $).$; 21 | } 22 | 23 | if ( ! $.fn.dataTable.Editor ) { 24 | require('datatables.net-editor')(root, $); 25 | } 26 | 27 | return factory( $, root, root.document ); 28 | }; 29 | } 30 | else { 31 | // Browser 32 | factory( jQuery, window, document ); 33 | } 34 | }(function( $, window, document, undefined ) { 35 | 'use strict'; 36 | var DataTable = $.fn.dataTable; 37 | 38 | 39 | var Editor = DataTable.Editor; 40 | var doingClose = false; 41 | 42 | /* 43 | * Set the default display controller to be our foundation control 44 | */ 45 | Editor.defaults.display = "jqueryui"; 46 | 47 | /* 48 | * Change the default classes from Editor to be classes for Bootstrap 49 | */ 50 | var buttonClass = "btn ui-button ui-widget ui-state-default ui-corner-all ui-button-text-only"; 51 | $.extend( true, $.fn.dataTable.Editor.classes, { 52 | form: { 53 | button: buttonClass, 54 | buttonInternal: buttonClass 55 | } 56 | } ); 57 | 58 | /* 59 | * jQuery UI display controller - this is effectively a proxy to the jQuery UI 60 | * modal control. 61 | */ 62 | Editor.display.jqueryui = $.extend( true, {}, Editor.models.displayController, { 63 | init: function ( dte ) { 64 | dte.__dialouge = $('') 65 | .css('display', 'none') 66 | .appendTo('body') 67 | .dialog( $.extend( true, Editor.display.jqueryui.modalOptions, { 68 | autoOpen: false, 69 | buttons: { "A": function () {} }, // fake button so the button container is created 70 | closeOnEscape: false // allow editor's escape function to run 71 | } ) ); 72 | 73 | // Need to know when the dialogue is closed using its own trigger 74 | // so we can reset the form 75 | $(dte.__dialouge).on( 'dialogclose', function (e) { 76 | if ( ! doingClose ) { 77 | dte.close(); 78 | } 79 | } ); 80 | 81 | return Editor.display.jqueryui; 82 | }, 83 | 84 | open: function ( dte, append, callback ) { 85 | dte.__dialouge 86 | .append( append ) 87 | .dialog( 'open' ); 88 | 89 | $(dte.dom.formError).appendTo( 90 | dte.__dialouge.parent().find('div.ui-dialog-buttonpane') 91 | ); 92 | 93 | dte.__dialouge.parent().find('.ui-dialog-title').html( dte.dom.header.innerHTML ); 94 | dte.__dialouge.parent().addClass('DTED'); 95 | 96 | // Modify the Editor buttons to be jQuery UI suitable 97 | var buttons = $(dte.dom.buttons) 98 | .children() 99 | .addClass( 'ui-button ui-widget ui-state-default ui-corner-all ui-button-text-only' ) 100 | .each( function () { 101 | $(this).wrapInner( '' ); 102 | } ); 103 | 104 | // Move the buttons into the jQuery UI button set 105 | dte.__dialouge.parent().find('div.ui-dialog-buttonset') 106 | .empty() 107 | .append( buttons.parent() ); 108 | 109 | if ( callback ) { 110 | callback(); 111 | } 112 | }, 113 | 114 | close: function ( dte, callback ) { 115 | if ( dte.__dialouge ) { 116 | // Don't want to trigger a close() call from dialogclose! 117 | doingClose = true; 118 | dte.__dialouge.dialog( 'close' ); 119 | doingClose = false; 120 | } 121 | 122 | if ( callback ) { 123 | callback(); 124 | } 125 | }, 126 | 127 | node: function ( dte ) { 128 | return dte.__dialouge[0]; 129 | }, 130 | 131 | // jQuery UI dialogues perform their own focus capture 132 | captureFocus: false 133 | } ); 134 | 135 | 136 | Editor.display.jqueryui.modalOptions = { 137 | width: 600, 138 | modal: true 139 | }; 140 | 141 | 142 | return DataTable.Editor; 143 | })); 144 | -------------------------------------------------------------------------------- /example/static/js/editor.bootstrap4.min.js: -------------------------------------------------------------------------------- 1 | /*! 2 | Bootstrap integration for DataTables' Editor 3 | ©2015 SpryMedia Ltd - datatables.net/license 4 | */ 5 | var $jscomp=$jscomp||{};$jscomp.scope={};$jscomp.findInternal=function(a,b,d){a instanceof String&&(a=String(a));for(var e=a.length,c=0;c'+e.create.title+"";e.edit.title=''+e.edit.title+"";e.remove.title=''+e.remove.title+"";if(e=b.TableTools)e.BUTTONS.editor_create.formButtons[0].className="btn btn-primary",e.BUTTONS.editor_edit.formButtons[0].className="btn btn-primary",e.BUTTONS.editor_remove.formButtons[0].className="btn btn-danger";a.extend(!0,a.fn.dataTable.Editor.classes,{header:{wrapper:"DTE_Header modal-header"},body:{wrapper:"DTE_Body modal-body"}, 10 | footer:{wrapper:"DTE_Footer modal-footer"},form:{tag:"form-horizontal",button:"btn",buttonInternal:"btn btn-outline-secondary"},field:{wrapper:"DTE_Field form-group row",label:"col-lg-4 col-form-label",input:"col-lg-8",error:"error is-invalid","msg-labelInfo":"form-text text-secondary small","msg-info":"form-text text-secondary small","msg-message":"form-text text-secondary small","msg-error":"form-text text-danger small",multiValue:"card multi-value",multiInfo:"small",multiRestore:"card multi-restore"}}); 11 | a.extend(!0,b.ext.buttons,{create:{formButtons:{className:"btn-primary"}},edit:{formButtons:{className:"btn-primary"}},remove:{formButtons:{className:"btn-danger"}}});b.Editor.display.bootstrap=a.extend(!0,{},b.Editor.models.displayController,{init:function(b){c._dom.content||(c._dom.content=a(''),c._dom.close=a('×'),c._dom.close.click(function(){c._dte.close("icon")}), 12 | a(d).on("click","div.modal",function(b){a(b.target).hasClass("modal")&&c._shown&&c._dte.background()}));b.on("displayOrder.dtebs",function(c,d,e,f){a.each(b.s.fields,function(c,b){a("input:not([type=checkbox]):not([type=radio]), select, textarea",b.node()).addClass("form-control")})});return c},open:function(b,d,e){c._shown?e&&e():(c._dte=b,c._shown=!0,c._fullyDisplayed=!1,b=c._dom.content.find("div.modal-content"),b.children().detach(),b.append(d),a("div.modal-header",d).append(c._dom.close),a(c._dom.content).one("shown.bs.modal", 13 | function(){c._dte.s.setFocus&&c._dte.s.setFocus.focus();c._fullyDisplayed=!0;e&&e()}).one("hidden",function(){c._shown=!1}).appendTo("body").modal({backdrop:"static",keyboard:!1}))},close:function(b,d){if(c._shown)if(c._fullyDisplayed)a(c._dom.content).one("hidden.bs.modal",function(){a(this).detach()}).modal("hide"),c._dte=b,c._shown=!1,c._fullyDisplayed=!1,d&&d();else a(c._dom.content).one("shown.bs.modal",function(){c.close(b,d)});else d&&d()},node:function(a){return c._dom.content[0]},_shown:!1, 14 | _dte:null,_dom:{}});var c=b.Editor.display.bootstrap;return b.Editor}); 15 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | import re 3 | import subprocess 4 | from pathlib import Path 5 | 6 | from setuptools import setup 7 | 8 | package_name = 'djangorestframework-datatables-editor' 9 | folder_name = 'rest_framework_datatables_editor' 10 | description = ('Seamless integration between Django REST framework and ' 11 | 'Datatables (https://datatables.net) with supporting ' 12 | 'Datatables editor') 13 | url = 'https://github.com/VVyacheslav/django-rest-framework-datatables-editor' 14 | author = 'Vyacheslav V.V.' 15 | author_email = 'vvvyacheslav23@gmail.com' 16 | license = 'MIT' 17 | 18 | version_re = re.compile('^Version: (.+)$', re.M) 19 | 20 | 21 | def get_long_description(): 22 | """ Return rst formatted readme and changelog. """ 23 | files_to_join = ['README.rst', 'docs/changelog.rst'] 24 | description = [] 25 | for file in files_to_join: 26 | with open(file) as f: 27 | description.append(f.read()) 28 | return '\n\n'.join(description) 29 | 30 | 31 | def get_packages(package): 32 | """ 33 | Return root package and all sub-packages. 34 | """ 35 | return [dirpath 36 | for dirpath, dirnames, filenames in os.walk(package) 37 | if os.path.exists(os.path.join(dirpath, '__init__.py'))] 38 | 39 | 40 | def get_version(): 41 | """ 42 | Reads version from git status or PKG-INFO 43 | 44 | https://gist.github.com/pwithnall/7bc5f320b3bdf418265a 45 | """ 46 | d: Path = Path(__file__).absolute().parent 47 | git_dir = d.joinpath('.git') 48 | if git_dir.is_dir(): 49 | # Get the version using "git describe". 50 | cmd = 'git describe --tags --match [0-9]*'.split() 51 | try: 52 | version = subprocess.check_output(cmd).decode().strip() 53 | except subprocess.CalledProcessError: 54 | return None 55 | 56 | # PEP 386 compatibility 57 | if '-' in version: 58 | version = '.post'.join(version.split('-')[:2]) 59 | 60 | # Don't declare a version "dirty" merely because a time stamp has 61 | # changed. If it is dirty, append a ".dev1" suffix to indicate 62 | # a development revision after the release. 63 | with open(os.devnull, 'w') as fd_devnull: 64 | subprocess.call(['git', 'status'], 65 | stdout=fd_devnull, stderr=fd_devnull) 66 | 67 | cmd = 'git diff-index --name-only HEAD'.split() 68 | try: 69 | dirty = subprocess.check_output(cmd).decode().strip() 70 | except subprocess.CalledProcessError: 71 | return None 72 | 73 | if dirty != '': 74 | version += '.dev1' 75 | else: 76 | # Extract the version from the PKG-INFO file. 77 | try: 78 | with open('PKG-INFO') as v: 79 | version = version_re.search(v.read()).group(1) 80 | except FileNotFoundError: 81 | version = None 82 | 83 | return version 84 | 85 | 86 | setup( 87 | name=package_name, 88 | version=get_version() or 'dev', 89 | url=url, 90 | license=license, 91 | description=description, 92 | long_description=get_long_description(), 93 | author=author, 94 | author_email=author_email, 95 | packages=get_packages(folder_name), 96 | install_requires=[ 97 | 'djangorestframework>=3.9.1', 98 | ], 99 | classifiers=[ 100 | 'Development Status :: 5 - Production/Stable', 101 | 'Environment :: Web Environment', 102 | 'Framework :: Django', 103 | 'Framework :: Django :: 1.9', 104 | 'Framework :: Django :: 1.10', 105 | 'Framework :: Django :: 1.11', 106 | 'Framework :: Django :: 2.0', 107 | 'Framework :: Django :: 2.1', 108 | 'Framework :: Django :: 2.2', 109 | 'Framework :: Django :: 3.0', 110 | 'Intended Audience :: Developers', 111 | 'License :: OSI Approved :: MIT License', 112 | 'Operating System :: OS Independent', 113 | 'Natural Language :: English', 114 | 'Programming Language :: Python :: 2', 115 | 'Programming Language :: Python :: 2.7', 116 | 'Programming Language :: Python :: 3', 117 | 'Programming Language :: Python :: 3.4', 118 | 'Programming Language :: Python :: 3.5', 119 | 'Programming Language :: Python :: 3.6', 120 | 'Topic :: Internet :: WWW/HTTP', 121 | ] 122 | ) 123 | -------------------------------------------------------------------------------- /rest_framework_datatables_editor/renderers.py: -------------------------------------------------------------------------------- 1 | from rest_framework.renderers import JSONRenderer 2 | 3 | 4 | class DatatablesRenderer(JSONRenderer): 5 | media_type = 'application/json' 6 | format = 'datatables' 7 | 8 | def render(self, data, accepted_media_type=None, renderer_context=None): 9 | """ 10 | Render `data` into JSON, returning a bytestring. 11 | """ 12 | if data is None: 13 | return bytes() 14 | 15 | request = renderer_context['request'] 16 | new_data = {} 17 | 18 | view = renderer_context.get('view') 19 | 20 | if 'recordsTotal' not in data: 21 | # pagination was not used, let's fix the data dict 22 | if 'results' in data: 23 | results = data['results'] 24 | count = data['count'] if 'count' in data else len(results) 25 | else: 26 | results = data 27 | count = len(results) 28 | new_data['data'] = results 29 | if view and hasattr(view, '_datatables_filtered_count'): 30 | count = view._datatables_filtered_count 31 | if view and hasattr(view, '_datatables_total_count'): 32 | total_count = view._datatables_total_count 33 | else: 34 | total_count = count 35 | new_data['recordsFiltered'] = count 36 | new_data['recordsTotal'] = total_count 37 | else: 38 | new_data = data 39 | # add datatables "draw" parameter 40 | new_data['draw'] = int(request.query_params.get('draw', '1')) 41 | 42 | serializer_class = None 43 | if hasattr(view, 'get_serializer_class'): 44 | serializer_class = view.get_serializer_class() 45 | elif hasattr(view, 'serializer_class'): 46 | serializer_class = view.serializer_class 47 | 48 | if serializer_class is not None and hasattr(serializer_class, 'Meta'): 49 | force_serialize = getattr( 50 | serializer_class.Meta, 'datatables_always_serialize', () 51 | ) 52 | else: 53 | force_serialize = () 54 | 55 | self._filter_unused_fields(request, new_data, force_serialize) 56 | 57 | if hasattr(view.__class__, 'Meta'): 58 | extra_json_funcs = getattr( 59 | view.__class__.Meta, 'datatables_extra_json', () 60 | ) 61 | else: 62 | extra_json_funcs = () 63 | 64 | self._filter_extra_json(view, new_data, extra_json_funcs) 65 | 66 | return super(DatatablesRenderer, self).render( 67 | new_data, accepted_media_type, renderer_context 68 | ) 69 | 70 | def _filter_unused_fields(self, request, result, force_serialize): 71 | # list of params to keep, triggered by ?keep= and can be comma 72 | # separated. 73 | keep = request.query_params.get('keep', []) 74 | cols = [] 75 | i = 0 76 | while True: 77 | col = request.query_params.get('columns[%d][data]' % i) 78 | if col is None: 79 | break 80 | cols.append(col.split('.').pop(0)) 81 | i += 1 82 | if len(cols): 83 | data = result['data'] 84 | for i, item in enumerate(data): 85 | try: 86 | keys = set(item.keys()) 87 | except AttributeError: 88 | continue 89 | for k in keys: 90 | if ( 91 | k not in cols 92 | and not k.startswith('DT_Row') 93 | and k not in force_serialize 94 | and k not in keep 95 | ): 96 | result['data'][i].pop(k) 97 | 98 | def _filter_extra_json(self, view, result, extra_json_funcs): 99 | read_only_keys = result.keys() # don't alter anything 100 | for func in extra_json_funcs: 101 | if not hasattr(view, func): 102 | raise TypeError( 103 | "extra_json_funcs: {0} not a view method.".format(func) 104 | ) 105 | method = getattr(view, func) 106 | if not callable(method): 107 | raise TypeError( 108 | "extra_json_funcs: {0} not callable.".format(func) 109 | ) 110 | key, val = method() 111 | if key in read_only_keys: 112 | raise ValueError("Duplicate key found: {key}".format(key=key)) 113 | result[key] = val 114 | -------------------------------------------------------------------------------- /example/static/js/editor.foundation.js: -------------------------------------------------------------------------------- 1 | /*! Foundation integration for DataTables' Editor 2 | * ©2015 SpryMedia Ltd - datatables.net/license 3 | */ 4 | 5 | (function( factory ){ 6 | if ( typeof define === 'function' && define.amd ) { 7 | // AMD 8 | define( ['jquery', 'datatables.net-zf', 'datatables.net-editor'], function ( $ ) { 9 | return factory( $, window, document ); 10 | } ); 11 | } 12 | else if ( typeof exports === 'object' ) { 13 | // CommonJS 14 | module.exports = function (root, $) { 15 | if ( ! root ) { 16 | root = window; 17 | } 18 | 19 | if ( ! $ || ! $.fn.dataTable ) { 20 | $ = require('datatables.net-zf')(root, $).$; 21 | } 22 | 23 | if ( ! $.fn.dataTable.Editor ) { 24 | require('datatables.net-editor')(root, $); 25 | } 26 | 27 | return factory( $, root, root.document ); 28 | }; 29 | } 30 | else { 31 | // Browser 32 | factory( jQuery, window, document ); 33 | } 34 | }(function( $, window, document, undefined ) { 35 | 'use strict'; 36 | var DataTable = $.fn.dataTable; 37 | 38 | 39 | /* 40 | * Set the default display controller to be our foundation control 41 | */ 42 | DataTable.Editor.defaults.display = "foundation"; 43 | 44 | 45 | /* 46 | * Change the default classes from Editor to be classes for Foundation 47 | */ 48 | $.extend( true, $.fn.dataTable.Editor.classes, { 49 | field: { 50 | wrapper: "DTE_Field row", 51 | label: "small-4 columns inline", 52 | input: "small-8 columns", 53 | error: "error", 54 | multiValue: "panel radius multi-value", 55 | multiInfo: "small", 56 | multiRestore: "panel radius multi-restore", 57 | "msg-labelInfo": "label secondary", 58 | "msg-info": "label secondary", 59 | "msg-message": "label secondary", 60 | "msg-error": "label alert" 61 | }, 62 | form: { 63 | button: "button small", 64 | buttonInternal: "button small" 65 | } 66 | } ); 67 | 68 | 69 | /* 70 | * Foundation display controller - this is effectively a proxy to the Foundation 71 | * modal control. 72 | */ 73 | var self; 74 | 75 | DataTable.Editor.display.foundation = $.extend( true, {}, DataTable.Editor.models.displayController, { 76 | /* 77 | * API methods 78 | */ 79 | "init": function ( dte ) { 80 | self._dom.content = $( 81 | '' 82 | ); 83 | self._dom.close = $('×'); 84 | 85 | self._dom.close.click( function () { 86 | self._dte.close('icon'); 87 | } ); 88 | 89 | return self; 90 | }, 91 | 92 | "open": function ( dte, append, callback ) { 93 | if ( self._shown ) { 94 | if ( callback ) { 95 | callback(); 96 | } 97 | return; 98 | } 99 | 100 | self._dte = dte; 101 | self._shown = true; 102 | 103 | var content = self._dom.content; 104 | content.children().detach(); 105 | content.append( append ); 106 | content.prepend( self._dom.close ); 107 | 108 | $(self._dom.content) 109 | .one('open.zf.reveal', function () { 110 | if ( callback ) { 111 | callback(); 112 | } 113 | }) 114 | .one('closed.zf.reveal', function () { 115 | self._shown = false; 116 | }); 117 | 118 | if ( window.Foundation && window.Foundation.Reveal ) { 119 | // Foundation 6 120 | if ( ! self._reveal ) { 121 | self._reveal = new window.Foundation.Reveal( self._dom.content, { 122 | closeOnClick: false 123 | } ); 124 | } 125 | 126 | //$(self._dom.content).appendTo('body'); 127 | self._reveal.open(); 128 | } 129 | else { 130 | // Foundation 5 131 | $(self._dom.content).foundation( 'reveal','open' ); 132 | } 133 | 134 | $(document).on('click.dte-zf', 'div.reveal-modal-bg, div.reveal-overlay', function (e) { 135 | if ( $(e.target).closest(self._dom.content).length ) { 136 | return; 137 | } 138 | self._dte.background(); 139 | } ); 140 | }, 141 | 142 | "close": function ( dte, callback ) { 143 | if ( !self._shown ) { 144 | if ( callback ) { 145 | callback(); 146 | } 147 | return; 148 | } 149 | 150 | if ( self._reveal ) { 151 | self._reveal.close(); 152 | } 153 | else { 154 | $(self._dom.content).foundation( 'reveal', 'close' ); 155 | } 156 | 157 | $(document).off( 'click.dte-zf' ); 158 | 159 | self._dte = dte; 160 | self._shown = false; 161 | 162 | if ( callback ) { 163 | callback(); 164 | } 165 | }, 166 | 167 | node: function ( dte ) { 168 | return self._dom.content[0]; 169 | }, 170 | 171 | 172 | /* 173 | * Private properties 174 | */ 175 | "_shown": false, 176 | "_dte": null, 177 | "_dom": {} 178 | } ); 179 | 180 | self = DataTable.Editor.display.foundation; 181 | 182 | 183 | return DataTable.Editor; 184 | })); 185 | -------------------------------------------------------------------------------- /example/albums/templates/albums/albums.html: -------------------------------------------------------------------------------- 1 | {% extends "albums/base.html" %} 2 | {% block content %} 3 | 4 | 5 | Full example with foreign key and many to many 6 | relation 7 | 8 | All time 9 | 50's 10 | 60's 11 | 70's 12 | 80's 13 | 90's 14 | 00's 15 | 10's 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | Rank 25 | Artist 26 | Album name 27 | Year 28 | Genres 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | Minimal example with data attributes 38 | 39 | 40 | 41 | 42 | 44 | 45 | 46 | Rank 47 | Artist 48 | Album name 49 | 50 | 51 | 52 | 53 | 54 | 63 | {% endblock %} 64 | {% block extra_js %} 65 | 126 | {% endblock %} 127 | -------------------------------------------------------------------------------- /example/static/js/editor.semanticui.js: -------------------------------------------------------------------------------- 1 | /*! Semantic UI integration for DataTables' Editor 2 | * ©2018 SpryMedia Ltd - datatables.net/license 3 | */ 4 | 5 | (function( factory ){ 6 | if ( typeof define === 'function' && define.amd ) { 7 | // AMD 8 | define( ['jquery', 'datatables.net-se', 'datatables.net-editor'], function ( $ ) { 9 | return factory( $, window, document ); 10 | } ); 11 | } 12 | else if ( typeof exports === 'object' ) { 13 | // CommonJS 14 | module.exports = function (root, $) { 15 | if ( ! root ) { 16 | root = window; 17 | } 18 | 19 | if ( ! $ || ! $.fn.dataTable ) { 20 | $ = require('datatables.net-se')(root, $).$; 21 | } 22 | 23 | if ( ! $.fn.dataTable.Editor ) { 24 | require('datatables.net-editor')(root, $); 25 | } 26 | 27 | return factory( $, root, root.document ); 28 | }; 29 | } 30 | else { 31 | // Browser 32 | factory( jQuery, window, document ); 33 | } 34 | }(function( $, window, document, undefined ) { 35 | 'use strict'; 36 | var DataTable = $.fn.dataTable; 37 | 38 | 39 | /* 40 | * Set the default display controller to be Semantic UI modal 41 | */ 42 | DataTable.Editor.defaults.display = "semanticui"; 43 | 44 | /* 45 | * Change the default classes from Editor to be classes for Bootstrap 46 | */ 47 | $.extend( true, $.fn.dataTable.Editor.classes, { 48 | "header": { 49 | "wrapper": "DTE_Header header" 50 | }, 51 | "body": { 52 | "wrapper": "DTE_Body content" 53 | }, 54 | "footer": { 55 | "wrapper": "DTE_Footer actions" 56 | }, 57 | "form": { 58 | "tag": "ui form", 59 | "button": "ui button", 60 | "buttonInternal": "ui button", 61 | "content": 'DTE_Form_Content' 62 | }, 63 | "field": { 64 | "wrapper": "DTE_Field inline fields", 65 | "label": "right aligned five wide field", 66 | "input": "eight wide field DTE_Field_Input", 67 | 68 | "error": "error has-error", 69 | "msg-labelInfo": "ui small", 70 | "msg-info": "ui small", 71 | "msg-message": "ui message small", 72 | "msg-error": "ui error message small", 73 | "multiValue": "ui message multi-value", 74 | "multiInfo": "small", 75 | "multiRestore": "ui message multi-restore" 76 | }, 77 | inline: { 78 | wrapper: "DTE DTE_Inline ui form" 79 | }, 80 | bubble: { 81 | table: "DTE_Bubble_Table ui form", 82 | bg: "ui dimmer modals page transition visible active" 83 | } 84 | } ); 85 | 86 | 87 | $.extend( true, DataTable.ext.buttons, { 88 | create: { 89 | formButtons: { 90 | className: 'primary' 91 | } 92 | }, 93 | edit: { 94 | formButtons: { 95 | className: 'primary' 96 | } 97 | }, 98 | remove: { 99 | formButtons: { 100 | className: 'negative' 101 | } 102 | } 103 | } ); 104 | 105 | /* 106 | * Bootstrap display controller - this is effectively a proxy to the Bootstrap 107 | * modal control. 108 | */ 109 | 110 | var self; 111 | 112 | DataTable.Editor.display.semanticui = $.extend( true, {}, DataTable.Editor.models.displayController, { 113 | /* 114 | * API methods 115 | */ 116 | "init": function ( dte ) { 117 | // init can be called multiple times (one for each Editor instance), but 118 | // we only support a single construct here (shared between all Editor 119 | // instances) 120 | if ( ! self._dom.modal ) { 121 | self._dom.modal = $(''); 122 | 123 | self._dom.close = $('') 124 | .click( function (e) { 125 | self._dte.close('icon'); 126 | return false; 127 | } ); 128 | 129 | $(document).on('click', 'div.ui.dimmer.modals', function (e) { 130 | if ( $(e.target).hasClass('modal') && self._shown ) { 131 | self._dte.background(); 132 | } 133 | } ); 134 | } 135 | 136 | return self; 137 | }, 138 | 139 | "open": function ( dte, append, callback ) { 140 | if ( self._shown ) { 141 | if ( callback ) { 142 | callback(); 143 | } 144 | return; 145 | } 146 | 147 | self._dte = dte; 148 | self._shown = true; 149 | 150 | var modal = self._dom.modal; 151 | var appendChildren = $(append).children(); 152 | 153 | // Clean up any existing elements and then insert the elements to 154 | // display. In Semantic UI we need to have the header, content and 155 | // actions at the top level of the modal rather than as children of a 156 | // wrapper. 157 | modal 158 | .children() 159 | .detach(); 160 | 161 | modal 162 | .append( appendChildren ) 163 | .prepend( modal.children('.header') ) // order is important 164 | .addClass( append.className ) 165 | .prepend( self._dom.close ); 166 | 167 | $(self._dom.modal) 168 | .modal( 'setting', { 169 | dimmerSettings: { 170 | closable: false 171 | }, 172 | onVisible: function () { 173 | // Can only give elements focus when shown 174 | if ( self._dte.s.setFocus ) { 175 | self._dte.s.setFocus.focus(); 176 | } 177 | 178 | if ( callback ) { 179 | callback(); 180 | } 181 | }, 182 | onHidden: function () { 183 | $(append).append( appendChildren ); 184 | self._shown = false; 185 | } 186 | } ) 187 | .modal( 'show' ); 188 | }, 189 | 190 | "close": function ( dte, callback ) { 191 | var modal = self._dom.modal; 192 | 193 | if ( !self._shown ) { 194 | if ( callback ) { 195 | callback(); 196 | } 197 | return; 198 | } 199 | 200 | modal.modal('hide'); 201 | 202 | self._dte = dte; 203 | self._shown = false; 204 | 205 | if ( callback ) { 206 | callback(); 207 | } 208 | }, 209 | 210 | node: function ( dte ) { 211 | return self._dom.modal[0]; 212 | }, 213 | 214 | 215 | /* 216 | * Private properties 217 | */ 218 | "_shown": false, 219 | "_dte": null, 220 | "_dom": {} 221 | } ); 222 | 223 | self = DataTable.Editor.display.semanticui; 224 | 225 | 226 | return DataTable.Editor; 227 | })); 228 | -------------------------------------------------------------------------------- /rest_framework_datatables_editor/filters.py: -------------------------------------------------------------------------------- 1 | import re 2 | from copy import deepcopy 3 | 4 | from django.db.models import Q 5 | 6 | from rest_framework.filters import BaseFilterBackend 7 | 8 | 9 | class DatatablesFilterBackend(BaseFilterBackend): 10 | """ 11 | Filter that works with datatables params. 12 | """ 13 | def filter_queryset(self, request, queryset, view): 14 | if request.accepted_renderer.format != 'datatables': 15 | return queryset 16 | 17 | filtered_count_before = queryset.count() 18 | total_count = view.get_queryset().count() 19 | # set the queryset count as an attribute of the view for later 20 | # TODO: find a better way than this hack 21 | setattr(view, '_datatables_total_count', total_count) 22 | 23 | # parse query params 24 | getter = request.query_params.get 25 | fields = self.get_fields(getter) 26 | ordering = self.get_ordering(getter, fields) 27 | search_value = getter('search[value]') 28 | search_regex = getter('search[regex]') == 'true' 29 | 30 | # filter queryset 31 | q = Q() 32 | for f in fields: 33 | if not f['searchable']: 34 | continue 35 | if search_value and search_value != 'false': 36 | if search_regex: 37 | if self.is_valid_regex(search_value): 38 | # iterate through the list created from the 'name' 39 | # param and create a string of 'ior' Q() objects. 40 | for x in f['name']: 41 | q |= Q(**{'%s__iregex' % x: search_value}) 42 | else: 43 | # same as above. 44 | for x in f['name']: 45 | q |= Q(**{'%s__icontains' % x: search_value}) 46 | f_search_value = f.get('search_value') 47 | f_search_regex = f.get('search_regex') == 'true' 48 | if f_search_value: 49 | if f_search_regex: 50 | if self.is_valid_regex(f_search_value): 51 | # create a temporary q variable to hold the Q() 52 | # objects adhering to the field's name criteria. 53 | temp_q = Q() 54 | for x in f['name']: 55 | temp_q |= Q(**{'%s__iregex' % x: f_search_value}) 56 | # Use deepcopy() to transfer them to the global Q() 57 | # object. Deepcopy() necessary, since the var will be 58 | # reinstantiated next iteration. 59 | q = q & deepcopy(temp_q) 60 | else: 61 | temp_q = Q() 62 | for x in f['name']: 63 | temp_q |= Q(**{'%s__icontains' % x: f_search_value}) 64 | q = q & deepcopy(temp_q) 65 | 66 | if q: 67 | queryset = queryset.filter(q).distinct() 68 | filtered_count = queryset.count() 69 | else: 70 | filtered_count = filtered_count_before 71 | # set the queryset count as an attribute of the view for later 72 | # TODO: maybe find a better way than this hack ? 73 | setattr(view, '_datatables_filtered_count', filtered_count) 74 | 75 | # order queryset 76 | if len(ordering): 77 | queryset = queryset.order_by(*ordering) 78 | return queryset 79 | 80 | def get_fields(self, getter): 81 | fields = [] 82 | i = 0 83 | while True: 84 | col = 'columns[%d][%s]' 85 | data = getter(col % (i, 'data')) 86 | if data is None or not data: 87 | break 88 | name = getter(col % (i, 'name')) 89 | if not name: 90 | name = data 91 | search_col = col % (i, 'search') 92 | # to be able to search across multiple fields (e.g. to search 93 | # through concatenated names), we create a list of the name field, 94 | # replacing dot notation with double-underscores and splitting 95 | # along the commas. 96 | field = { 97 | 'name': [ 98 | n.lstrip() for n in name.replace('.', '__').split(',') 99 | ], 100 | 'data': data, 101 | 'searchable': getter(col % (i, 'searchable')) == 'true', 102 | 'orderable': getter(col % (i, 'orderable')) == 'true', 103 | 'search_value': getter('%s[%s]' % (search_col, 'value')), 104 | 'search_regex': getter('%s[%s]' % (search_col, 'regex')), 105 | } 106 | fields.append(field) 107 | i += 1 108 | return fields 109 | 110 | def get_ordering(self, getter, fields): 111 | ordering = [] 112 | i = 0 113 | while True: 114 | col = 'order[%d][%s]' 115 | idx = getter(col % (i, 'column')) 116 | if idx is None: 117 | break 118 | try: 119 | field = fields[int(idx)] 120 | except IndexError: 121 | i += 1 122 | continue 123 | if not field['orderable']: 124 | i += 1 125 | continue 126 | dir_ = getter(col % (i, 'dir'), 'asc') 127 | ordering.append('%s%s' % ( 128 | '-' if dir_ == 'desc' else '', 129 | field['name'][0] 130 | )) 131 | i += 1 132 | return ordering 133 | 134 | def is_valid_regex(cls, regex): 135 | try: 136 | re.compile(regex) 137 | return True 138 | except re.error: 139 | return False 140 | -------------------------------------------------------------------------------- /example/static/js/editor.bootstrap.js: -------------------------------------------------------------------------------- 1 | /*! Bootstrap integration for DataTables' Editor 2 | * ©2015 SpryMedia Ltd - datatables.net/license 3 | */ 4 | 5 | (function( factory ){ 6 | if ( typeof define === 'function' && define.amd ) { 7 | // AMD 8 | define( ['jquery', 'datatables.net-bs', 'datatables.net-editor'], function ( $ ) { 9 | return factory( $, window, document ); 10 | } ); 11 | } 12 | else if ( typeof exports === 'object' ) { 13 | // CommonJS 14 | module.exports = function (root, $) { 15 | if ( ! root ) { 16 | root = window; 17 | } 18 | 19 | if ( ! $ || ! $.fn.dataTable ) { 20 | $ = require('datatables.net-bs')(root, $).$; 21 | } 22 | 23 | if ( ! $.fn.dataTable.Editor ) { 24 | require('datatables.net-editor')(root, $); 25 | } 26 | 27 | return factory( $, root, root.document ); 28 | }; 29 | } 30 | else { 31 | // Browser 32 | factory( jQuery, window, document ); 33 | } 34 | }(function( $, window, document, undefined ) { 35 | 'use strict'; 36 | var DataTable = $.fn.dataTable; 37 | 38 | 39 | /* 40 | * Set the default display controller to be our bootstrap control 41 | */ 42 | DataTable.Editor.defaults.display = "bootstrap"; 43 | 44 | 45 | /* 46 | * Alter the buttons that Editor adds to TableTools so they are suitable for bootstrap 47 | */ 48 | var i18nDefaults = DataTable.Editor.defaults.i18n; 49 | i18nDefaults.create.title = ""+i18nDefaults.create.title+""; 50 | i18nDefaults.edit.title = ""+i18nDefaults.edit.title+""; 51 | i18nDefaults.remove.title = ""+i18nDefaults.remove.title+""; 52 | 53 | var tt = DataTable.TableTools; 54 | if ( tt ) { 55 | tt.BUTTONS.editor_create.formButtons[0].className = "btn btn-primary"; 56 | tt.BUTTONS.editor_edit.formButtons[0].className = "btn btn-primary"; 57 | tt.BUTTONS.editor_remove.formButtons[0].className = "btn btn-danger"; 58 | } 59 | 60 | 61 | /* 62 | * Change the default classes from Editor to be classes for Bootstrap 63 | */ 64 | $.extend( true, $.fn.dataTable.Editor.classes, { 65 | "header": { 66 | "wrapper": "DTE_Header modal-header" 67 | }, 68 | "body": { 69 | "wrapper": "DTE_Body modal-body" 70 | }, 71 | "footer": { 72 | "wrapper": "DTE_Footer modal-footer" 73 | }, 74 | "form": { 75 | "tag": "form-horizontal", 76 | "button": "btn btn-default", 77 | "buttonInternal": "btn btn-default" 78 | }, 79 | "field": { 80 | "wrapper": "DTE_Field", 81 | "label": "col-lg-4 control-label", 82 | "input": "col-lg-8 controls", 83 | "error": "error has-error", 84 | "msg-labelInfo": "help-block", 85 | "msg-info": "help-block", 86 | "msg-message": "help-block", 87 | "msg-error": "help-block", 88 | "multiValue": "well well-sm multi-value", 89 | "multiInfo": "small", 90 | "multiRestore": "well well-sm multi-restore" 91 | } 92 | } ); 93 | 94 | $.extend( true, DataTable.ext.buttons, { 95 | create: { 96 | formButtons: { 97 | className: 'btn-primary' 98 | } 99 | }, 100 | edit: { 101 | formButtons: { 102 | className: 'btn-primary' 103 | } 104 | }, 105 | remove: { 106 | formButtons: { 107 | className: 'btn-danger' 108 | } 109 | } 110 | } ); 111 | 112 | 113 | /* 114 | * Bootstrap display controller - this is effectively a proxy to the Bootstrap 115 | * modal control. 116 | */ 117 | 118 | var self; 119 | 120 | DataTable.Editor.display.bootstrap = $.extend( true, {}, DataTable.Editor.models.displayController, { 121 | /* 122 | * API methods 123 | */ 124 | "init": function ( dte ) { 125 | // init can be called multiple times (one for each Editor instance), but 126 | // we only support a single construct here (shared between all Editor 127 | // instances) 128 | if ( ! self._dom.content ) { 129 | self._dom.content = $( 130 | ''+ 131 | ''+ 132 | ''+ 133 | ''+ 134 | '' 135 | ); 136 | 137 | self._dom.close = $('×'); 138 | self._dom.modalContent = self._dom.content.find('div.modal-content'); 139 | 140 | self._dom.close.click( function () { 141 | self._dte.close('icon'); 142 | } ); 143 | 144 | $(document).on('click', 'div.modal', function (e) { 145 | if ( $(e.target).hasClass('modal') && self._shown ) { 146 | self._dte.background(); 147 | } 148 | } ); 149 | } 150 | 151 | // Add `form-control` to required elements 152 | dte.on( 'displayOrder.dtebs', function ( e, display, action, form ) { 153 | $.each( dte.s.fields, function ( key, field ) { 154 | $('input:not([type=checkbox]):not([type=radio]), select, textarea', field.node() ) 155 | .addClass( 'form-control' ); 156 | } ); 157 | } ); 158 | 159 | return self; 160 | }, 161 | 162 | "open": function ( dte, append, callback ) { 163 | if ( self._shown ) { 164 | if ( callback ) { 165 | callback(); 166 | } 167 | return; 168 | } 169 | 170 | self._dte = dte; 171 | self._shown = true; 172 | 173 | var content = self._dom.modalContent; 174 | content.children().detach(); 175 | content.append( append ); 176 | 177 | $('div.modal-header', append).prepend( self._dom.close ); 178 | 179 | $(self._dom.content) 180 | .one('shown.bs.modal', function () { 181 | // Can only give elements focus when shown 182 | if ( self._dte.s.setFocus ) { 183 | self._dte.s.setFocus.focus(); 184 | } 185 | 186 | if ( callback ) { 187 | callback(); 188 | } 189 | }) 190 | .one('hidden', function () { 191 | self._shown = false; 192 | }) 193 | .appendTo( 'body' ) 194 | .modal( { 195 | backdrop: "static", 196 | keyboard: false 197 | } ); 198 | }, 199 | 200 | "close": function ( dte, callback ) { 201 | if ( !self._shown ) { 202 | if ( callback ) { 203 | callback(); 204 | } 205 | return; 206 | } 207 | 208 | $(self._dom.content) 209 | .one( 'hidden.bs.modal', function () { 210 | $(this).detach(); 211 | } ) 212 | .modal('hide'); 213 | 214 | self._dte = dte; 215 | self._shown = false; 216 | 217 | if ( callback ) { 218 | callback(); 219 | } 220 | }, 221 | 222 | node: function ( dte ) { 223 | return self._dom.content[0]; 224 | }, 225 | 226 | 227 | /* 228 | * Private properties 229 | */ 230 | "_shown": false, 231 | "_dte": null, 232 | "_dom": {} 233 | } ); 234 | 235 | self = DataTable.Editor.display.bootstrap; 236 | 237 | 238 | return DataTable.Editor; 239 | })); 240 | -------------------------------------------------------------------------------- /tests/test_editor.py: -------------------------------------------------------------------------------- 1 | from django.db.models import Q 2 | from django.test import TestCase 3 | from rest_framework.test import APIClient 4 | 5 | from albums.models import Album 6 | from albums.views import AlbumViewSet 7 | from rest_framework_datatables_editor.pagination import ( 8 | DatatablesPageNumberPagination 9 | ) 10 | 11 | 12 | class DatatablesEditorTestCase(TestCase): 13 | fixtures = ['test_data'] 14 | 15 | def setUp(self): 16 | self.client = APIClient() 17 | AlbumViewSet.pagination_class = DatatablesPageNumberPagination 18 | 19 | def test_edit_one(self): 20 | data = { 21 | 'action': 'edit', 22 | 'data[1][artist][id]': 2, 23 | 'data[1][name]': 'New name1', 24 | } 25 | response = self.client.post('/api/albums/editor/', data) 26 | result = response.json()['data'] 27 | self.assertEqual(result[0]['name'], 'New name1') 28 | self.assertEqual(result[0]['artist']['id'], 2) 29 | album = Album.objects.get(pk=1) 30 | self.assertEqual([album.name, album.artist.id], ['New name1', 2]) 31 | 32 | def test_edit_two(self): 33 | data = { 34 | 'action': 'edit', 35 | 'data[1][artist][id]': 2, 36 | 'data[1][name]': 'New name1', 37 | 'data[4][artist][id]': 4, 38 | 'data[4][name]': 'New name4', 39 | } 40 | response = self.client.post('/api/albums/editor/', data) 41 | result = response.json()['data'] 42 | order_flag = 0 if result[0]['name'] == 'New name1' else 1 43 | 44 | self.assertEqual(result[order_flag - 0]['name'], 'New name1') 45 | self.assertEqual(result[order_flag - 0]['artist']['id'], 2) 46 | album = Album.objects.get(pk=1) 47 | self.assertEqual([album.name, album.artist.id], ['New name1', 2]) 48 | 49 | self.assertEqual(result[order_flag - 1]['name'], 'New name4') 50 | self.assertEqual(result[order_flag - 1]['artist']['id'], 4) 51 | album = Album.objects.get(pk=4) 52 | self.assertEqual([album.name, album.artist.id], ['New name4', 4]) 53 | 54 | def test_edit_wrong_foreignkey(self): 55 | data = { 56 | 'action': 'edit', 57 | 'data[1][artist][id]': 1, 58 | 'data[1][name]': 'New name1', 59 | } 60 | response = self.client.post('/api/albums/editor/', data) 61 | self.assertEquals(response.status_code, 404) 62 | 63 | def test_edit_wrong_id(self): 64 | data = { 65 | 'action': 'edit', 66 | 'data[20][rank]': 3, 67 | 'data[20][artist][id]': 2, 68 | 'data[20][name]': 'New name', 69 | 'data[20][year]': 1955 70 | } 71 | response = self.client.post('/api/albums/editor/', data) 72 | expected = {'detail': 'Not found.'} 73 | result = response.json() 74 | self.assertEqual(result, expected) 75 | 76 | def test_remove_one(self): 77 | data = { 78 | 'action': 'remove', 79 | 'data[7][rank]': '', 80 | 'data[7][DT_RowId]': '7', 81 | 'data[7][DT_RowAttr][data-pk]': '7', 82 | } 83 | response = self.client.post('/api/albums/editor/', data) 84 | expected = {'data': []} 85 | result = response.json() 86 | self.assertEqual(result, expected) 87 | self.assertQuerysetEqual(Album.objects.filter(pk=7), []) 88 | self.assertEqual(Album.objects.all().count(), 14) 89 | 90 | def test_remove_two(self): 91 | data = { 92 | 'action': 'remove', 93 | 'data[7][rank]': '', 94 | 'data[8][rank]': '', 95 | } 96 | response = self.client.post('/api/albums/editor/', data) 97 | expected = {'data': []} 98 | result = response.json() 99 | self.assertEqual(result, expected) 100 | self.assertQuerysetEqual(Album.objects.filter(Q(pk=7) | Q(pk=8)), []) 101 | self.assertEqual(Album.objects.all().count(), 13) 102 | 103 | def test_create(self): 104 | data = { 105 | 'action': 'create', 106 | 'data[0][rank]': 16, 107 | 'data[0][artist][id]': 2, 108 | 'data[0][name]': 'New name', 109 | 'data[0][year]': 1950, 110 | } 111 | response = self.client.post('/api/albums/editor/', data) 112 | result = response.json()['data'][0] 113 | self.assertEqual(result['name'], 'New name') 114 | self.assertEqual(result['artist']['id'], 2) 115 | self.assertEqual(result['DT_RowId'], 16) 116 | album = Album.objects.get(pk=16) 117 | self.assertEqual([album.name, album.artist.id], ['New name', 2]) 118 | 119 | def test_create_wrong_foreignkey(self): 120 | data = { 121 | 'action': 'create', 122 | 'data[0][rank]': 16, 123 | 'data[0][artist][id]': 1, 124 | 'data[0][name]': 'New name', 125 | 'data[0][year]': 1950, 126 | } 127 | response = self.client.post('/api/albums/editor/', data) 128 | self.assertEquals(response.status_code, 404) 129 | self.assertEqual(Album.objects.all().count(), 15) 130 | 131 | def test_one_wrong_field_name(self): 132 | data = { 133 | 'action': 'create', 134 | 'data[0][incorrect_field]': 16, 135 | 'data[0][artist][id]': 2, 136 | 'data[0][name]': 'New name', 137 | 'data[0][year]': 1950, 138 | } 139 | response = self.client.post('/api/albums/editor/', data) 140 | self.assertEquals(response.status_code, 400) 141 | result = response.json()[0] 142 | expected = ('The following fields are present in the request, ' 143 | 'but they are not writable: incorrect_field') 144 | self.assertEqual(result, expected) 145 | 146 | def test_two_wrong_field_name(self): 147 | data = { 148 | 'action': 'create', 149 | 'data[0][incorrect_field1]': 16, 150 | 'data[0][incorrect_field2]': 16, 151 | } 152 | response = self.client.post('/api/albums/editor/', data) 153 | expected1 = ('The following fields are present in the request, ' 154 | 'but they are not writable:') 155 | expected2 = 'incorrect_field1' 156 | expected3 = 'incorrect_field2' 157 | self.assertContains(response, expected1, count=1, status_code=400) 158 | self.assertContains(response, expected2, count=1, status_code=400) 159 | self.assertContains(response, expected3, count=1, status_code=400) 160 | -------------------------------------------------------------------------------- /example/static/js/editor.bootstrap4.js: -------------------------------------------------------------------------------- 1 | /*! Bootstrap integration for DataTables' Editor 2 | * ©2015 SpryMedia Ltd - datatables.net/license 3 | */ 4 | 5 | (function( factory ){ 6 | if ( typeof define === 'function' && define.amd ) { 7 | // AMD 8 | define( ['jquery', 'datatables.net-bs4', 'datatables.net-editor'], function ( $ ) { 9 | return factory( $, window, document ); 10 | } ); 11 | } 12 | else if ( typeof exports === 'object' ) { 13 | // CommonJS 14 | module.exports = function (root, $) { 15 | if ( ! root ) { 16 | root = window; 17 | } 18 | 19 | if ( ! $ || ! $.fn.dataTable ) { 20 | $ = require('datatables.net-bs4')(root, $).$; 21 | } 22 | 23 | if ( ! $.fn.dataTable.Editor ) { 24 | require('datatables.net-editor')(root, $); 25 | } 26 | 27 | return factory( $, root, root.document ); 28 | }; 29 | } 30 | else { 31 | // Browser 32 | factory( jQuery, window, document ); 33 | } 34 | }(function( $, window, document, undefined ) { 35 | 'use strict'; 36 | var DataTable = $.fn.dataTable; 37 | 38 | 39 | /* 40 | * Set the default display controller to be our bootstrap control 41 | */ 42 | DataTable.Editor.defaults.display = "bootstrap"; 43 | 44 | 45 | /* 46 | * Alter the buttons that Editor adds to TableTools so they are suitable for bootstrap 47 | */ 48 | var i18nDefaults = DataTable.Editor.defaults.i18n; 49 | i18nDefaults.create.title = ''+i18nDefaults.create.title+''; 50 | i18nDefaults.edit.title = ''+i18nDefaults.edit.title+''; 51 | i18nDefaults.remove.title = ''+i18nDefaults.remove.title+''; 52 | 53 | var tt = DataTable.TableTools; 54 | if ( tt ) { 55 | tt.BUTTONS.editor_create.formButtons[0].className = "btn btn-primary"; 56 | tt.BUTTONS.editor_edit.formButtons[0].className = "btn btn-primary"; 57 | tt.BUTTONS.editor_remove.formButtons[0].className = "btn btn-danger"; 58 | } 59 | 60 | 61 | /* 62 | * Change the default classes from Editor to be classes for Bootstrap 63 | */ 64 | $.extend( true, $.fn.dataTable.Editor.classes, { 65 | "header": { 66 | "wrapper": "DTE_Header modal-header" 67 | }, 68 | "body": { 69 | "wrapper": "DTE_Body modal-body" 70 | }, 71 | "footer": { 72 | "wrapper": "DTE_Footer modal-footer" 73 | }, 74 | "form": { 75 | "tag": "form-horizontal", 76 | "button": "btn", 77 | "buttonInternal": "btn btn-outline-secondary" 78 | }, 79 | "field": { 80 | "wrapper": "DTE_Field form-group row", 81 | "label": "col-lg-4 col-form-label", 82 | "input": "col-lg-8", 83 | "error": "error is-invalid", 84 | "msg-labelInfo": "form-text text-secondary small", 85 | "msg-info": "form-text text-secondary small", 86 | "msg-message": "form-text text-secondary small", 87 | "msg-error": "form-text text-danger small", 88 | "multiValue": "card multi-value", 89 | "multiInfo": "small", 90 | "multiRestore": "card multi-restore" 91 | } 92 | } ); 93 | 94 | $.extend( true, DataTable.ext.buttons, { 95 | create: { 96 | formButtons: { 97 | className: 'btn-primary' 98 | } 99 | }, 100 | edit: { 101 | formButtons: { 102 | className: 'btn-primary' 103 | } 104 | }, 105 | remove: { 106 | formButtons: { 107 | className: 'btn-danger' 108 | } 109 | } 110 | } ); 111 | 112 | 113 | /* 114 | * Bootstrap display controller - this is effectively a proxy to the Bootstrap 115 | * modal control. 116 | */ 117 | 118 | var self; 119 | 120 | DataTable.Editor.display.bootstrap = $.extend( true, {}, DataTable.Editor.models.displayController, { 121 | /* 122 | * API methods 123 | */ 124 | "init": function ( dte ) { 125 | // init can be called multiple times (one for each Editor instance), but 126 | // we only support a single construct here (shared between all Editor 127 | // instances) 128 | if ( ! self._dom.content ) { 129 | self._dom.content = $( 130 | ''+ 131 | ''+ 132 | ''+ 133 | ''+ 134 | '' 135 | ); 136 | 137 | self._dom.close = $('×'); 138 | 139 | self._dom.close.click( function () { 140 | self._dte.close('icon'); 141 | } ); 142 | 143 | $(document).on('click', 'div.modal', function (e) { 144 | if ( $(e.target).hasClass('modal') && self._shown ) { 145 | self._dte.background(); 146 | } 147 | } ); 148 | } 149 | 150 | // Add `form-control` to required elements 151 | dte.on( 'displayOrder.dtebs', function ( e, display, action, form ) { 152 | $.each( dte.s.fields, function ( key, field ) { 153 | $('input:not([type=checkbox]):not([type=radio]), select, textarea', field.node() ) 154 | .addClass( 'form-control' ); 155 | } ); 156 | } ); 157 | 158 | return self; 159 | }, 160 | 161 | "open": function ( dte, append, callback ) { 162 | if ( self._shown ) { 163 | if ( callback ) { 164 | callback(); 165 | } 166 | return; 167 | } 168 | 169 | self._dte = dte; 170 | self._shown = true; 171 | self._fullyDisplayed = false; 172 | 173 | var content = self._dom.content.find('div.modal-content'); 174 | content.children().detach(); 175 | content.append( append ); 176 | 177 | $('div.modal-header', append).append( self._dom.close ); 178 | 179 | $(self._dom.content) 180 | .one('shown.bs.modal', function () { 181 | // Can only give elements focus when shown 182 | if ( self._dte.s.setFocus ) { 183 | self._dte.s.setFocus.focus(); 184 | } 185 | 186 | self._fullyDisplayed = true; 187 | 188 | if ( callback ) { 189 | callback(); 190 | } 191 | }) 192 | .one('hidden', function () { 193 | self._shown = false; 194 | }) 195 | .appendTo( 'body' ) 196 | .modal( { 197 | backdrop: "static", 198 | keyboard: false 199 | } ); 200 | }, 201 | 202 | "close": function ( dte, callback ) { 203 | if ( !self._shown ) { 204 | if ( callback ) { 205 | callback(); 206 | } 207 | return; 208 | } 209 | 210 | // Check if actually displayed or not before hiding. BS4 doesn't like `hide` 211 | // before it has been fully displayed 212 | if ( ! self._fullyDisplayed ) { 213 | $(self._dom.content) 214 | .one('shown.bs.modal', function () { 215 | self.close( dte, callback ); 216 | } ); 217 | 218 | return; 219 | } 220 | 221 | $(self._dom.content) 222 | .one( 'hidden.bs.modal', function () { 223 | $(this).detach(); 224 | } ) 225 | .modal('hide'); 226 | 227 | self._dte = dte; 228 | self._shown = false; 229 | self._fullyDisplayed = false; 230 | 231 | if ( callback ) { 232 | callback(); 233 | } 234 | }, 235 | 236 | node: function ( dte ) { 237 | return self._dom.content[0]; 238 | }, 239 | 240 | 241 | /* 242 | * Private properties 243 | */ 244 | "_shown": false, 245 | "_dte": null, 246 | "_dom": {} 247 | } ); 248 | 249 | self = DataTable.Editor.display.bootstrap; 250 | 251 | 252 | return DataTable.Editor; 253 | })); 254 | -------------------------------------------------------------------------------- /example/albums/fixtures/test_data.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "model": "albums.genre", 4 | "pk": 1, 5 | "fields": { 6 | "name": "Rock & Roll" 7 | } 8 | }, 9 | { 10 | "model": "albums.genre", 11 | "pk": 2, 12 | "fields": { 13 | "name": "Psychedelic Rock" 14 | } 15 | }, 16 | { 17 | "model": "albums.genre", 18 | "pk": 3, 19 | "fields": { 20 | "name": "Pop Rock" 21 | } 22 | }, 23 | { 24 | "model": "albums.genre", 25 | "pk": 4, 26 | "fields": { 27 | "name": "Folk Rock" 28 | } 29 | }, 30 | { 31 | "model": "albums.genre", 32 | "pk": 5, 33 | "fields": { 34 | "name": "Blues Rock" 35 | } 36 | }, 37 | { 38 | "model": "albums.genre", 39 | "pk": 6, 40 | "fields": { 41 | "name": "Soul" 42 | } 43 | }, 44 | { 45 | "model": "albums.genre", 46 | "pk": 7, 47 | "fields": { 48 | "name": "Classic Rock" 49 | } 50 | }, 51 | { 52 | "model": "albums.genre", 53 | "pk": 8, 54 | "fields": { 55 | "name": "Punk" 56 | } 57 | }, 58 | { 59 | "model": "albums.genre", 60 | "pk": 9, 61 | "fields": { 62 | "name": "New Wave" 63 | } 64 | }, 65 | { 66 | "model": "albums.genre", 67 | "pk": 10, 68 | "fields": { 69 | "name": "Rhythm & Blues" 70 | } 71 | }, 72 | { 73 | "model": "albums.genre", 74 | "pk": 11, 75 | "fields": { 76 | "name": "Experimental" 77 | } 78 | }, 79 | { 80 | "model": "albums.genre", 81 | "pk": 12, 82 | "fields": { 83 | "name": "Modal" 84 | } 85 | }, 86 | { 87 | "model": "albums.genre", 88 | "pk": 13, 89 | "fields": { 90 | "name": "Garage Rock" 91 | } 92 | }, 93 | { 94 | "model": "albums.genre", 95 | "pk": 14, 96 | "fields": { 97 | "name": "Art Rock" 98 | } 99 | }, 100 | { 101 | "model": "albums.artist", 102 | "pk": 2, 103 | "fields": { 104 | "name": "The Beatles" 105 | } 106 | }, 107 | { 108 | "model": "albums.artist", 109 | "pk": 3, 110 | "fields": { 111 | "name": "The Beach Boys" 112 | } 113 | }, 114 | { 115 | "model": "albums.artist", 116 | "pk": 4, 117 | "fields": { 118 | "name": "Bob Dylan" 119 | } 120 | }, 121 | { 122 | "model": "albums.artist", 123 | "pk": 5, 124 | "fields": { 125 | "name": "Marvin Gaye" 126 | } 127 | }, 128 | { 129 | "model": "albums.artist", 130 | "pk": 6, 131 | "fields": { 132 | "name": "The Rolling Stones" 133 | } 134 | }, 135 | { 136 | "model": "albums.artist", 137 | "pk": 7, 138 | "fields": { 139 | "name": "The Clash" 140 | } 141 | }, 142 | { 143 | "model": "albums.artist", 144 | "pk": 8, 145 | "fields": { 146 | "name": "Elvis Presley" 147 | } 148 | }, 149 | { 150 | "model": "albums.artist", 151 | "pk": 9, 152 | "fields": { 153 | "name": "Miles Davis" 154 | } 155 | }, 156 | { 157 | "model": "albums.artist", 158 | "pk": 10, 159 | "fields": { 160 | "name": "The Velvet Underground" 161 | } 162 | }, 163 | { 164 | "model": "albums.artist", 165 | "pk": 11, 166 | "fields": { 167 | "name": "The Jimi Hendrix Experience" 168 | } 169 | }, 170 | { 171 | "model": "albums.album", 172 | "pk": 1, 173 | "fields": { 174 | "name": "Sgt. Pepper's Lonely Hearts Club Band", 175 | "rank": 1, 176 | "year": 1967, 177 | "artist": 2, 178 | "genres": [ 179 | 2, 180 | 1 181 | ] 182 | } 183 | }, 184 | { 185 | "model": "albums.album", 186 | "pk": 2, 187 | "fields": { 188 | "name": "Pet Sounds", 189 | "rank": 2, 190 | "year": 1966, 191 | "artist": 3, 192 | "genres": [ 193 | 3, 194 | 2 195 | ] 196 | } 197 | }, 198 | { 199 | "model": "albums.album", 200 | "pk": 3, 201 | "fields": { 202 | "name": "Revolver", 203 | "rank": 3, 204 | "year": 1966, 205 | "artist": 2, 206 | "genres": [ 207 | 3, 208 | 2 209 | ] 210 | } 211 | }, 212 | { 213 | "model": "albums.album", 214 | "pk": 4, 215 | "fields": { 216 | "name": "Highway 61 Revisited", 217 | "rank": 4, 218 | "year": 1965, 219 | "artist": 4, 220 | "genres": [ 221 | 5, 222 | 4 223 | ] 224 | } 225 | }, 226 | { 227 | "model": "albums.album", 228 | "pk": 5, 229 | "fields": { 230 | "name": "Rubber Soul", 231 | "rank": 5, 232 | "year": 1965, 233 | "artist": 2, 234 | "genres": [ 235 | 3 236 | ] 237 | } 238 | }, 239 | { 240 | "model": "albums.album", 241 | "pk": 6, 242 | "fields": { 243 | "name": "What's Going On", 244 | "rank": 6, 245 | "year": 1971, 246 | "artist": 5, 247 | "genres": [ 248 | 6 249 | ] 250 | } 251 | }, 252 | { 253 | "model": "albums.album", 254 | "pk": 7, 255 | "fields": { 256 | "name": "Exile on Main St.", 257 | "rank": 7, 258 | "year": 1972, 259 | "artist": 6, 260 | "genres": [ 261 | 5, 262 | 7, 263 | 1 264 | ] 265 | } 266 | }, 267 | { 268 | "model": "albums.album", 269 | "pk": 8, 270 | "fields": { 271 | "name": "London Calling", 272 | "rank": 8, 273 | "year": 1979, 274 | "artist": 7, 275 | "genres": [ 276 | 9, 277 | 8 278 | ] 279 | } 280 | }, 281 | { 282 | "model": "albums.album", 283 | "pk": 9, 284 | "fields": { 285 | "name": "Blonde on Blonde", 286 | "rank": 9, 287 | "year": 1966, 288 | "artist": 4, 289 | "genres": [ 290 | 4, 291 | 10 292 | ] 293 | } 294 | }, 295 | { 296 | "model": "albums.album", 297 | "pk": 10, 298 | "fields": { 299 | "name": "The Beatles (\"The White Album\")", 300 | "rank": 10, 301 | "year": 1968, 302 | "artist": 2, 303 | "genres": [ 304 | 11, 305 | 3, 306 | 2, 307 | 1 308 | ] 309 | } 310 | }, 311 | { 312 | "model": "albums.album", 313 | "pk": 11, 314 | "fields": { 315 | "name": "The Sun Sessions", 316 | "rank": 11, 317 | "year": 1976, 318 | "artist": 8, 319 | "genres": [ 320 | 1 321 | ] 322 | } 323 | }, 324 | { 325 | "model": "albums.album", 326 | "pk": 12, 327 | "fields": { 328 | "name": "Kind of Blue", 329 | "rank": 12, 330 | "year": 1959, 331 | "artist": 9, 332 | "genres": [ 333 | 12 334 | ] 335 | } 336 | }, 337 | { 338 | "model": "albums.album", 339 | "pk": 13, 340 | "fields": { 341 | "name": "The Velvet Underground & Nico", 342 | "rank": 13, 343 | "year": 1967, 344 | "artist": 10, 345 | "genres": [ 346 | 14, 347 | 11, 348 | 13 349 | ] 350 | } 351 | }, 352 | { 353 | "model": "albums.album", 354 | "pk": 14, 355 | "fields": { 356 | "name": "Abbey Road", 357 | "rank": 14, 358 | "year": 1969, 359 | "artist": 2, 360 | "genres": [ 361 | 7, 362 | 3, 363 | 2 364 | ] 365 | } 366 | }, 367 | { 368 | "model": "albums.album", 369 | "pk": 15, 370 | "fields": { 371 | "name": "Are You Experienced", 372 | "rank": 15, 373 | "year": 1967, 374 | "artist": 11, 375 | "genres": [ 376 | 5, 377 | 2 378 | ] 379 | } 380 | } 381 | ] 382 | -------------------------------------------------------------------------------- /tests/test_renderers.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from django.test import TestCase 4 | from rest_framework.test import APIRequestFactory 5 | from rest_framework.views import APIView 6 | 7 | from rest_framework_datatables_editor.renderers import DatatablesRenderer 8 | 9 | 10 | class DatatablesRendererTestCase(TestCase): 11 | def setUp(self): 12 | self.factory = APIRequestFactory() 13 | 14 | def test_render_no_data(self): 15 | renderer = DatatablesRenderer() 16 | content = renderer.render(None) 17 | self.assertEquals(content, bytes()) 18 | 19 | def test_render_no_pagination1(self): 20 | obj = [{'foo': 'bar'}] 21 | renderer = DatatablesRenderer() 22 | view = APIView() 23 | request = view.initialize_request( 24 | self.factory.get('/api/foo/?format=datatables&draw=1') 25 | ) 26 | content = renderer.render(obj, 'application/json', 27 | {'request': request, 'view': view}) 28 | expected = { 29 | 'recordsTotal': 1, 30 | 'recordsFiltered': 1, 31 | 'data': [{'foo': 'bar'}], 32 | 'draw': 1 33 | } 34 | self.assertEquals(json.loads(content.decode('utf-8')), expected) 35 | 36 | def test_render_no_pagination1_1(self): 37 | obj = [{'foo': 'bar'}] 38 | renderer = DatatablesRenderer() 39 | view = APIView() 40 | request = view.initialize_request( 41 | self.factory.get('/api/foo.datatables?draw=1') 42 | ) 43 | content = renderer.render(obj, 'application/json', 44 | {'request': request, 'view': view}) 45 | expected = { 46 | 'recordsTotal': 1, 47 | 'recordsFiltered': 1, 48 | 'data': [{'foo': 'bar'}], 49 | 'draw': 1 50 | } 51 | self.assertEquals(json.loads(content.decode('utf-8')), expected) 52 | 53 | def test_render_no_pagination2(self): 54 | obj = {'results': [{'foo': 'bar'}, {'spam': 'eggs'}]} 55 | renderer = DatatablesRenderer() 56 | view = APIView() 57 | request = view.initialize_request( 58 | self.factory.get('/api/foo/?format=datatables&draw=1') 59 | ) 60 | content = renderer.render(obj, 'application/json', 61 | {'request': request, 'view': view}) 62 | expected = { 63 | 'recordsTotal': 2, 64 | 'recordsFiltered': 2, 65 | 'data': [{'foo': 'bar'}, {'spam': 'eggs'}], 66 | 'draw': 1 67 | } 68 | self.assertEquals(json.loads(content.decode('utf-8')), expected) 69 | 70 | def test_render_no_pagination3(self): 71 | obj = {'results': [{'foo': 'bar'}, {'spam': 'eggs'}]} 72 | renderer = DatatablesRenderer() 73 | view = APIView() 74 | view._datatables_total_count = 4 75 | view._datatables_filtered_count = 2 76 | request = view.initialize_request( 77 | self.factory.get('/api/foo/?format=datatables&draw=1') 78 | ) 79 | content = renderer.render(obj, 'application/json', 80 | {'request': request, 'view': view}) 81 | expected = { 82 | 'recordsTotal': 4, 83 | 'recordsFiltered': 2, 84 | 'data': [{'foo': 'bar'}, {'spam': 'eggs'}], 85 | 'draw': 1 86 | } 87 | self.assertEquals(json.loads(content.decode('utf-8')), expected) 88 | 89 | def test_render(self): 90 | obj = {'recordsTotal': 4, 'recordsFiltered': 2, 91 | 'data': [{'foo': 'bar'}, {'spam': 'eggs'}]} 92 | renderer = DatatablesRenderer() 93 | view = APIView() 94 | request = view.initialize_request( 95 | self.factory.get('/api/foo/?format=datatables&draw=2') 96 | ) 97 | content = renderer.render(obj, 'application/json', 98 | {'request': request, 'view': view}) 99 | expected = { 100 | 'recordsTotal': 4, 101 | 'recordsFiltered': 2, 102 | 'data': [{'foo': 'bar'}, {'spam': 'eggs'}], 103 | 'draw': 2 104 | } 105 | self.assertEquals(json.loads(content.decode('utf-8')), expected) 106 | 107 | def test_render_extra_json(self): 108 | class TestAPIView(APIView): 109 | def test_callback(self): 110 | return "key", "value" 111 | 112 | class Meta: 113 | datatables_extra_json = ('test_callback',) 114 | 115 | obj = {'recordsTotal': 4, 'recordsFiltered': 2, 116 | 'data': [{'foo': 'bar'}, {'spam': 'eggs'}]} 117 | renderer = DatatablesRenderer() 118 | view = TestAPIView() 119 | request = view.initialize_request( 120 | self.factory.get('/api/foo/?format=datatables&draw=2') 121 | ) 122 | content = renderer.render(obj, 'application/json', 123 | {'request': request, 'view': view}) 124 | expected = { 125 | 'recordsTotal': 4, 126 | 'recordsFiltered': 2, 127 | 'data': [{'foo': 'bar'}, {'spam': 'eggs'}], 128 | 'key': 'value', 129 | 'draw': 2 130 | } 131 | self.assertEquals(json.loads(content.decode('utf-8')), expected) 132 | 133 | def test_render_extra_json_attr_missing(self): 134 | class TestAPIView(APIView): 135 | class Meta: 136 | datatables_extra_json = ('test_callback',) 137 | 138 | obj = {'recordsTotal': 4, 'recordsFiltered': 2, 139 | 'data': [{'foo': 'bar'}, {'spam': 'eggs'}]} 140 | renderer = DatatablesRenderer() 141 | view = TestAPIView() 142 | request = view.initialize_request( 143 | self.factory.get('/api/foo/?format=datatables&draw=2') 144 | ) 145 | try: 146 | renderer.render(obj, 'application/json', 147 | {'request': request, 'view': view}) 148 | self.assertEqual(True, False, "TypeError expected; did not occur.") 149 | except TypeError as e: 150 | self.assertEqual( 151 | e.__str__(), 152 | "extra_json_funcs: test_callback not a view method." 153 | ) 154 | 155 | def test_render_extra_json_attr_not_callable(self): 156 | class TestAPIView(APIView): 157 | test_callback = 'gotcha' 158 | 159 | class Meta: 160 | datatables_extra_json = ('test_callback',) 161 | 162 | obj = {'recordsTotal': 4, 'recordsFiltered': 2, 163 | 'data': [{'foo': 'bar'}, {'spam': 'eggs'}]} 164 | renderer = DatatablesRenderer() 165 | view = TestAPIView() 166 | request = view.initialize_request( 167 | self.factory.get('/api/foo/?format=datatables&draw=2') 168 | ) 169 | try: 170 | renderer.render(obj, 'application/json', 171 | {'request': request, 'view': view}) 172 | self.assertEqual(True, False, "TypeError expected; did not occur.") 173 | except TypeError as e: 174 | self.assertEqual(e.__str__(), 175 | "extra_json_funcs: test_callback not callable.") 176 | 177 | def test_render_extra_json_clashes(self): 178 | class TestAPIView(APIView): 179 | def test_callback(self): 180 | return "recordsTotal", "this could be bad" 181 | 182 | class Meta: 183 | datatables_extra_json = ('test_callback',) 184 | 185 | obj = {'recordsTotal': 4, 'recordsFiltered': 2, 186 | 'data': [{'foo': 'bar'}, {'spam': 'eggs'}]} 187 | renderer = DatatablesRenderer() 188 | view = TestAPIView() 189 | request = view.initialize_request( 190 | self.factory.get('/api/foo/?format=datatables&draw=2') 191 | ) 192 | try: 193 | renderer.render(obj, 'application/json', 194 | {'request': request, 'view': view}) 195 | self.assertEqual(True, False, "Value expected; did not occur.") 196 | except ValueError as e: 197 | self.assertEqual(e.__str__(), "Duplicate key found: recordsTotal") 198 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | django-rest-framework-datatables-editor 2 | ======================================= 3 | 4 | |build-status-image| |codecov-image| |documentation-status-image| |pypi-version| |py-versions| |dj-versions| 5 | 6 | Overview 7 | -------- 8 | 9 | This package provides seamless integration between `Django REST framework `_ and `Datatables `_ with supporting `Datatables editor `_. 10 | 11 | - It handles searching, filtering, ordering and most usecases you can imagine with Datatables. 12 | 13 | - You don't have to create a different API, your API still work exactly the same . 14 | 15 | How to use 16 | ---------- 17 | 18 | Install 19 | ~~~~~~~ 20 | 21 | .. code:: bash 22 | 23 | $ pip install djangorestframework-datatables-editor 24 | 25 | If you need the functionality of the editor, you also need to download the data editor from `here `_, the JS+CSS version, and put the downloaded files in ``static`` folder. 26 | 27 | Configuration 28 | ~~~~~~~~~~~~~ 29 | 30 | To enable Datatables support in your project, add ``'rest_framework_datatables_editor'`` to your ``INSTALLED_APPS``, and modify your ``REST_FRAMEWORK`` settings like this: 31 | 32 | .. code:: python 33 | 34 | REST_FRAMEWORK = { 35 | 'DEFAULT_RENDERER_CLASSES': ( 36 | 'rest_framework.renderers.JSONRenderer', 37 | 'rest_framework.renderers.BrowsableAPIRenderer', 38 | 'rest_framework_datatables_editor.renderers.DatatablesRenderer', 39 | ), 40 | 'DEFAULT_FILTER_BACKENDS': ( 41 | 'rest_framework_datatables_editor.filters.DatatablesFilterBackend', 42 | ), 43 | 'DEFAULT_PAGINATION_CLASS': 'rest_framework_datatables_editor.pagination.DatatablesPageNumberPagination', 44 | 'PAGE_SIZE': 50, 45 | } 46 | 47 | For using Datatables editor you should use DatatablesEditorModelViewSet instead ModelViewSet or add EditorModelMixin to your views. 48 | 49 | And that's it ! 50 | ~~~~~~~~~~~~~~~ 51 | 52 | Your API is now fully compatible with Datatables and will provide searching, filtering, ordering and pagination without any modification of your API code ! For using Datatables editor you should use DatatablesEditorModelViewSet instead ModelViewSet or add EditorModelMixin to your views. 53 | 54 | Configuring Datatables and Datatables editor 55 | -------------------------------------------- 56 | 57 | - The URL for connecting datatables is the URL of your view with ``?format=datatables`` 58 | - The URL connecting datatables editor is the URL of your view with ``editor/`` 59 | - Full documentation is available on `Read the Docs `_ ! 60 | - Also you'll need download `Datatables editor `_. 61 | 62 | Example of HTML code: 63 | 64 | .. code:: html 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | Rolling Stone Top 500 albums of all time 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | 85 | 86 | Rank 87 | Artist 88 | Album name 89 | Year 90 | Genres 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 | 148 | 149 | 150 | 151 | Thanks 152 | ------------ 153 | 154 | The project is based on the project `django-rest-framework-datatables `_ by `David Jean Louis `_ 155 | 156 | 157 | Requirements 158 | ------------ 159 | 160 | - Python (2.7, 3.4, 3.5, 3.6, 3.7, 3.8) 161 | - Django (1.9, 1.11, 2.0, 2.1, 2.2, 3.0) 162 | - Django REST Framework (3.9, 3.10, 3.11) 163 | 164 | Example project 165 | --------------- 166 | 167 | To play with the example project, just clone the repository and run the dev server. 168 | 169 | .. code:: bash 170 | 171 | $ git clone https://github.com/VVyacheslav/DRF-datatables-editor.git 172 | $ cd DRF-datatables-editor 173 | Activate virtualenv. 174 | $ pip install -r requirements-dev.txt 175 | $ python example/manage.py runserver 176 | $ firefox http://127.0.0.1:8000 177 | 178 | Testing 179 | ------- 180 | 181 | Install development requirements. 182 | 183 | .. code:: bash 184 | 185 | $ pip install -r requirements-dev.txt 186 | 187 | Run the tests. 188 | 189 | .. code:: bash 190 | 191 | $ python example/manage.py test 192 | 193 | You can also use the excellent `tox`_ testing tool to run the tests 194 | against all supported versions of Python and Django. Install tox 195 | globally, and then simply run: 196 | 197 | .. code:: bash 198 | 199 | $ tox 200 | 201 | If you want to check the coverage, use: 202 | 203 | .. code:: bash 204 | 205 | $ coverage run ./example/manage.py test 206 | $ coverage report -m 207 | 208 | 209 | .. _tox: http://tox.readthedocs.org/en/latest/ 210 | 211 | .. |build-status-image| image:: https://img.shields.io/github/workflow/status/VVyacheslav/django-rest-framework-datatables-editor/build/master?style=flat-square 212 | :alt: Build 213 | 214 | .. |codecov-image| image:: https://codecov.io/gh/VVyacheslav/django-rest-framework-datatables-editor/branch/master/graph/badge.svg?style=flat-square 215 | :target: https://codecov.io/gh/VVyacheslav/django-rest-framework-datatables-editor 216 | 217 | .. |pypi-version| image:: https://img.shields.io/pypi/v/djangorestframework-datatables-editor.svg?style=flat-square 218 | :target: https://pypi.python.org/pypi/djangorestframework-datatables-editor 219 | :alt: Pypi version 220 | 221 | .. |documentation-status-image| image:: https://readthedocs.org/projects/drf-datatables-editor/badge/?version=latest&style=flat-square 222 | :target: https://drf-datatables-editor.readthedocs.io/en/latest/?badge=latest 223 | :alt: Documentation Status 224 | 225 | .. |py-versions| image:: https://img.shields.io/pypi/pyversions/djangorestframework-datatables-editor.svg?style=flat-square 226 | :target: https://img.shields.io/pypi/pyversions/djangorestframework-datatables-editor.svg 227 | :alt: Python versions 228 | 229 | .. |dj-versions| image:: https://img.shields.io/pypi/djversions/djangorestframework-datatables-editor.svg?style=flat-square 230 | :target: https://img.shields.io/pypi/djversions/djangorestframework-datatables-editor.svg 231 | :alt: Django versions 232 | -------------------------------------------------------------------------------- /docs/tutorial.rst: -------------------------------------------------------------------------------- 1 | Tutorial 2 | ======== 3 | 4 | .. note:: 5 | 6 | The purpose of this section is not to replace the excellent `Django REST Framework documentation `_ nor the `Datatables manual `_, it is just to give you hints and gotchas for using your datatables compatible API. 7 | 8 | 9 | Backend code 10 | ------------ 11 | 12 | So we have the following backend code, nothing very complicated if you are familiar with Django and Django REST Framework: 13 | 14 | albums/models.py: 15 | 16 | .. code:: python 17 | 18 | from django.db import models 19 | 20 | 21 | class Genre(models.Model): 22 | name = models.CharField('Name', max_length=80) 23 | 24 | class Meta: 25 | ordering = ['name'] 26 | 27 | def __str__(self): 28 | return self.name 29 | 30 | 31 | class Artist(models.Model): 32 | name = models.CharField('Name', max_length=80) 33 | 34 | class Meta: 35 | ordering = ['name'] 36 | 37 | def __str__(self): 38 | return self.name 39 | 40 | 41 | class Album(models.Model): 42 | name = models.CharField('Name', max_length=80) 43 | rank = models.PositiveIntegerField('Rank') 44 | year = models.PositiveIntegerField('Year') 45 | artist = models.ForeignKey( 46 | Artist, 47 | models.CASCADE, 48 | verbose_name='Artist', 49 | related_name='albums' 50 | ) 51 | genres = models.ManyToManyField( 52 | Genre, 53 | verbose_name='Genres', 54 | related_name='albums' 55 | ) 56 | 57 | class Meta: 58 | ordering = ['name'] 59 | 60 | def __str__(self): 61 | return self.name 62 | 63 | albums/serializers.py: 64 | 65 | .. code:: python 66 | 67 | from rest_framework import serializers 68 | from .models import Album 69 | 70 | 71 | class ArtistSerializer(serializers.ModelSerializer): 72 | id = serializers.IntegerField(read_only=True) 73 | 74 | # if we need to edit a field that is a nested serializer, 75 | # we must override to_internal_value method 76 | def to_internal_value(self, data): 77 | return get_object_or_404(Artist, pk=data['id']) 78 | 79 | class Meta: 80 | model = Artist 81 | fields = ( 82 | 'id', 'name', 83 | ) 84 | 85 | 86 | class AlbumSerializer(serializers.ModelSerializer): 87 | artist = ArtistSerializer() 88 | genres = serializers.SerializerMethodField() 89 | 90 | def get_genres(self, album): 91 | return ', '.join([str(genre) for genre in album.genres.all()]) 92 | 93 | class Meta: 94 | model = Album 95 | fields = ( 96 | 'rank', 'name', 'year', 'artist_name', 'genres', 97 | ) 98 | 99 | albums/views.py: 100 | 101 | .. code:: python 102 | 103 | from django.shortcuts import render 104 | from rest_framework import viewsets 105 | from .models import Album 106 | from .serializers import AlbumSerializer 107 | 108 | 109 | def index(request): 110 | return render(request, 'albums/albums.html') 111 | 112 | 113 | class AlbumViewSet(EditorModelMixin, viewsets.ModelViewSet): 114 | queryset = Album.objects.all().order_by('rank') 115 | serializer_class = AlbumSerializer 116 | 117 | urls.py: 118 | 119 | .. code:: python 120 | 121 | from django.conf.urls import url, include 122 | from rest_framework import routers 123 | from albums import views 124 | 125 | 126 | router = routers.DefaultRouter() 127 | router.register(r'albums', views.AlbumViewSet) 128 | 129 | 130 | urlpatterns = [ 131 | url('^api/', include(router.urls)), 132 | url('', views.index, name='albums') 133 | ] 134 | 135 | A minimal datatable 136 | ------------------- 137 | 138 | In this example, we will build a simple table that will list music albums, we will display 3 columns, the album rank, name and release year. 139 | For the sake of simplicity we will also use HTML5 data attributes (which are supported by Datatables). 140 | 141 | .. code:: html 142 | 143 | 144 | 145 | 146 | 147 | Rolling Stone Top 500 albums of all time 148 | 149 | 150 | 151 | 152 | 153 | 154 | 155 | 156 | 157 | 158 | 159 | 160 | Rank 161 | Album name 162 | Year 163 | 164 | 165 | 166 | 167 | 168 | 169 | 170 | 171 | 172 | 177 | 178 | 179 | 180 | And that's it ! At this point, you should have a fully functional Datatable with search, ordering and pagination ! 181 | 182 | What we just did: 183 | 184 | - included all the necessary CSS and JS files 185 | - set the table ``data-server-side`` attribute to ``true``, to tell Datatables to use the server-side processing mode 186 | - set the table ``data-ajax`` to our API URL with ``?format=datatables`` as query parameter 187 | - set a ``data-data`` attribute for the two columns to tell Datatables what properties must be extracted from the response 188 | - and finally initialized the Datatable via a javascript one-liner. 189 | 190 | 191 | Perhaps you noticed that we didn't use all fields from our serializer in the above example, that's not a problem, django-rest-framework-datatables will automatically filter the fields that are not necessary when processing the request from Datatables. 192 | 193 | If you want to force serialization of fields that are not requested by Datatables you can use the ``datatables_always_serialize`` Meta option in your serializer, here's an example: 194 | 195 | .. code:: python 196 | 197 | class AlbumSerializer(serializers.ModelSerializer): 198 | id = serializers.IntegerField(read_only=True) 199 | class Meta: 200 | model = Album 201 | fields = ( 202 | 'id', 'rank', 'name', 'year', 203 | ) 204 | datatables_always_serialize = ('id', 'rank',) 205 | 206 | In the above example, the fields 'id' and 'rank' will always be serialized in the response regardless of fields requested in the Datatables request. 207 | 208 | .. hint:: 209 | 210 | Alternatively, if you wish to choose which fields to preserve at runtime rather than hardcoding them into your serializer models, use the ``?keep=`` param along with the fields you wish to maintain (comma separated). For example, if you wished to preserve ``id`` and ``rank`` as before, you would simply use the following API call: 211 | 212 | .. code:: html 213 | 214 | data-ajax="/api/albums/?format=datatables&keep=id,rank" 215 | 216 | In order to provide additional context of the data from the view, you can use the ``datatables_extra_json`` Meta option. 217 | 218 | .. code:: python 219 | 220 | class AlbumViewSet(viewsets.ModelViewSet): 221 | queryset = Album.objects.all().order_by('rank') 222 | serializer_class = AlbumSerializer 223 | 224 | def get_options(self): 225 | return "options", { 226 | "artist": [{'label': obj.name, 'value': obj.pk} for obj in Artist.objects.all()], 227 | "genre": [{'label': obj.name, 'value': obj.pk} for obj in Genre.objects.all()] 228 | } 229 | 230 | class Meta: 231 | datatables_extra_json = ('get_options', ) 232 | 233 | In the above example, the 'get_options' method will be called to populate the rendered JSON with the key and value from the method's return tuple. 234 | 235 | .. important:: 236 | 237 | To sum up, **the most important things** to remember here are: 238 | 239 | - don't forget to add ``?format=datatables`` to your API URL 240 | - you must add a **data-data attribute** or specify the column data property via JS for each columns, the name must **match one of the fields of your DRF serializers**. 241 | 242 | 243 | A more complex and detailed example with the ability to edit data 244 | ----------------------------------------------------------------- 245 | 246 | In this example we want to display more informations about the album: 247 | 248 | - the album artist name (``Album.artist`` is a foreignkey to ``Artist`` model) 249 | - the genres (``Album.genres`` is a many to many relation with ``Genre`` model) 250 | 251 | The HTML/JS code will look like this: 252 | 253 | .. code:: html 254 | 255 | 256 | 257 | 258 | 259 | Rolling Stone Top 500 albums of all time 260 | 261 | 262 | 263 | 264 | 265 | 266 | 267 | 268 | 269 | 270 | 271 | 272 | 273 | Rank 274 | Artist 275 | Album name 276 | Year 277 | Genres 278 | 279 | 280 | 281 | 282 | 283 | 284 | 285 | 286 | 287 | 288 | 289 | 290 | 291 | 292 | 293 | 335 | 336 | 337 | 338 | Notice that artist and genres columns have an extra data attribute: ``data-name``, this attribute is necessary to tell to the django-rest-framework-datatables builtin filter backend what field part to use to filter and reorder the queryset. The builtin filter will add ``__icontains`` to the string to perform the filtering/ordering. 339 | 340 | 341 | .. hint:: 342 | 343 | Datatables uses the dot notation in the ``data`` field to populate columns with nested data. In this example, ``artist.name`` refers to the field ``name`` within the nested serializer ``artist``. 344 | 345 | 346 | Authorization 347 | ------------- 348 | 349 | If you use user authorization you must sent a CSRF token with each POST request. To do this, you can use the following script: 350 | 351 | .. code:: html 352 | 353 | 387 | 388 | 389 | Filtering 390 | --------- 391 | 392 | Filtering is based off of the either the ``data`` or ``name`` fields. If you need to filter on multiple fields, you can always pass through multiple variables like so 393 | 394 | .. code:: html 395 | 396 | 400 | 401 | This would allow you to filter the ``artist.name`` column based upon ``name`` or ``year``. 402 | 403 | Because the ``name`` field is used to filter on Django queries, you can use either dot or double-underscore notation as shown in the example above. 404 | 405 | The values within a single ``name`` field are tied together using a logical ``OR`` operator for filtering, while those between ``name`` fields are strung together with an ``AND`` operator. This means that Datatables' multicolumn search functionality is preserved. 406 | 407 | If you need more complex filtering and ordering, you can always implement your own filter backend by inheriting from ``rest_framework_datatables.DatatablesFilterBackend``. 408 | 409 | .. important:: 410 | 411 | To sum up, for **foreign keys and relations** you need to specify a **name for the column** otherwise filtering and ordering will not work. 412 | 413 | 414 | You can see this code live by running the :doc:`example app `. 415 | -------------------------------------------------------------------------------- /tests/test_api.py: -------------------------------------------------------------------------------- 1 | from django.test import TestCase, override_settings 2 | from rest_framework.test import APIClient 3 | 4 | from albums.serializers import AlbumSerializer 5 | from albums.views import AlbumViewSet 6 | from rest_framework_datatables_editor.pagination import ( 7 | DatatablesLimitOffsetPagination, 8 | DatatablesPageNumberPagination, 9 | ) 10 | 11 | 12 | class TestApiTestCase(TestCase): 13 | fixtures = ["test_data"] 14 | ELVIS_PRESLEY = "Elvis Presley" 15 | THE_BEATLES = "The Beatles" 16 | LIMIT_OFFSET_PAGINATION = ( 17 | "rest_framework_datatables_editor.pagination." "DatatablesLimitOffsetPagination" 18 | ) 19 | 20 | def setUp(self): 21 | self.client = APIClient() 22 | AlbumViewSet.pagination_class = DatatablesPageNumberPagination 23 | 24 | def test_no_datatables(self): 25 | response = self.client.get("/api/albums/") 26 | expected = 15 27 | result = response.json() 28 | self.assertEquals(result["count"], expected) 29 | 30 | def test_datatables_query(self): 31 | response = self.client.get("/api/albums/?format=datatables") 32 | expected = 15 33 | result = response.json() 34 | self.assertEquals("count" in result, False) 35 | self.assertEquals("recordsTotal" in result, True) 36 | self.assertEquals(result["recordsTotal"], expected) 37 | 38 | def test_datatables_suffix(self): 39 | response = self.client.get("/api/albums.datatables/") 40 | expected = 15 41 | result = response.json() 42 | self.assertEquals("count" in result, False) 43 | self.assertEquals("recordsTotal" in result, True) 44 | self.assertEquals(result["recordsTotal"], expected) 45 | 46 | def test_pagenumber_pagination(self): 47 | response = self.client.get( 48 | "/api/albums/?format=datatables&length=10&" 49 | "start=10&columns[0][data]=name&" 50 | "columns[1][data]=artist_name&draw=1" 51 | ) 52 | expected = (15, 15, self.ELVIS_PRESLEY) 53 | result = response.json() 54 | self.assertEquals( 55 | ( 56 | result["recordsFiltered"], 57 | result["recordsTotal"], 58 | result["data"][0]["artist_name"], 59 | ), 60 | expected, 61 | ) 62 | 63 | def test_pagenumber_pagination_invalid_page(self): 64 | response = self.client.get( 65 | "/api/albums/?format=datatables&length=10&" 66 | "start=20&columns[0][data]=name&" 67 | "columns[1][data]=artist_name&draw=1" 68 | ) 69 | self.assertEquals(response.status_code, 404) 70 | 71 | @override_settings( 72 | REST_FRAMEWORK={"DEFAULT_PAGINATION_CLASS": LIMIT_OFFSET_PAGINATION, } 73 | ) 74 | def test_limitoffset_pagination(self): 75 | AlbumViewSet.pagination_class = DatatablesLimitOffsetPagination 76 | client = APIClient() 77 | response = client.get( 78 | "/api/albums/?format=datatables&length=10&" 79 | "start=10&columns[0][data]=name&" 80 | "columns[1][data]=artist_name&draw=1" 81 | ) 82 | expected = (15, 15, self.ELVIS_PRESLEY) 83 | result = response.json() 84 | self.assertEquals( 85 | ( 86 | result["recordsFiltered"], 87 | result["recordsTotal"], 88 | result["data"][0]["artist_name"], 89 | ), 90 | expected, 91 | ) 92 | 93 | @override_settings( 94 | REST_FRAMEWORK={"DEFAULT_PAGINATION_CLASS": LIMIT_OFFSET_PAGINATION, } 95 | ) 96 | def test_limitoffset_pagination_no_length(self): 97 | AlbumViewSet.pagination_class = DatatablesLimitOffsetPagination 98 | client = APIClient() 99 | response = client.get( 100 | "/api/albums/?format=datatables&start=10&columns[0][data]=name&columns[1][data]=artist_name&draw=1" 101 | ) 102 | expected = (15, 15, self.THE_BEATLES) 103 | result = response.json() 104 | self.assertEquals( 105 | ( 106 | result["recordsFiltered"], 107 | result["recordsTotal"], 108 | result["data"][0]["artist_name"], 109 | ), 110 | expected, 111 | ) 112 | 113 | @override_settings( 114 | REST_FRAMEWORK={"DEFAULT_PAGINATION_CLASS": LIMIT_OFFSET_PAGINATION, } 115 | ) 116 | def test_limitoffset_pagination_no_datatables(self): 117 | AlbumViewSet.pagination_class = DatatablesLimitOffsetPagination 118 | client = APIClient() 119 | response = client.get("/api/albums/?limit=10&offset=10") 120 | expected = (15, self.ELVIS_PRESLEY) 121 | result = response.json() 122 | self.assertEquals( 123 | (result["count"], result["results"][0]["artist_name"]), expected 124 | ) 125 | 126 | def test_column_column_data_null(self): 127 | response = self.client.get( 128 | "/api/albums/?format=datatables&length=10&start=10&columns[0][data]=&columns[1][data]=name" 129 | ) 130 | expected = (15, 15, "The Sun Sessions") 131 | result = response.json() 132 | self.assertEquals( 133 | ( 134 | result["recordsFiltered"], 135 | result["recordsTotal"], 136 | result["data"][0]["name"], 137 | ), 138 | expected, 139 | ) 140 | 141 | def test_dt_row_attrs_present(self): 142 | response = self.client.get( 143 | "/api/albums/?format=datatables&length=10&start=0&columns[0][data]=&columns[1][data]=name" 144 | ) 145 | result = response.json() 146 | self.assertTrue("DT_RowId" in result["data"][0]) 147 | self.assertTrue("DT_RowAttr" in result["data"][0]) 148 | 149 | def test_dt_force_serialize_class(self): 150 | AlbumSerializer.Meta.datatables_always_serialize = ("year",) 151 | response = self.client.get( 152 | "/api/albums/?format=datatables&length=10&start=0&columns[0][data]=&columns[1][data]=name" 153 | ) 154 | result = response.json() 155 | self.assertTrue("year" in result["data"][0]) 156 | 157 | delattr(AlbumSerializer.Meta, "datatables_always_serialize") 158 | 159 | def test_param_keep_field(self): 160 | response = self.client.get( 161 | "/api/albums/?format=datatables&length=10&columns[0][data]=artist.name&columns[0][name]=artist.name&keep=year" 162 | ) 163 | expected = (15, 15, 1967) 164 | result = response.json() 165 | self.assertEquals( 166 | ( 167 | result["recordsFiltered"], 168 | result["recordsTotal"], 169 | result["data"][0]["year"], 170 | ), 171 | expected, 172 | ) 173 | 174 | def test_param_keep_field_search(self): 175 | response = self.client.get( 176 | "/api/albums/?format=datatables&length=10&columns[0][" 177 | "data]=artist.name&columns[0][name]=artist.name,year&columns[0][" 178 | "searchable]=true&keep=year&search[value]=1968" 179 | ) 180 | expected = (1, 15, self.THE_BEATLES, 1968) 181 | result = response.json() 182 | self.assertEquals( 183 | ( 184 | result["recordsFiltered"], 185 | result["recordsTotal"], 186 | result["data"][0]["artist"]["name"], 187 | result["data"][0]["year"], 188 | ), 189 | expected, 190 | ) 191 | 192 | def test_dt_force_serialize_generic(self): 193 | response = self.client.get( 194 | "/api/artists/?format=datatables&length=10&start=0&columns[0][" 195 | "data]=&columns[1][data]=name" 196 | ) 197 | result = response.json() 198 | self.assertTrue("id" in result["data"][0]) 199 | 200 | def test_filtering_simple(self): 201 | response = self.client.get( 202 | "/api/albums/?format=datatables&columns[0][data]=name&columns[" 203 | "0][searchable]=true&columns[1][data]=artist__name&columns[1][" 204 | "searchable]=true&search[value]=are+you+exp" 205 | ) 206 | expected = (1, 15, "Are You Experienced") 207 | result = response.json() 208 | self.assertEquals( 209 | ( 210 | result["recordsFiltered"], 211 | result["recordsTotal"], 212 | result["data"][0]["name"], 213 | ), 214 | expected, 215 | ) 216 | 217 | def test_filtering_multiple_names(self): 218 | # Two asserts here to test searching on separate namespaces 219 | api_call = "/api/albums/?format=datatables&columns[0][data]=name&columns[0][searchable]=true&columns[1][data]=artist__name&columns[1][name]=artist__name,year&columns[1][searchable]=true" 220 | # First search. 221 | response_1 = self.client.get(api_call + "&search[value]=Beatles") 222 | # Second search. 223 | response_2 = self.client.get(api_call + "&search[value]=1968") 224 | expected_1 = (5, 15, "Sgt. Pepper's Lonely Hearts Club Band") 225 | expected_2 = (1, 15, 'The Beatles ("The White Album")') 226 | result_1 = response_1.json() 227 | result_2 = response_2.json() 228 | self.assertEquals( 229 | ( 230 | result_1["recordsFiltered"], 231 | result_1["recordsTotal"], 232 | result_1["data"][0]["name"], 233 | ), 234 | expected_1, 235 | ) 236 | self.assertEquals( 237 | ( 238 | result_2["recordsFiltered"], 239 | result_2["recordsTotal"], 240 | result_2["data"][0]["name"], 241 | ), 242 | expected_2, 243 | ) 244 | 245 | def test_filtering_regex(self): 246 | response = self.client.get( 247 | "/api/albums/?format=datatables&length=10&columns[0][data]=name&columns[0][searchable]=true&search[regex]=true&search[value]=^Highway [0-9]{2} Revisited$" 248 | ) 249 | expected = (1, 15, "Highway 61 Revisited") 250 | result = response.json() 251 | self.assertEquals( 252 | ( 253 | result["recordsFiltered"], 254 | result["recordsTotal"], 255 | result["data"][0]["name"], 256 | ), 257 | expected, 258 | ) 259 | 260 | def test_filtering_bad_regex(self): 261 | response = self.client.get( 262 | "/api/albums/?format=datatables&length=10&columns[0][data]=name&columns[0][searchable]=true&search[regex]=true&search[value]=^Highway [0" 263 | ) 264 | expected = (15, 15, "Sgt. Pepper's Lonely Hearts Club Band") 265 | result = response.json() 266 | self.assertEquals( 267 | ( 268 | result["recordsFiltered"], 269 | result["recordsTotal"], 270 | result["data"][0]["name"], 271 | ), 272 | expected, 273 | ) 274 | 275 | def test_filtering_foreignkey_without_nested_serializer(self): 276 | response = self.client.get( 277 | "/api/albums/?format=datatables&length=10&columns[0][data]=artist_name&columns[0][name]=artist__name&columns[0][searchable]=true&search[value]=Jimi" 278 | ) 279 | expected = (1, 15, "The Jimi Hendrix Experience") 280 | result = response.json() 281 | self.assertEquals( 282 | ( 283 | result["recordsFiltered"], 284 | result["recordsTotal"], 285 | result["data"][0]["artist_name"], 286 | ), 287 | expected, 288 | ) 289 | 290 | def test_filtering_foreignkey_with_nested_serializer(self): 291 | response = self.client.get( 292 | "/api/albums/?format=datatables&length=10&columns[0][data]=artist.name&columns[0][name]=artist.name&columns[0][searchable]=true&search[value]=Jimi" 293 | ) 294 | expected = (1, 15, "The Jimi Hendrix Experience") 295 | result = response.json() 296 | self.assertEquals( 297 | ( 298 | result["recordsFiltered"], 299 | result["recordsTotal"], 300 | result["data"][0]["artist"]["name"], 301 | ), 302 | expected, 303 | ) 304 | 305 | def test_filtering_column(self): 306 | response = self.client.get( 307 | "/api/albums/?format=datatables&length=10&columns[0][data]=artist_name&columns[0][name]=artist__name&columns[0][searchable]=true&columns[0][search][value]=Beatles" 308 | ) 309 | expected = (5, 15, self.THE_BEATLES) 310 | result = response.json() 311 | self.assertEquals( 312 | ( 313 | result["recordsFiltered"], 314 | result["recordsTotal"], 315 | result["data"][0]["artist_name"], 316 | ), 317 | expected, 318 | ) 319 | 320 | def test_filtering_column_suffix(self): 321 | response = self.client.get( 322 | "/api/albums.datatables?length=10&columns[0][data]=artist_name&columns[0][name]=artist__name&columns[0][searchable]=true&columns[0][search][value]=Beatles" 323 | ) 324 | expected = (5, 15, self.THE_BEATLES) 325 | result = response.json() 326 | self.assertEquals( 327 | ( 328 | result["recordsFiltered"], 329 | result["recordsTotal"], 330 | result["data"][0]["artist_name"], 331 | ), 332 | expected, 333 | ) 334 | 335 | def test_filtering_column_regex(self): 336 | response = self.client.get( 337 | "/api/albums/?format=datatables&length=10&columns[0][data]=artist_name&columns[0][name]=artist__name&columns[0][searchable]=true&columns[0][search][regex]=true&columns[0][search][value]=^bob" 338 | ) 339 | expected = (2, 15, "Bob Dylan") 340 | result = response.json() 341 | self.assertEquals( 342 | ( 343 | result["recordsFiltered"], 344 | result["recordsTotal"], 345 | result["data"][0]["artist_name"], 346 | ), 347 | expected, 348 | ) 349 | 350 | def test_filtering_multicolumn1(self): 351 | response = self.client.get( 352 | "/api/albums/?format=datatables&length=10&columns[0][data]=artist_name&columns[0][name]=artist__name&columns[0][searchable]=true&columns[0][search][value]=Beatles&columns[1][data]=year&columns[1][searchable]=true&columns[1][search][value]=1968" 353 | ) 354 | expected = (1, 15, self.THE_BEATLES) 355 | result = response.json() 356 | self.assertEquals( 357 | ( 358 | result["recordsFiltered"], 359 | result["recordsTotal"], 360 | result["data"][0]["artist_name"], 361 | ), 362 | expected, 363 | ) 364 | 365 | def test_filtering_multicolumn2(self): 366 | response = self.client.get( 367 | "/api/albums/?format=datatables&length=10&columns[0][data]=artist_name&columns[0][name]=artist__name&columns[0][searchable]=true&columns[0][search][value]=Beatles&columns[1][data]=year&columns[1][searchable]=true&columns[1][search][value]=2018" 368 | ) 369 | expected = (0, 15) 370 | result = response.json() 371 | self.assertEquals((result["recordsFiltered"], result["recordsTotal"]), 372 | expected) 373 | 374 | def test_ordering_simple(self): 375 | response = self.client.get( 376 | "/api/albums/?format=datatables&length=10&columns[0][data]=artist_name&columns[0][name]=artist__name&columns[0][orderable]=true&order[0][column]=0&order[0][dir]=desc" 377 | ) 378 | expected = (15, 15, "The Velvet Underground") 379 | result = response.json() 380 | self.assertEquals( 381 | ( 382 | result["recordsFiltered"], 383 | result["recordsTotal"], 384 | result["data"][0]["artist_name"], 385 | ), 386 | expected, 387 | ) 388 | 389 | def test_ordering_but_not_orderable(self): 390 | response = self.client.get( 391 | "/api/albums/?format=datatables&length=10&columns[0][data]=artist_name&columns[0][name]=artist__name&columns[0][orderable]=false&order[0][column]=0&order[0][dir]=desc" 392 | ) 393 | expected = (15, 15, self.THE_BEATLES) 394 | result = response.json() 395 | self.assertEquals( 396 | ( 397 | result["recordsFiltered"], 398 | result["recordsTotal"], 399 | result["data"][0]["artist_name"], 400 | ), 401 | expected, 402 | ) 403 | 404 | def test_ordering_bad_column_index(self): 405 | response = self.client.get( 406 | "/api/albums/?format=datatables&length=10&columns[0][data]=artist_name&columns[0][name]=artist__name&columns[0][orderable]=true&order[0][column]=8&order[0][dir]=desc" 407 | ) 408 | expected = (15, 15, self.THE_BEATLES) 409 | result = response.json() 410 | self.assertEquals( 411 | ( 412 | result["recordsFiltered"], 413 | result["recordsTotal"], 414 | result["data"][0]["artist_name"], 415 | ), 416 | expected, 417 | ) 418 | -------------------------------------------------------------------------------- /example/static/css/editor.bootstrap4.min.css: -------------------------------------------------------------------------------- 1 | div.DTE div.DTE_Form_Error{color:#b11f1f}div.modal div.DTE div.DTE_Form_Error{display:none;float:left;padding-top:7px}div.DTE_Field{position:relative}div.DTE_Field div.multi-value,div.DTE_Field div.multi-restore{display:none;cursor:pointer}div.DTE_Field div.multi-value span,div.DTE_Field div.multi-restore span{display:block;color:#666}div.DTE_Field div.multi-value:hover,div.DTE_Field div.multi-restore:hover{background-color:#f1f1f1}div.DTE_Field div.multi-restore{margin-top:0.5em;font-size:0.8em;line-height:1.25em}div.DTE_Field:after{display:block;content:".";height:0;line-height:0;clear:both;visibility:hidden}div.DTE_Field div:not([data-dte-e="msg-error"]){color:inherit}div.DTE_Inline{position:relative;display:table;width:100%}div.DTE_Inline div.DTE_Inline_Field,div.DTE_Inline div.DTE_Inline_Buttons{display:table-cell;vertical-align:middle}div.DTE_Inline div.DTE_Inline_Field div.DTE_Field,div.DTE_Inline div.DTE_Inline_Buttons div.DTE_Field{padding:0}div.DTE_Inline div.DTE_Inline_Field div.DTE_Field>label,div.DTE_Inline div.DTE_Inline_Buttons div.DTE_Field>label{display:none}div.DTE_Inline div.DTE_Inline_Field div.DTE_Field input[type="color"],div.DTE_Inline div.DTE_Inline_Field div.DTE_Field input[type="date"],div.DTE_Inline div.DTE_Inline_Field div.DTE_Field input[type="datetime"],div.DTE_Inline div.DTE_Inline_Field div.DTE_Field input[type="datetime-local"],div.DTE_Inline div.DTE_Inline_Field div.DTE_Field input[type="email"],div.DTE_Inline div.DTE_Inline_Field div.DTE_Field input[type="month"],div.DTE_Inline div.DTE_Inline_Field div.DTE_Field input[type="number"],div.DTE_Inline div.DTE_Inline_Field div.DTE_Field input[type="password"],div.DTE_Inline div.DTE_Inline_Field div.DTE_Field input[type="search"],div.DTE_Inline div.DTE_Inline_Field div.DTE_Field input[type="tel"],div.DTE_Inline div.DTE_Inline_Field div.DTE_Field input[type="text"],div.DTE_Inline div.DTE_Inline_Field div.DTE_Field input[type="time"],div.DTE_Inline div.DTE_Inline_Field div.DTE_Field input[type="url"],div.DTE_Inline div.DTE_Inline_Field div.DTE_Field input[type="week"],div.DTE_Inline div.DTE_Inline_Buttons div.DTE_Field input[type="color"],div.DTE_Inline div.DTE_Inline_Buttons div.DTE_Field input[type="date"],div.DTE_Inline div.DTE_Inline_Buttons div.DTE_Field input[type="datetime"],div.DTE_Inline div.DTE_Inline_Buttons div.DTE_Field input[type="datetime-local"],div.DTE_Inline div.DTE_Inline_Buttons div.DTE_Field input[type="email"],div.DTE_Inline div.DTE_Inline_Buttons div.DTE_Field input[type="month"],div.DTE_Inline div.DTE_Inline_Buttons div.DTE_Field input[type="number"],div.DTE_Inline div.DTE_Inline_Buttons div.DTE_Field input[type="password"],div.DTE_Inline div.DTE_Inline_Buttons div.DTE_Field input[type="search"],div.DTE_Inline div.DTE_Inline_Buttons div.DTE_Field input[type="tel"],div.DTE_Inline div.DTE_Inline_Buttons div.DTE_Field input[type="text"],div.DTE_Inline div.DTE_Inline_Buttons div.DTE_Field input[type="time"],div.DTE_Inline div.DTE_Inline_Buttons div.DTE_Field input[type="url"],div.DTE_Inline div.DTE_Inline_Buttons div.DTE_Field input[type="week"]{width:100%}div.DTE_Inline div.DTE_Inline_Field div.DTE_Form_Buttons button,div.DTE_Inline div.DTE_Inline_Buttons div.DTE_Form_Buttons button{margin:-6px 0 -6px 4px;padding:5px}div.DTE_Inline div.DTE_Field input[type="color"],div.DTE_Inline div.DTE_Field input[type="date"],div.DTE_Inline div.DTE_Field input[type="datetime"],div.DTE_Inline div.DTE_Field input[type="datetime-local"],div.DTE_Inline div.DTE_Field input[type="email"],div.DTE_Inline div.DTE_Field input[type="month"],div.DTE_Inline div.DTE_Field input[type="number"],div.DTE_Inline div.DTE_Field input[type="password"],div.DTE_Inline div.DTE_Field input[type="search"],div.DTE_Inline div.DTE_Field input[type="tel"],div.DTE_Inline div.DTE_Field input[type="text"],div.DTE_Inline div.DTE_Field input[type="time"],div.DTE_Inline div.DTE_Field input[type="url"],div.DTE_Inline div.DTE_Field input[type="week"]{margin:-6px 0}div.DTE_Inline div.DTE_Field_Error,div.DTE_Inline div.DTE_Form_Error{font-size:11px;line-height:1.2em;padding:0;margin-top:10px}div.DTE_Inline div.DTE_Field_Error:empty,div.DTE_Inline div.DTE_Form_Error:empty{margin-top:0}span.dtr-data div.DTE_Inline{display:inline-table}div.DTE_Inline div.DTE_Field{width:100%}div.DTE_Inline div.DTE_Field>div{width:100%;padding:0}div.DTE_Inline div.DTE_Field input.form-control{height:30px}div.DTE_Inline div.DTE_Field div.help-block{display:none;margin-top:10px;margin-bottom:0}div.DTE_Inline.DTE_Processing:after{top:5px}div.DTE_Field_Type_checkbox div.controls,div.DTE_Field_Type_radio div.controls{margin-top:0.4em}div.DTE_Field_Type_checkbox div.controls label,div.DTE_Field_Type_radio div.controls label{margin-left:0.75em;margin-bottom:0;vertical-align:middle;font-weight:normal}div.DTE_Bubble{position:absolute;z-index:11;margin-top:-6px;opacity:0}div.DTE_Bubble div.DTE_Bubble_Liner{position:absolute;bottom:0;border:1px solid black;width:300px;margin-left:-150px;background-color:white;box-shadow:0 12px 30px 0 rgba(0,0,0,0.5);border-radius:6px;border:1px solid #666;padding:1em;background:#fcfcfc;-webkit-box-sizing:border-box;-moz-box-sizing:border-box;box-sizing:border-box}div.DTE_Bubble div.DTE_Bubble_Liner div.DTE_Bubble_Table{width:100%}div.DTE_Bubble div.DTE_Bubble_Liner div.DTE_Bubble_Table>form div.DTE_Form_Content{padding:0}div.DTE_Bubble div.DTE_Bubble_Liner div.DTE_Bubble_Table>form div.DTE_Form_Content div.DTE_Field{position:relative;zoom:1;margin-bottom:0.5em}div.DTE_Bubble div.DTE_Bubble_Liner div.DTE_Bubble_Table>form div.DTE_Form_Content div.DTE_Field:last-child{margin-bottom:0}div.DTE_Bubble div.DTE_Bubble_Liner div.DTE_Bubble_Table>form div.DTE_Form_Content div.DTE_Field>label{padding-top:0;margin-bottom:0}div.DTE_Bubble div.DTE_Bubble_Liner div.DTE_Bubble_Table>form div.DTE_Form_Content div.DTE_Field>div{padding:0}div.DTE_Bubble div.DTE_Bubble_Liner div.DTE_Bubble_Table>form div.DTE_Form_Content div.DTE_Field>div input{margin:0}div.DTE_Bubble div.DTE_Bubble_Liner div.DTE_Bubble_Table div.DTE_Form_Buttons{text-align:right;margin-top:1em}div.DTE_Bubble div.DTE_Bubble_Liner div.DTE_Bubble_Table div.DTE_Form_Buttons button{margin-bottom:0}div.DTE_Bubble div.DTE_Bubble_Liner div.DTE_Header{border-top-left-radius:5px;border-top-right-radius:5px}div.DTE_Bubble div.DTE_Bubble_Liner div.DTE_Header+div.DTE_Form_Info,div.DTE_Bubble div.DTE_Bubble_Liner div.DTE_Header+div.DTE_Bubble_Table{padding-top:42px}div.DTE_Bubble div.DTE_Bubble_Liner div.DTE_Form_Error{float:none;display:none;padding:0;margin-bottom:0.5em}div.DTE_Bubble div.DTE_Bubble_Liner div.DTE_Bubble_Close{position:absolute;top:-11px;right:-11px;width:22px;height:22px;border:2px solid white;background-color:black;text-align:center;border-radius:15px;cursor:pointer;z-index:12;box-shadow:2px 2px 6px #111}div.DTE_Bubble div.DTE_Bubble_Liner div.DTE_Bubble_Close:after{content:'\00d7';color:white;font-weight:bold;font-size:18px;line-height:22px;font-family:'Courier New', Courier, monospace;padding-left:1px}div.DTE_Bubble div.DTE_Bubble_Liner div.DTE_Bubble_Close:hover{background-color:#092079;box-shadow:2px 2px 9px #111}div.DTE_Bubble div.DTE_Bubble_Triangle{position:absolute;height:10px;width:10px;top:-6px;background-color:white;border:1px solid #666;border-top:none;border-right:none;-webkit-transform:rotate(-45deg);-moz-transform:rotate(-45deg);-ms-transform:rotate(-45deg);-o-transform:rotate(-45deg);transform:rotate(-45deg)}div.DTE_Bubble.below div.DTE_Bubble_Liner{top:10px;bottom:auto}div.DTE_Bubble.below div.DTE_Bubble_Triangle{top:4px;-webkit-transform:rotate(135deg);-moz-transform:rotate(135deg);-ms-transform:rotate(135deg);-o-transform:rotate(135deg);transform:rotate(135deg)}div.DTE_Bubble_Background{position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.7);background:-ms-radial-gradient(center, ellipse farthest-corner, rgba(0,0,0,0.3) 0%, rgba(0,0,0,0.7) 100%);background:-moz-radial-gradient(center, ellipse farthest-corner, rgba(0,0,0,0.3) 0%, rgba(0,0,0,0.7) 100%);background:-o-radial-gradient(center, ellipse farthest-corner, rgba(0,0,0,0.3) 0%, rgba(0,0,0,0.7) 100%);background:-webkit-gradient(radial, center center, 0, center center, 497, color-stop(0, rgba(0,0,0,0.3)), color-stop(1, rgba(0,0,0,0.7)));background:-webkit-radial-gradient(center, ellipse farthest-corner, rgba(0,0,0,0.3) 0%, rgba(0,0,0,0.7) 100%);background:radial-gradient(ellipse farthest-corner at center, rgba(0,0,0,0.3) 0%, rgba(0,0,0,0.7) 100%);z-index:10}div.DTE_Bubble_Background>div{position:absolute;top:0;right:0;left:0;bottom:0;filter:progid:DXImageTransform.Microsoft.gradient(startColorstr=#99000000, endColorstr=#99000000);-ms-filter:"progid:DXImageTransform.Microsoft.gradient(startColorstr=#99000000, endColorstr=#99000000)"}div.DTE_Bubble_Background>div:not([dummy]){filter:progid:DXImageTransform.Microsoft.gradient(enabled='false')}div.DTE_Bubble div.DTE_Bubble_Liner{box-shadow:0 5px 10px rgba(0,0,0,0.2);border-radius:6px;padding:1em;border:1px solid rgba(0,0,0,0.2)}div.DTE_Bubble div.DTE_Bubble_Liner div.DTE_Bubble_Table>form div.DTE_Form_Content div.DTE_Field label,div.DTE_Bubble div.DTE_Bubble_Liner div.DTE_Bubble_Table>form div.DTE_Form_Content div.DTE_Field>div{width:100%;max-width:100%;float:none;clear:both;text-align:left;flex:none}div.DTE_Bubble div.DTE_Bubble_Liner div.DTE_Bubble_Table>form div.DTE_Form_Content div.DTE_Field label{padding:0 0 4px 0}div.DTE_Bubble div.DTE_Bubble_Liner div.DTE_Bubble_Table div.DTE_Form_Buttons{text-align:right;margin-top:0}div.DTE_Bubble div.DTE_Bubble_Liner div.DTE_Header{background-color:#f7f7f7;border-bottom:1px solid #ebebeb;font-size:14px;width:100%}div.DTE_Bubble div.DTE_Bubble_Liner div.DTE_Bubble_Close:after{margin-top:-2px;display:block}div.DTE_Bubble div.DTE_Bubble_Triangle{border-bottom:1px solid rgba(0,0,0,0.2);border-left:1px solid rgba(0,0,0,0.2)}div.DTE_Bubble_Background{position:fixed;top:0;left:0;right:0;bottom:0;z-index:10;background-color:rgba(0,0,0,0.05)}div.DTE div.editor_upload{padding-top:4px}div.DTE div.editor_upload div.eu_table{display:table;width:100%}div.DTE div.editor_upload div.row{display:table-row}div.DTE div.editor_upload div.cell{display:table-cell;position:relative;width:50%;vertical-align:top}div.DTE div.editor_upload div.cell+div.cell{padding-left:10px}div.DTE div.editor_upload div.row+div.row div.cell{padding-top:10px}div.DTE div.editor_upload button.btn,div.DTE div.editor_upload input[type=file]{width:100%;height:2.3em;font-size:0.8em;text-align:center;line-height:1em}div.DTE div.editor_upload input[type=file]{position:absolute;top:0;left:0;width:100%;opacity:0}div.DTE div.editor_upload div.drop{position:relative;box-sizing:border-box;width:100%;height:100%;border:3px dashed #ccc;border-radius:6px;min-height:4em;color:#999;padding-top:3px;text-align:center}div.DTE div.editor_upload div.drop.over{border:3px dashed #111;color:#111}div.DTE div.editor_upload div.drop span{max-width:75%;font-size:0.85em;line-height:1em}div.DTE div.editor_upload div.rendered img{max-width:8em;margin:0 auto}div.DTE div.editor_upload.noDrop div.drop{display:none}div.DTE div.editor_upload.noDrop div.row.second{display:none}div.DTE div.editor_upload.noDrop div.rendered{margin-top:10px}div.DTE div.editor_upload.noClear div.clearValue button{display:none}div.DTE div.editor_upload.multi div.cell{display:block;width:100%}div.DTE div.editor_upload.multi div.cell div.drop{min-height:0;padding-bottom:5px}div.DTE div.editor_upload.multi div.clearValue{display:none}div.DTE div.editor_upload.multi ul{list-style-type:none;margin:0;padding:0}div.DTE div.editor_upload.multi ul li{position:relative;margin-top:0.5em}div.DTE div.editor_upload.multi ul li:first-child{margin-top:0}div.DTE div.editor_upload.multi ul li img{vertical-align:middle}div.DTE div.editor_upload.multi ul li button{position:absolute;width:40px;right:0;top:50%;margin-top:-1.5em}div.DTE div.editor_upload button.btn,div.DTE div.editor_upload input[type=file]{height:auto}div.DTE div.editor_upload ul li button{padding-bottom:8px}div.editor-datetime{position:absolute;background-color:white;z-index:2050;border:1px solid #ccc;box-shadow:0 5px 15px -5px rgba(0,0,0,0.5);padding:0 20px 6px 20px;width:275px}div.editor-datetime div.editor-datetime-title{text-align:center;padding:5px 0px 3px}div.editor-datetime table{border-spacing:0;margin:12px 0;width:100%}div.editor-datetime table.editor-datetime-table-nospace{margin-top:-12px}div.editor-datetime table th{font-size:0.8em;color:#777;font-weight:normal;width:14.285714286%;padding:0 0 4px 0;text-align:center}div.editor-datetime table td{font-size:0.9em;color:#444;padding:0}div.editor-datetime table td.selectable{text-align:center;background:#f5f5f5}div.editor-datetime table td.selectable.disabled{color:#aaa;background:white}div.editor-datetime table td.selectable.disabled button:hover{color:#aaa;background:white}div.editor-datetime table td.selectable.now{background-color:#ddd}div.editor-datetime table td.selectable.now button{font-weight:bold}div.editor-datetime table td.selectable.selected button{background:#0275d8;color:white;border-radius:2px}div.editor-datetime table td.selectable button:hover{background:#ff8000;color:white;border-radius:2px}div.editor-datetime table td.editor-datetime-week{font-size:0.7em}div.editor-datetime table button{width:100%;box-sizing:border-box;border:none;background:transparent;font-size:inherit;color:inherit;text-align:center;padding:4px 0;cursor:pointer;margin:0}div.editor-datetime table button span{display:inline-block;min-width:14px;text-align:right}div.editor-datetime table.weekNumber th{width:12.5%}div.editor-datetime div.editor-datetime-calendar table{margin-top:0}div.editor-datetime div.editor-datetime-label{position:relative;display:inline-block;height:30px;padding:5px 6px;border:1px solid transparent;box-sizing:border-box;cursor:pointer}div.editor-datetime div.editor-datetime-label:hover{border:1px solid #ddd;border-radius:2px;background-color:#f5f5f5}div.editor-datetime div.editor-datetime-label select{position:absolute;top:6px;left:0;cursor:pointer;opacity:0;-ms-filter:"alpha(opacity=0)"}div.editor-datetime div.editor-datetime-time{text-align:center}div.editor-datetime div.editor-datetime-time>span{vertical-align:middle}div.editor-datetime div.editor-datetime-time th{text-align:left}div.editor-datetime div.editor-datetime-time div.editor-datetime-timeblock{display:inline-block;vertical-align:middle}div.editor-datetime div.editor-datetime-iconLeft,div.editor-datetime div.editor-datetime-iconRight,div.editor-datetime div.editor-datetime-iconUp,div.editor-datetime div.editor-datetime-iconDown{width:30px;height:30px;background-position:center;background-repeat:no-repeat;opacity:0.3;overflow:hidden;box-sizing:border-box}div.editor-datetime div.editor-datetime-iconLeft:hover,div.editor-datetime div.editor-datetime-iconRight:hover,div.editor-datetime div.editor-datetime-iconUp:hover,div.editor-datetime div.editor-datetime-iconDown:hover{border:1px solid #ccc;border-radius:2px;background-color:#f0f0f0;opacity:0.6}div.editor-datetime div.editor-datetime-iconLeft button,div.editor-datetime div.editor-datetime-iconRight button,div.editor-datetime div.editor-datetime-iconUp button,div.editor-datetime div.editor-datetime-iconDown button{border:none;background:transparent;text-indent:30px;height:100%;width:100%;cursor:pointer}div.editor-datetime div.editor-datetime-iconLeft{position:absolute;top:5px;left:5px;background-image:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAeCAYAAAAsEj5rAAAAUklEQVR42u3VMQoAIBADQf8Pgj+OD9hG2CtONJB2ymQkKe0HbwAP0xucDiQWARITIDEBEnMgMQ8S8+AqBIl6kKgHiXqQqAeJepBo/z38J/U0uAHlaBkBl9I4GwAAAABJRU5ErkJggg==")}div.editor-datetime div.editor-datetime-iconRight{position:absolute;top:5px;right:5px;background-image:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAeCAYAAAAsEj5rAAAAU0lEQVR42u3VOwoAMAgE0dwfAnNjU26bYkBCFGwfiL9VVWoO+BJ4Gf3gtsEKKoFBNTCoCAYVwaAiGNQGMUHMkjGbgjk2mIONuXo0nC8XnCf1JXgArVIZAQh5TKYAAAAASUVORK5CYII=")}div.editor-datetime div.editor-datetime-iconUp{height:20px;background-image:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB4AAAALCAMAAABf9c24AAAAFVBMVEX///99fX1+fn57e3t6enoAAAAAAAC73bqPAAAABnRSTlMAYmJkZt92bnysAAAAL0lEQVR4AWOgJmBhxCvLyopHnpmVjY2VCadeoCxIHrcsWJ4RlyxCHlMWCTBRJxwAjrIBDMWSiM0AAAAASUVORK5CYII=")}div.editor-datetime div.editor-datetime-iconDown{height:20px;background-image:url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB4AAAALCAMAAABf9c24AAAAFVBMVEX///99fX1+fn57e3t6enoAAAAAAAC73bqPAAAABnRSTlMAYmJkZt92bnysAAAAMElEQVR4AWOgDmBiRQIsmPKMrGxQgJDFlEfIYpoPk8Utz8qM232MYFfhkQfKUg8AANefAQxecJ58AAAAAElFTkSuQmCC")}div.editor-datetime-error{padding:0 1em;max-width:240px;font-size:11px;line-height:1.25em;text-align:center;color:#b11f1f}div.DTE div.DTE_Processing_Indicator{position:absolute;top:17px;right:9px;height:2em;width:2em;z-index:20;font-size:12px;display:none;-webkit-transform:translateZ(0);-ms-transform:translateZ(0);transform:translateZ(0)}div.DTE.processing div.DTE_Processing_Indicator{display:block}div.DTE.processing div.DTE_Field div.DTE_Processing_Indicator{display:none}div.DTE div.DTE_Field div.DTE_Processing_Indicator{top:13px;right:0;font-size:8px}div.DTE.DTE_Inline div.DTE_Processing_Indicator{top:5px;right:6px;font-size:6px}div.DTE.DTE_Bubble div.DTE_Processing_Indicator{top:10px;right:14px;font-size:8px}div.DTE div.DTE_Processing_Indicator span,div.DTE div.DTE_Processing_Indicator:before,div.DTE div.DTE_Processing_Indicator:after{display:block;background:black;width:0.5em;height:1.5em;border:1px solid rgba(0,0,0,0.4);background-color:rgba(0,0,0,0.1);-webkit-animation:editorProcessing 0.9s infinite ease-in-out;animation:editorProcessing 0.9s infinite ease-in-out}div.DTE div.DTE_Processing_Indicator:before,div.DTE div.DTE_Processing_Indicator:after{position:absolute;top:0;content:''}div.DTE div.DTE_Processing_Indicator:before{left:-1em;-webkit-animation-delay:-0.3s;animation-delay:-0.3s}div.DTE div.DTE_Processing_Indicator span{-webkit-animation-delay:-0.15s;animation-delay:-0.15s}div.DTE div.DTE_Processing_Indicator:after{left:1em}@-webkit-keyframes editorProcessing{0%, 2 | 80%, 3 | 100%{transform:scale(1, 1)}40%{transform:scale(1, 1.5)}}@keyframes editorProcessing{0%, 4 | 80%, 5 | 100%{transform:scale(1, 1)}40%{transform:scale(1, 1.5)}}table.dataTable tbody tr.highlight{background-color:#3399ff !important}table.dataTable tbody tr.highlight,table.dataTable tbody tr.noHighlight,table.dataTable tbody tr.highlight td,table.dataTable tbody tr.noHighlight td{-webkit-transition:background-color 500ms linear;-moz-transition:background-color 500ms linear;-ms-transition:background-color 500ms linear;-o-transition:background-color 500ms linear;transition:background-color 500ms linear}div.DTE div.DTE_Field div.DTE_Processing_Indicator{top:13px;right:20px}div.DTE div.DTE_Processing_Indicator{top:52px;right:12px}div.DTED_Envelope_Wrapper{position:absolute;top:0;bottom:0;left:50%;height:100%;z-index:11;display:none;overflow:hidden}div.DTED_Envelope_Wrapper div.DTED_Envelope_Shadow{position:absolute;top:-10px;left:10px;right:10px;height:10px;z-index:10;box-shadow:0 0 20px black}div.DTED_Envelope_Wrapper div.DTED_Envelope_Container{position:absolute;top:0;left:5%;width:90%;border-left:1px solid #777;border-right:1px solid #777;border-bottom:1px solid #777;box-shadow:3px 3px 10px #555;border-bottom-left-radius:5px;border-bottom-right-radius:5px;background-color:white}div.DTED_Envelope_Wrapper div.DTED_Envelope_Container div.DTE_Processing_Indicator{right:36px}div.DTED_Envelope_Wrapper div.DTED_Envelope_Container div.DTE_Footer{border-bottom-left-radius:5px;border-bottom-right-radius:5px}div.DTED_Envelope_Wrapper div.DTED_Envelope_Container div.DTED_Envelope_Close{position:absolute;top:16px;right:10px;width:18px;height:18px;cursor:pointer;z-index:12;text-align:center;font-size:12px;background:#F8F8F8;background:-webkit-gradient(linear, center bottom, center top, from(#CCC), to(#fff));background:-moz-linear-gradient(top, #fff, #CCC);background:linear-gradient(to bottom, #fff, #CCC);text-shadow:0 1px 0 white;border:1px solid #999;border-radius:2px;-moz-border-radius:2px;-webkit-border-radius:2px;box-shadow:0px 0px 1px #999;-moz-box-shadow:0px 0px 1px #999;-webkit-box-shadow:0px 0px 1px #999}div.DTED_Envelope_Background{position:fixed;top:0;left:0;width:100%;height:100%;z-index:10;background:rgba(0,0,0,0.4);background:-ms-radial-gradient(center, ellipse farthest-corner, rgba(0,0,0,0.1) 0%, rgba(0,0,0,0.4) 100%);background:-moz-radial-gradient(center, ellipse farthest-corner, rgba(0,0,0,0.1) 0%, rgba(0,0,0,0.4) 100%);background:-o-radial-gradient(center, ellipse farthest-corner, rgba(0,0,0,0.1) 0%, rgba(0,0,0,0.4) 100%);background:-webkit-gradient(radial, center center, 0, center center, 497, color-stop(0, rgba(0,0,0,0.1)), color-stop(1, rgba(0,0,0,0.4)));background:-webkit-radial-gradient(center, ellipse farthest-corner, rgba(0,0,0,0.1) 0%, rgba(0,0,0,0.4) 100%);background:radial-gradient(ellipse farthest-corner at center, rgba(0,0,0,0.1) 0%, rgba(0,0,0,0.4) 100%)}div.DTED_Envelope_Wrapper div.DTED_Envelope_Container div.DTED_Envelope_Close{top:10px;background:transparent;text-shadow:none;box-shadow:none;border:none;font-size:21px;color:black;opacity:0.2}div.DTED_Envelope_Wrapper div.DTED_Envelope_Container div.DTED_Envelope_Close:hover{opacity:1}div.card.multi-value,div.card.multi-restore{padding:0.5em}div.card.multi-value span,div.card.multi-restore span{line-height:1.2em}div.DTE_Bubble div.DTE_Bubble_Liner div.DTE_Bubble_Table>form div.DTE_Form_Content{margin:0 1em}div.DTE_Bubble div.DTE_Bubble_Liner div.DTE_Bubble_Table div.DTE_Form_Buttons{margin-top:1em}div.DTE_Inline div.DTE_Field{width:100%;margin:0}div.DTE_Inline div.DTE_Field>div{max-width:100%;flex:none}div.DTE_Inline div.DTE_Field input{margin:-5px 0 -10px !important}div.DTE_Body div.DTE_Body_Content div.DTE_Field.block label,div.DTE_Body div.DTE_Body_Content div.DTE_Field.block>div{max-width:100%;flex:0 0 100%}div.DTE_Field_Type_checkbox div label,div.DTE_Field_Type_radio div label{margin-left:0.75em;vertical-align:middle}div.DTE div.DTE_Processing_Indicator{top:20px;right:36px} 6 | -------------------------------------------------------------------------------- /example/static/css/editor.semanticui.css: -------------------------------------------------------------------------------- 1 | div.DTE div.DTE_Form_Error { 2 | display: none; 3 | color: #b11f1f; 4 | } 5 | div.DTE label { 6 | padding-top: 9px !important; 7 | align-self: flex-start; 8 | justify-content: flex-end; 9 | } 10 | div.DTE div.eight.wide.field { 11 | flex-direction: column; 12 | } 13 | div.DTE div.DTE_Field_InputControl { 14 | width: 100%; 15 | margin: 0 !important; 16 | } 17 | div.DTE div.ui.message:empty { 18 | display: none; 19 | } 20 | 21 | div.DTE_Field div.ui.message { 22 | width: 100%; 23 | } 24 | div.DTE_Field div.multi-value, 25 | div.DTE_Field div.multi-restore { 26 | display: none; 27 | cursor: pointer; 28 | margin-top: 0; 29 | } 30 | div.DTE_Field div.multi-value span, 31 | div.DTE_Field div.multi-restore span { 32 | display: block; 33 | color: #666; 34 | font-size: 0.85em; 35 | line-height: 1.35em; 36 | } 37 | div.DTE_Field div.multi-value:hover, 38 | div.DTE_Field div.multi-restore:hover { 39 | background-color: #f1f1f1; 40 | } 41 | div.DTE_Field div.multi-restore { 42 | margin-top: 0.5em; 43 | font-size: 0.8em; 44 | line-height: 1.25em; 45 | } 46 | div.DTE_Field:after { 47 | display: block; 48 | content: "."; 49 | height: 0; 50 | line-height: 0; 51 | clear: both; 52 | visibility: hidden; 53 | } 54 | 55 | div.DTE_Inline { 56 | position: relative; 57 | display: table; 58 | width: 100%; 59 | } 60 | div.DTE_Inline div.DTE_Inline_Field, 61 | div.DTE_Inline div.DTE_Inline_Buttons { 62 | display: table-cell; 63 | vertical-align: middle; 64 | } 65 | div.DTE_Inline div.DTE_Inline_Field div.DTE_Field, 66 | div.DTE_Inline div.DTE_Inline_Buttons div.DTE_Field { 67 | padding: 0; 68 | } 69 | div.DTE_Inline div.DTE_Inline_Field div.DTE_Field > label, 70 | div.DTE_Inline div.DTE_Inline_Buttons div.DTE_Field > label { 71 | display: none; 72 | } 73 | div.DTE_Inline div.DTE_Inline_Field div.DTE_Field input[type="color"], 74 | div.DTE_Inline div.DTE_Inline_Field div.DTE_Field input[type="date"], 75 | div.DTE_Inline div.DTE_Inline_Field div.DTE_Field input[type="datetime"], 76 | div.DTE_Inline div.DTE_Inline_Field div.DTE_Field input[type="datetime-local"], 77 | div.DTE_Inline div.DTE_Inline_Field div.DTE_Field input[type="email"], 78 | div.DTE_Inline div.DTE_Inline_Field div.DTE_Field input[type="month"], 79 | div.DTE_Inline div.DTE_Inline_Field div.DTE_Field input[type="number"], 80 | div.DTE_Inline div.DTE_Inline_Field div.DTE_Field input[type="password"], 81 | div.DTE_Inline div.DTE_Inline_Field div.DTE_Field input[type="search"], 82 | div.DTE_Inline div.DTE_Inline_Field div.DTE_Field input[type="tel"], 83 | div.DTE_Inline div.DTE_Inline_Field div.DTE_Field input[type="text"], 84 | div.DTE_Inline div.DTE_Inline_Field div.DTE_Field input[type="time"], 85 | div.DTE_Inline div.DTE_Inline_Field div.DTE_Field input[type="url"], 86 | div.DTE_Inline div.DTE_Inline_Field div.DTE_Field input[type="week"], 87 | div.DTE_Inline div.DTE_Inline_Buttons div.DTE_Field input[type="color"], 88 | div.DTE_Inline div.DTE_Inline_Buttons div.DTE_Field input[type="date"], 89 | div.DTE_Inline div.DTE_Inline_Buttons div.DTE_Field input[type="datetime"], 90 | div.DTE_Inline div.DTE_Inline_Buttons div.DTE_Field input[type="datetime-local"], 91 | div.DTE_Inline div.DTE_Inline_Buttons div.DTE_Field input[type="email"], 92 | div.DTE_Inline div.DTE_Inline_Buttons div.DTE_Field input[type="month"], 93 | div.DTE_Inline div.DTE_Inline_Buttons div.DTE_Field input[type="number"], 94 | div.DTE_Inline div.DTE_Inline_Buttons div.DTE_Field input[type="password"], 95 | div.DTE_Inline div.DTE_Inline_Buttons div.DTE_Field input[type="search"], 96 | div.DTE_Inline div.DTE_Inline_Buttons div.DTE_Field input[type="tel"], 97 | div.DTE_Inline div.DTE_Inline_Buttons div.DTE_Field input[type="text"], 98 | div.DTE_Inline div.DTE_Inline_Buttons div.DTE_Field input[type="time"], 99 | div.DTE_Inline div.DTE_Inline_Buttons div.DTE_Field input[type="url"], 100 | div.DTE_Inline div.DTE_Inline_Buttons div.DTE_Field input[type="week"] { 101 | width: 100%; 102 | } 103 | div.DTE_Inline div.DTE_Inline_Field div.DTE_Form_Buttons button, 104 | div.DTE_Inline div.DTE_Inline_Buttons div.DTE_Form_Buttons button { 105 | margin: -6px 0 -6px 4px; 106 | padding: 5px; 107 | } 108 | div.DTE_Inline div.DTE_Field input[type="color"], 109 | div.DTE_Inline div.DTE_Field input[type="date"], 110 | div.DTE_Inline div.DTE_Field input[type="datetime"], 111 | div.DTE_Inline div.DTE_Field input[type="datetime-local"], 112 | div.DTE_Inline div.DTE_Field input[type="email"], 113 | div.DTE_Inline div.DTE_Field input[type="month"], 114 | div.DTE_Inline div.DTE_Field input[type="number"], 115 | div.DTE_Inline div.DTE_Field input[type="password"], 116 | div.DTE_Inline div.DTE_Field input[type="search"], 117 | div.DTE_Inline div.DTE_Field input[type="tel"], 118 | div.DTE_Inline div.DTE_Field input[type="text"], 119 | div.DTE_Inline div.DTE_Field input[type="time"], 120 | div.DTE_Inline div.DTE_Field input[type="url"], 121 | div.DTE_Inline div.DTE_Field input[type="week"] { 122 | margin: -6px 0; 123 | } 124 | div.DTE_Inline div.DTE_Field_Error, 125 | div.DTE_Inline div.DTE_Form_Error { 126 | font-size: 11px; 127 | line-height: 1.2em; 128 | padding: 0; 129 | margin-top: 10px; 130 | } 131 | div.DTE_Inline div.DTE_Field_Error:empty, 132 | div.DTE_Inline div.DTE_Form_Error:empty { 133 | margin-top: 0; 134 | } 135 | 136 | span.dtr-data div.DTE_Inline { 137 | display: inline-table; 138 | } 139 | 140 | div.DTE.DTE_Inline.ui.form label { 141 | display: none !important; 142 | } 143 | div.DTE.DTE_Inline.ui.form div.DTE_Field { 144 | width: 100%; 145 | margin: 0 !important; 146 | } 147 | div.DTE.DTE_Inline.ui.form div.DTE_Field div.DTE_Field_Input { 148 | width: 100% !important; 149 | box-sizing: border-box; 150 | } 151 | div.DTE.DTE_Inline.ui.form div.DTE_Field > div { 152 | width: 100%; 153 | padding: 0; 154 | } 155 | div.DTE.DTE_Inline.ui.form.DTE_Processing:after { 156 | top: 5px; 157 | } 158 | 159 | div.DTE_Bubble { 160 | position: absolute; 161 | z-index: 11; 162 | margin-top: -6px; 163 | opacity: 0; 164 | } 165 | div.DTE_Bubble div.DTE_Bubble_Liner { 166 | position: absolute; 167 | bottom: 0; 168 | border: 1px solid black; 169 | width: 300px; 170 | margin-left: -150px; 171 | background-color: white; 172 | box-shadow: 0 12px 30px 0 rgba(0, 0, 0, 0.5); 173 | border-radius: 6px; 174 | border: 1px solid #666; 175 | padding: 1em; 176 | background: #fcfcfc; 177 | -webkit-box-sizing: border-box; 178 | -moz-box-sizing: border-box; 179 | box-sizing: border-box; 180 | } 181 | div.DTE_Bubble div.DTE_Bubble_Liner div.DTE_Bubble_Table { 182 | width: 100%; 183 | } 184 | div.DTE_Bubble div.DTE_Bubble_Liner div.DTE_Bubble_Table > form div.DTE_Form_Content { 185 | padding: 0; 186 | } 187 | div.DTE_Bubble div.DTE_Bubble_Liner div.DTE_Bubble_Table > form div.DTE_Form_Content div.DTE_Field { 188 | position: relative; 189 | zoom: 1; 190 | margin-bottom: 0.5em; 191 | } 192 | div.DTE_Bubble div.DTE_Bubble_Liner div.DTE_Bubble_Table > form div.DTE_Form_Content div.DTE_Field:last-child { 193 | margin-bottom: 0; 194 | } 195 | div.DTE_Bubble div.DTE_Bubble_Liner div.DTE_Bubble_Table > form div.DTE_Form_Content div.DTE_Field > label { 196 | padding-top: 0; 197 | margin-bottom: 0; 198 | } 199 | div.DTE_Bubble div.DTE_Bubble_Liner div.DTE_Bubble_Table > form div.DTE_Form_Content div.DTE_Field > div { 200 | padding: 0; 201 | } 202 | div.DTE_Bubble div.DTE_Bubble_Liner div.DTE_Bubble_Table > form div.DTE_Form_Content div.DTE_Field > div input { 203 | margin: 0; 204 | } 205 | div.DTE_Bubble div.DTE_Bubble_Liner div.DTE_Bubble_Table div.DTE_Form_Buttons { 206 | text-align: right; 207 | margin-top: 1em; 208 | } 209 | div.DTE_Bubble div.DTE_Bubble_Liner div.DTE_Bubble_Table div.DTE_Form_Buttons button { 210 | margin-bottom: 0; 211 | } 212 | div.DTE_Bubble div.DTE_Bubble_Liner div.DTE_Header { 213 | border-top-left-radius: 5px; 214 | border-top-right-radius: 5px; 215 | } 216 | div.DTE_Bubble div.DTE_Bubble_Liner div.DTE_Header + div.DTE_Form_Info, 217 | div.DTE_Bubble div.DTE_Bubble_Liner div.DTE_Header + div.DTE_Bubble_Table { 218 | padding-top: 42px; 219 | } 220 | div.DTE_Bubble div.DTE_Bubble_Liner div.DTE_Form_Error { 221 | float: none; 222 | display: none; 223 | padding: 0; 224 | margin-bottom: 0.5em; 225 | } 226 | div.DTE_Bubble div.DTE_Bubble_Liner div.DTE_Bubble_Close { 227 | position: absolute; 228 | top: -11px; 229 | right: -11px; 230 | width: 22px; 231 | height: 22px; 232 | border: 2px solid white; 233 | background-color: black; 234 | text-align: center; 235 | border-radius: 15px; 236 | cursor: pointer; 237 | z-index: 12; 238 | box-shadow: 2px 2px 6px #111; 239 | } 240 | div.DTE_Bubble div.DTE_Bubble_Liner div.DTE_Bubble_Close:after { 241 | content: '\00d7'; 242 | color: white; 243 | font-weight: bold; 244 | font-size: 18px; 245 | line-height: 22px; 246 | font-family: 'Courier New', Courier, monospace; 247 | padding-left: 1px; 248 | } 249 | div.DTE_Bubble div.DTE_Bubble_Liner div.DTE_Bubble_Close:hover { 250 | background-color: #092079; 251 | box-shadow: 2px 2px 9px #111; 252 | } 253 | div.DTE_Bubble div.DTE_Bubble_Triangle { 254 | position: absolute; 255 | height: 10px; 256 | width: 10px; 257 | top: -6px; 258 | background-color: white; 259 | border: 1px solid #666; 260 | border-top: none; 261 | border-right: none; 262 | -webkit-transform: rotate(-45deg); 263 | -moz-transform: rotate(-45deg); 264 | -ms-transform: rotate(-45deg); 265 | -o-transform: rotate(-45deg); 266 | transform: rotate(-45deg); 267 | } 268 | div.DTE_Bubble.below div.DTE_Bubble_Liner { 269 | top: 10px; 270 | bottom: auto; 271 | } 272 | div.DTE_Bubble.below div.DTE_Bubble_Triangle { 273 | top: 4px; 274 | -webkit-transform: rotate(135deg); 275 | -moz-transform: rotate(135deg); 276 | -ms-transform: rotate(135deg); 277 | -o-transform: rotate(135deg); 278 | transform: rotate(135deg); 279 | } 280 | 281 | div.DTE_Bubble_Background { 282 | position: fixed; 283 | top: 0; 284 | left: 0; 285 | width: 100%; 286 | height: 100%; 287 | background: rgba(0, 0, 0, 0.7); 288 | /* Fallback */ 289 | background: -ms-radial-gradient(center, ellipse farthest-corner, rgba(0, 0, 0, 0.3) 0%, rgba(0, 0, 0, 0.7) 100%); 290 | /* IE10 Consumer Preview */ 291 | background: -moz-radial-gradient(center, ellipse farthest-corner, rgba(0, 0, 0, 0.3) 0%, rgba(0, 0, 0, 0.7) 100%); 292 | /* Firefox */ 293 | background: -o-radial-gradient(center, ellipse farthest-corner, rgba(0, 0, 0, 0.3) 0%, rgba(0, 0, 0, 0.7) 100%); 294 | /* Opera */ 295 | background: -webkit-gradient(radial, center center, 0, center center, 497, color-stop(0, rgba(0, 0, 0, 0.3)), color-stop(1, rgba(0, 0, 0, 0.7))); 296 | /* Webkit (Safari/Chrome 10) */ 297 | background: -webkit-radial-gradient(center, ellipse farthest-corner, rgba(0, 0, 0, 0.3) 0%, rgba(0, 0, 0, 0.7) 100%); 298 | /* Webkit (Chrome 11+) */ 299 | background: radial-gradient(ellipse farthest-corner at center, rgba(0, 0, 0, 0.3) 0%, rgba(0, 0, 0, 0.7) 100%); 300 | /* W3C Markup, IE10 Release Preview */ 301 | z-index: 10; 302 | } 303 | div.DTE_Bubble_Background > div { 304 | position: absolute; 305 | top: 0; 306 | right: 0; 307 | left: 0; 308 | bottom: 0; 309 | filter: progid:DXImageTransform.Microsoft.gradient(startColorstr=#99000000, endColorstr=#99000000); 310 | -ms-filter: "progid:DXImageTransform.Microsoft.gradient(startColorstr=#99000000, endColorstr=#99000000)"; 311 | } 312 | div.DTE_Bubble_Background > div:not([dummy]) { 313 | filter: progid:DXImageTransform.Microsoft.gradient(enabled='false'); 314 | } 315 | 316 | div.DTE_Bubble { 317 | z-index: 1001; 318 | } 319 | div.DTE_Bubble div.DTE_Bubble_Liner { 320 | box-shadow: 0 5px 10px rgba(0, 0, 0, 0.2); 321 | border-radius: 6px; 322 | padding: 1em; 323 | border: 1px solid rgba(0, 0, 0, 0.2); 324 | } 325 | div.DTE_Bubble div.DTE_Bubble_Liner div.DTE_Bubble_Table > form div.DTE_Form_Content div.DTE_Field { 326 | flex-direction: column; 327 | } 328 | div.DTE_Bubble div.DTE_Bubble_Liner div.DTE_Bubble_Table > form div.DTE_Form_Content div.DTE_Field label, 329 | div.DTE_Bubble div.DTE_Bubble_Liner div.DTE_Bubble_Table > form div.DTE_Form_Content div.DTE_Field > div { 330 | justify-content: flex-start; 331 | width: 100% !important; 332 | float: none; 333 | clear: both; 334 | text-align: left; 335 | } 336 | div.DTE_Bubble div.DTE_Bubble_Liner div.DTE_Bubble_Table > form div.DTE_Form_Content div.DTE_Field label { 337 | padding-bottom: 4px; 338 | } 339 | div.DTE_Bubble div.DTE_Bubble_Liner div.DTE_Bubble_Table > form div.DTE_Form_Content div.DTE_Field:first-child label { 340 | padding-top: 0 !important; 341 | } 342 | div.DTE_Bubble div.DTE_Bubble_Liner div.DTE_Bubble_Table div.DTE_Form_Buttons { 343 | text-align: right; 344 | padding: 0; 345 | } 346 | div.DTE_Bubble div.DTE_Bubble_Triangle { 347 | border-bottom: 1px solid rgba(0, 0, 0, 0.2); 348 | border-left: 1px solid rgba(0, 0, 0, 0.2); 349 | } 350 | 351 | div.DTE div.editor_upload { 352 | padding-top: 4px; 353 | } 354 | div.DTE div.editor_upload div.eu_table { 355 | display: table; 356 | width: 100%; 357 | } 358 | div.DTE div.editor_upload div.row { 359 | display: table-row; 360 | } 361 | div.DTE div.editor_upload div.cell { 362 | display: table-cell; 363 | position: relative; 364 | width: 50%; 365 | vertical-align: top; 366 | } 367 | div.DTE div.editor_upload div.cell + div.cell { 368 | padding-left: 10px; 369 | } 370 | div.DTE div.editor_upload div.row + div.row div.cell { 371 | padding-top: 10px; 372 | } 373 | div.DTE div.editor_upload button.btn, 374 | div.DTE div.editor_upload input[type=file] { 375 | width: 100%; 376 | height: 2.3em; 377 | font-size: 0.8em; 378 | text-align: center; 379 | line-height: 1em; 380 | } 381 | div.DTE div.editor_upload input[type=file] { 382 | position: absolute; 383 | top: 0; 384 | left: 0; 385 | width: 100%; 386 | opacity: 0; 387 | } 388 | div.DTE div.editor_upload div.drop { 389 | position: relative; 390 | box-sizing: border-box; 391 | width: 100%; 392 | height: 100%; 393 | border: 3px dashed #ccc; 394 | border-radius: 6px; 395 | min-height: 4em; 396 | color: #999; 397 | padding-top: 3px; 398 | text-align: center; 399 | } 400 | div.DTE div.editor_upload div.drop.over { 401 | border: 3px dashed #111; 402 | color: #111; 403 | } 404 | div.DTE div.editor_upload div.drop span { 405 | max-width: 75%; 406 | font-size: 0.85em; 407 | line-height: 1em; 408 | } 409 | div.DTE div.editor_upload div.rendered img { 410 | max-width: 8em; 411 | margin: 0 auto; 412 | } 413 | div.DTE div.editor_upload.noDrop div.drop { 414 | display: none; 415 | } 416 | div.DTE div.editor_upload.noDrop div.row.second { 417 | display: none; 418 | } 419 | div.DTE div.editor_upload.noDrop div.rendered { 420 | margin-top: 10px; 421 | } 422 | div.DTE div.editor_upload.noClear div.clearValue button { 423 | display: none; 424 | } 425 | div.DTE div.editor_upload.multi div.cell { 426 | display: block; 427 | width: 100%; 428 | } 429 | div.DTE div.editor_upload.multi div.cell div.drop { 430 | min-height: 0; 431 | padding-bottom: 5px; 432 | } 433 | div.DTE div.editor_upload.multi div.clearValue { 434 | display: none; 435 | } 436 | div.DTE div.editor_upload.multi ul { 437 | list-style-type: none; 438 | margin: 0; 439 | padding: 0; 440 | } 441 | div.DTE div.editor_upload.multi ul li { 442 | position: relative; 443 | margin-top: 0.5em; 444 | } 445 | div.DTE div.editor_upload.multi ul li:first-child { 446 | margin-top: 0; 447 | } 448 | div.DTE div.editor_upload.multi ul li img { 449 | vertical-align: middle; 450 | } 451 | div.DTE div.editor_upload.multi ul li button { 452 | position: absolute; 453 | width: 40px; 454 | right: 0; 455 | top: 50%; 456 | margin-top: -1.5em; 457 | } 458 | 459 | div.DTE div.editor_upload button.btn, 460 | div.DTE div.editor_upload input[type=file] { 461 | height: auto; 462 | } 463 | div.DTE div.editor_upload ul li button { 464 | padding-bottom: 8px; 465 | } 466 | 467 | div.editor-datetime { 468 | position: absolute; 469 | background-color: white; 470 | z-index: 2050; 471 | border: 1px solid #ccc; 472 | box-shadow: 0 5px 15px -5px rgba(0, 0, 0, 0.5); 473 | padding: 0 20px 6px 20px; 474 | width: 275px; 475 | } 476 | div.editor-datetime div.editor-datetime-title { 477 | text-align: center; 478 | padding: 5px 0px 3px; 479 | } 480 | div.editor-datetime table { 481 | border-spacing: 0; 482 | margin: 12px 0; 483 | width: 100%; 484 | } 485 | div.editor-datetime table.editor-datetime-table-nospace { 486 | margin-top: -12px; 487 | } 488 | div.editor-datetime table th { 489 | font-size: 0.8em; 490 | color: #777; 491 | font-weight: normal; 492 | width: 14.285714286%; 493 | padding: 0 0 4px 0; 494 | text-align: center; 495 | } 496 | div.editor-datetime table td { 497 | font-size: 0.9em; 498 | color: #444; 499 | padding: 0; 500 | } 501 | div.editor-datetime table td.selectable { 502 | text-align: center; 503 | background: #f5f5f5; 504 | } 505 | div.editor-datetime table td.selectable.disabled { 506 | color: #aaa; 507 | background: white; 508 | } 509 | div.editor-datetime table td.selectable.disabled button:hover { 510 | color: #aaa; 511 | background: white; 512 | } 513 | div.editor-datetime table td.selectable.now { 514 | background-color: #ddd; 515 | } 516 | div.editor-datetime table td.selectable.now button { 517 | font-weight: bold; 518 | } 519 | div.editor-datetime table td.selectable.selected button { 520 | background: #2185D0; 521 | color: white; 522 | border-radius: 2px; 523 | } 524 | div.editor-datetime table td.selectable button:hover { 525 | background: #ff8000; 526 | color: white; 527 | border-radius: 2px; 528 | } 529 | div.editor-datetime table td.editor-datetime-week { 530 | font-size: 0.7em; 531 | } 532 | div.editor-datetime table button { 533 | width: 100%; 534 | box-sizing: border-box; 535 | border: none; 536 | background: transparent; 537 | font-size: inherit; 538 | color: inherit; 539 | text-align: center; 540 | padding: 4px 0; 541 | cursor: pointer; 542 | margin: 0; 543 | } 544 | div.editor-datetime table button span { 545 | display: inline-block; 546 | min-width: 14px; 547 | text-align: right; 548 | } 549 | div.editor-datetime table.weekNumber th { 550 | width: 12.5%; 551 | } 552 | div.editor-datetime div.editor-datetime-calendar table { 553 | margin-top: 0; 554 | } 555 | div.editor-datetime div.editor-datetime-label { 556 | position: relative; 557 | display: inline-block; 558 | height: 30px; 559 | padding: 5px 6px; 560 | border: 1px solid transparent; 561 | box-sizing: border-box; 562 | cursor: pointer; 563 | } 564 | div.editor-datetime div.editor-datetime-label:hover { 565 | border: 1px solid #ddd; 566 | border-radius: 2px; 567 | background-color: #f5f5f5; 568 | } 569 | div.editor-datetime div.editor-datetime-label select { 570 | position: absolute; 571 | top: 6px; 572 | left: 0; 573 | cursor: pointer; 574 | opacity: 0; 575 | -ms-filter: "alpha(opacity=0)"; 576 | } 577 | div.editor-datetime div.editor-datetime-time { 578 | text-align: center; 579 | } 580 | div.editor-datetime div.editor-datetime-time > span { 581 | vertical-align: middle; 582 | } 583 | div.editor-datetime div.editor-datetime-time th { 584 | text-align: left; 585 | } 586 | div.editor-datetime div.editor-datetime-time div.editor-datetime-timeblock { 587 | display: inline-block; 588 | vertical-align: middle; 589 | } 590 | div.editor-datetime div.editor-datetime-iconLeft, 591 | div.editor-datetime div.editor-datetime-iconRight, 592 | div.editor-datetime div.editor-datetime-iconUp, 593 | div.editor-datetime div.editor-datetime-iconDown { 594 | width: 30px; 595 | height: 30px; 596 | background-position: center; 597 | background-repeat: no-repeat; 598 | opacity: 0.3; 599 | overflow: hidden; 600 | box-sizing: border-box; 601 | } 602 | div.editor-datetime div.editor-datetime-iconLeft:hover, 603 | div.editor-datetime div.editor-datetime-iconRight:hover, 604 | div.editor-datetime div.editor-datetime-iconUp:hover, 605 | div.editor-datetime div.editor-datetime-iconDown:hover { 606 | border: 1px solid #ccc; 607 | border-radius: 2px; 608 | background-color: #f0f0f0; 609 | opacity: 0.6; 610 | } 611 | div.editor-datetime div.editor-datetime-iconLeft button, 612 | div.editor-datetime div.editor-datetime-iconRight button, 613 | div.editor-datetime div.editor-datetime-iconUp button, 614 | div.editor-datetime div.editor-datetime-iconDown button { 615 | border: none; 616 | background: transparent; 617 | text-indent: 30px; 618 | height: 100%; 619 | width: 100%; 620 | cursor: pointer; 621 | } 622 | div.editor-datetime div.editor-datetime-iconLeft { 623 | position: absolute; 624 | top: 5px; 625 | left: 5px; 626 | background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAeCAYAAAAsEj5rAAAAUklEQVR42u3VMQoAIBADQf8Pgj+OD9hG2CtONJB2ymQkKe0HbwAP0xucDiQWARITIDEBEnMgMQ8S8+AqBIl6kKgHiXqQqAeJepBo/z38J/U0uAHlaBkBl9I4GwAAAABJRU5ErkJggg=="); 627 | } 628 | div.editor-datetime div.editor-datetime-iconRight { 629 | position: absolute; 630 | top: 5px; 631 | right: 5px; 632 | background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAeCAYAAAAsEj5rAAAAU0lEQVR42u3VOwoAMAgE0dwfAnNjU26bYkBCFGwfiL9VVWoO+BJ4Gf3gtsEKKoFBNTCoCAYVwaAiGNQGMUHMkjGbgjk2mIONuXo0nC8XnCf1JXgArVIZAQh5TKYAAAAASUVORK5CYII="); 633 | } 634 | div.editor-datetime div.editor-datetime-iconUp { 635 | height: 20px; 636 | background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB4AAAALCAMAAABf9c24AAAAFVBMVEX///99fX1+fn57e3t6enoAAAAAAAC73bqPAAAABnRSTlMAYmJkZt92bnysAAAAL0lEQVR4AWOgJmBhxCvLyopHnpmVjY2VCadeoCxIHrcsWJ4RlyxCHlMWCTBRJxwAjrIBDMWSiM0AAAAASUVORK5CYII="); 637 | } 638 | div.editor-datetime div.editor-datetime-iconDown { 639 | height: 20px; 640 | background-image: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAB4AAAALCAMAAABf9c24AAAAFVBMVEX///99fX1+fn57e3t6enoAAAAAAAC73bqPAAAABnRSTlMAYmJkZt92bnysAAAAMElEQVR4AWOgDmBiRQIsmPKMrGxQgJDFlEfIYpoPk8Utz8qM232MYFfhkQfKUg8AANefAQxecJ58AAAAAElFTkSuQmCC"); 641 | } 642 | 643 | div.editor-datetime-error { 644 | padding: 0 1em; 645 | max-width: 240px; 646 | font-size: 11px; 647 | line-height: 1.25em; 648 | text-align: center; 649 | color: #b11f1f; 650 | } 651 | 652 | div.DTE div.DTE_Processing_Indicator { 653 | position: absolute; 654 | top: 17px; 655 | right: 9px; 656 | height: 2em; 657 | width: 2em; 658 | z-index: 20; 659 | font-size: 12px; 660 | display: none; 661 | -webkit-transform: translateZ(0); 662 | -ms-transform: translateZ(0); 663 | transform: translateZ(0); 664 | } 665 | div.DTE.processing div.DTE_Processing_Indicator { 666 | display: block; 667 | } 668 | div.DTE.processing div.DTE_Field div.DTE_Processing_Indicator { 669 | display: none; 670 | } 671 | div.DTE div.DTE_Field div.DTE_Processing_Indicator { 672 | top: 13px; 673 | right: 0; 674 | font-size: 8px; 675 | } 676 | div.DTE.DTE_Inline div.DTE_Processing_Indicator { 677 | top: 5px; 678 | right: 6px; 679 | font-size: 6px; 680 | } 681 | div.DTE.DTE_Bubble div.DTE_Processing_Indicator { 682 | top: 10px; 683 | right: 14px; 684 | font-size: 8px; 685 | } 686 | div.DTE div.DTE_Processing_Indicator span, 687 | div.DTE div.DTE_Processing_Indicator:before, 688 | div.DTE div.DTE_Processing_Indicator:after { 689 | display: block; 690 | background: black; 691 | width: 0.5em; 692 | height: 1.5em; 693 | border: 1px solid rgba(0, 0, 0, 0.4); 694 | background-color: rgba(0, 0, 0, 0.1); 695 | -webkit-animation: editorProcessing 0.9s infinite ease-in-out; 696 | animation: editorProcessing 0.9s infinite ease-in-out; 697 | } 698 | div.DTE div.DTE_Processing_Indicator:before, 699 | div.DTE div.DTE_Processing_Indicator:after { 700 | position: absolute; 701 | top: 0; 702 | content: ''; 703 | } 704 | div.DTE div.DTE_Processing_Indicator:before { 705 | left: -1em; 706 | -webkit-animation-delay: -0.3s; 707 | animation-delay: -0.3s; 708 | } 709 | div.DTE div.DTE_Processing_Indicator span { 710 | -webkit-animation-delay: -0.15s; 711 | animation-delay: -0.15s; 712 | } 713 | div.DTE div.DTE_Processing_Indicator:after { 714 | left: 1em; 715 | } 716 | @-webkit-keyframes editorProcessing { 717 | 0%, 718 | 80%, 719 | 100% { 720 | transform: scale(1, 1); 721 | } 722 | 40% { 723 | transform: scale(1, 1.5); 724 | } 725 | } 726 | @keyframes editorProcessing { 727 | 0%, 728 | 80%, 729 | 100% { 730 | transform: scale(1, 1); 731 | } 732 | 40% { 733 | transform: scale(1, 1.5); 734 | } 735 | } 736 | div.DTE div.DTE_Processing_Indicator { 737 | top: 22px; 738 | right: 12px; 739 | } 740 | --------------------------------------------------------------------------------