├── ajax_cbv ├── tests │ ├── __init__.py │ ├── test_views.py │ └── test_mixins.py ├── apps.py ├── __init__.py ├── views.py ├── static │ └── ajax_cbv │ │ ├── partials.jquery.js │ │ └── forms.jquery.js └── mixins.py ├── MANIFEST.in ├── AUTHORS.md ├── runtests.py ├── CHANGELOG.md ├── .coveragerc ├── tox.ini ├── .editorconfig ├── .travis.yml ├── LICENSE ├── .gitignore ├── README.rst ├── setup.py └── CODE_OF_CONDUCT.md /ajax_cbv/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /ajax_cbv/apps.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from django.apps import AppConfig 4 | 5 | 6 | class AjaxCBVConfig(AppConfig): 7 | name = 'ajax_cbv' 8 | -------------------------------------------------------------------------------- /ajax_cbv/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | 5 | default_app_config = 'ajax_cbv.apps.AjaxCBVConfig' 6 | 7 | __version__ = '0.1.1' 8 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include AUTHORS.md 2 | include CHANGELOG.md 3 | include CODE_OF_CONDUCT.md 4 | include LICENSE 5 | include MANIFEST.in 6 | include README.rst 7 | recursive-include ajax_cbv/static * 8 | -------------------------------------------------------------------------------- /AUTHORS.md: -------------------------------------------------------------------------------- 1 | # Authors 2 | 3 | We'd like to thank the following people for their contributions. 4 | 5 | - Sandro Rodrigues 6 | - Tiago Brito 7 | - Tiago Loureiro 8 | - Paulo Truta 9 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | # /usr/bin/python 2 | import django 3 | import sys 4 | 5 | from django.test.runner import DiscoverRunner 6 | from django.conf import settings 7 | 8 | settings.configure( 9 | INSTALLED_APPS=( 10 | 'ajax_cbv', 11 | ), 12 | ) 13 | 14 | if __name__ == "__main__": 15 | django.setup() 16 | runner = DiscoverRunner() 17 | failures = runner.run_tests(['ajax_cbv']) 18 | if failures: 19 | sys.exit(failures) 20 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/) 5 | and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html). 6 | 7 | ## [0.1.1] - 2017-09-13 8 | ### Added 9 | - Support to python 3.4, 3.5 and 3.6 and django 1.9, 1.10 and 1.11. 10 | 11 | ## [0.1.0] - 2017-09-13 12 | - First release on PyPi 13 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source=ajax_cbv/ 3 | branch=True 4 | omit= 5 | *tests* 6 | *settings* 7 | 8 | [report] 9 | skip_covered=True 10 | show_missing=True 11 | ignore_errors=True 12 | sort=Cover 13 | exclude_lines = 14 | # Have to re-enable the standard pragma 15 | pragma: no cover 16 | 17 | # Don't complain about missing debug-only code: 18 | def __repr__ 19 | if self\.debug 20 | 21 | # Don't complain if tests don't hit defensive assertion code: 22 | raise AssertionError 23 | raise NotImplementedError 24 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | skipsdist = True 3 | envlist = 4 | py{27,34,35}-django{19,110,111}, 5 | py{35,36}-django{111,master} 6 | flake8 7 | 8 | [testenv] 9 | deps = 10 | coverage 11 | mock >= 2.0.0 12 | django19: Django>=1.9,<1.10 13 | django110: Django>=1.10,<1.11 14 | django111: Django>=1.11,<2.0 15 | djangomaster: https://github.com/django/django/archive/master.tar.gz 16 | commands= 17 | coverage run runtests.py {posargs:} 18 | coverage report 19 | coverage xml 20 | 21 | [testenv:flake8] 22 | deps = flake8 23 | commands = flake8 {toxinidir}/ajax_cbv 24 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: http://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | charset = utf-8 9 | indent_size = 4 10 | end_of_line = lf 11 | indent_style = space 12 | insert_final_newline = true 13 | trim_trailing_whitespace = true 14 | 15 | # Matches the exact files either package.json or .travis.yml 16 | [.travis.yml] 17 | indent_style = space 18 | indent_size = 2 19 | 20 | # The JSON files contain newlines inconsistently 21 | [*.json] 22 | indent_size = 2 23 | insert_final_newline = ignore 24 | -------------------------------------------------------------------------------- /ajax_cbv/tests/test_views.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from ajax_cbv.views import DeleteAjaxView 4 | from django.test import RequestFactory, SimpleTestCase 5 | from mock import Mock, patch 6 | 7 | 8 | class DeleteAjaxViewTest(SimpleTestCase): 9 | """docstring for ClassName""" 10 | 11 | def setUp(self): 12 | # Every test needs access to the request factory. 13 | self.factory = RequestFactory() 14 | 15 | @patch.object(DeleteAjaxView, 'get_object', return_value=Mock()) 16 | def test_delete(self, mget_object): 17 | request = self.factory.delete('/unit/') 18 | response = DeleteAjaxView.as_view()(request) 19 | mget_object().delete.assert_called_with() 20 | self.assertEqual(response.status_code, 200) 21 | -------------------------------------------------------------------------------- /ajax_cbv/views.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import absolute_import, unicode_literals 3 | 4 | from django.views.generic import ( 5 | CreateView, DeleteView, TemplateView, UpdateView) 6 | 7 | from .mixins import AjaxResponseMixin, FormAjaxMixin, PartialAjaxMixin 8 | 9 | 10 | class TemplateAjaxView(PartialAjaxMixin, TemplateView): 11 | """ """ 12 | 13 | 14 | class CreateAjaxView(FormAjaxMixin, PartialAjaxMixin, CreateView): 15 | """ """ 16 | 17 | 18 | class UpdateAjaxView(FormAjaxMixin, PartialAjaxMixin, UpdateView): 19 | """ """ 20 | 21 | 22 | class DeleteAjaxView(PartialAjaxMixin, AjaxResponseMixin, DeleteView): 23 | """ """ 24 | 25 | def delete(self, request, *args, **kwargs): 26 | self.object = self.get_object() 27 | self.object.delete() 28 | return self.json_to_response() 29 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: python 3 | 4 | python: 5 | - "2.7" 6 | - "3.4" 7 | - "3.5" 8 | - "3.6" 9 | 10 | env: 11 | matrix: 12 | - django19 13 | - django110 14 | - django111 15 | - djangomaster 16 | 17 | matrix: 18 | fast_finish: true 19 | include: 20 | - python: "3.4" 21 | env: flake8 22 | exclude: 23 | - python: "2.7" 24 | env: djangomaster 25 | - python: "3.6" 26 | env: django19 27 | - python: "3.6" 28 | env: django111 29 | allow_failures: 30 | - env: djangomaster 31 | 32 | cache: 33 | directories: 34 | - $HOME/.cache/pip 35 | - $TRAVIS_BUILD_DIR/.tox 36 | 37 | install: 38 | - pip install --upgrade pip wheel setuptools 39 | - pip install coveralls codacy-coverage tox-travis 40 | 41 | script: 42 | - tox 43 | 44 | after_success: 45 | - coveralls 46 | - python-codacy-coverage -r coverage.xml 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Dipcode 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # IPython Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | 78 | # dotenv 79 | .env 80 | 81 | # virtualenv 82 | venv/ 83 | ENV/ 84 | 85 | # Spyder project settings 86 | .spyderproject 87 | 88 | # Rope project settings 89 | .ropeproject 90 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Django Ajax CBV 2 | ================= 3 | 4 | |Build Status| |Codacy Badge| |Coverage Status| |BCH compliance| |Pypi| 5 | 6 | Django module to easily use generic Class Based views with ajax. 7 | 8 | Table of contents: 9 | * `How to install`_; 10 | * `License`_. 11 | 12 | Compatibility 13 | ------------- 14 | Tested with python 2.7, 3.4, 3.5, 3.6 and Django 1.9, 1.10, 1.11: `Travis CI `_ 15 | 16 | How to install 17 | -------------- 18 | 19 | To install the app run : 20 | 21 | .. code:: shell 22 | 23 | pip install django-ajax-cbv 24 | 25 | or add it to the list of requirements of your project. 26 | 27 | Then add ‘ajax\_cbv’ to your INSTALLED\_APPS. 28 | 29 | .. code:: python 30 | 31 | INSTALLED_APPS = [ 32 | ... 33 | 'ajax_cbv', 34 | ] 35 | 36 | License 37 | ------- 38 | 39 | MIT license, see the LICENSE file. You can use obfuscator in open source 40 | projects and commercial products. 41 | 42 | .. _How to install: #how-to-install 43 | .. _License: #license 44 | 45 | .. |Build Status| image:: https://travis-ci.org/dipcode-software/django-ajax-cbv.svg?branch=master 46 | :target: https://travis-ci.org/dipcode-software/django-ajax-cbv 47 | .. |Codacy Badge| image:: https://api.codacy.com/project/badge/Grade/a64f03c2bd344561bc21e05c23aa04fb 48 | :target: https://www.codacy.com/app/srtabs/django-ajax-cbv?utm_source=github.com&utm_medium=referral&utm_content=dipcode-software/django-ajax-cbv&utm_campaign=Badge_Grade 49 | .. |Coverage Status| image:: https://coveralls.io/repos/github/dipcode-software/django-ajax-cbv/badge.svg?branch=master 50 | :target: https://coveralls.io/github/dipcode-software/django-ajax-cbv?branch=master 51 | .. |BCH compliance| image:: https://bettercodehub.com/edge/badge/dipcode-software/django-ajax-cbv?branch=master 52 | :target: https://bettercodehub.com/ 53 | .. |Pypi| image:: https://img.shields.io/pypi/v/django-ajax-cbv.svg?style=flat-square 54 | :target: https://pypi.python.org/pypi/django-ajax-cbv 55 | -------------------------------------------------------------------------------- /ajax_cbv/static/ajax_cbv/partials.jquery.js: -------------------------------------------------------------------------------- 1 | /* globals jQuery */ 2 | 3 | /****************************************************************** 4 | * Author: Dipcode 5 | *****************************************************************/ 6 | 7 | (function($) { 8 | "use strict"; 9 | 10 | $.fn.djangoPartials = function (options) { 11 | 12 | var opts = $.extend({ 13 | injectInSelector: null, 14 | previousContentData: 'previous-content' 15 | }, $.fn.djangoPartials.defaults, options); 16 | 17 | 18 | function DjangoPartials ($elem) { 19 | 20 | var self = this; 21 | var $partialContainer = $($elem.data('partial')); 22 | var partialUrl = $elem.data('partial-url'); 23 | 24 | $elem.on('click', function () { 25 | 26 | var partialData = $(this).data('partial-data') || {}; 27 | // redefine previous content 28 | var previousContent = $partialContainer.data(opts.previousContentData); 29 | 30 | if (previousContent) { 31 | self.injectPartial($partialContainer, previousContent); 32 | } 33 | 34 | $elem.trigger('partial:loading', [$partialContainer]); 35 | 36 | self.request(partialUrl, partialData).done(function (data) { 37 | 38 | self.injectPartial($partialContainer, data.content).done(function() { 39 | $partialContainer.find('form').djangoAjaxForms(); 40 | $elem.trigger('partial:contentLoaded', [$partialContainer, data]); 41 | }); 42 | 43 | }).fail(function() { 44 | $elem.trigger('partial:fail', [$partialContainer]); 45 | }).always(function() { 46 | $elem.trigger('partial:finished', [$partialContainer]); 47 | }); 48 | 49 | }); 50 | } 51 | 52 | DjangoPartials.prototype = { 53 | 54 | request: function request(url, data) 55 | { 56 | return $.ajax({ 57 | method: 'GET', 58 | url: url, 59 | data: data 60 | }); 61 | }, 62 | 63 | injectPartial: function injectPartial($container, data) 64 | { 65 | 66 | var $injectInElem = $container.find(opts.injectInSelector); 67 | 68 | $injectInElem = $injectInElem.length ? $injectInElem : $container; 69 | 70 | // save previous content in temporary data var of container element 71 | $container.data(opts.previousContentData, $injectInElem.html()); 72 | 73 | return $injectInElem.html(data).promise(); 74 | } 75 | }; 76 | 77 | return this.each(function () { 78 | new DjangoPartials($(this)); 79 | }); 80 | }; 81 | 82 | 83 | $.fn.djangoPartials.defaults = {}; 84 | 85 | })(jQuery); 86 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from __future__ import unicode_literals 3 | 4 | import io 5 | import os 6 | from shutil import rmtree 7 | import sys 8 | 9 | from setuptools import Command, find_packages, setup 10 | 11 | 12 | # Package meta-data. 13 | VERSION = __import__("ajax_cbv").__version__ 14 | NAME = 'django-ajax-cbv' 15 | DESCRIPTION = 'Django module to easily use generic views with ajax. ' 16 | URL = 'https://github.com/dipcode-software/django-ajax-cbv/' 17 | EMAIL = 'team@dipcode.com' 18 | AUTHOR = 'Dipcode' 19 | 20 | 21 | here = os.path.abspath(os.path.dirname(__file__)) 22 | with io.open(os.path.join(here, 'README.rst'), encoding='utf-8') as f: 23 | long_description = '\n' + f.read() 24 | 25 | 26 | class PublishCommand(Command): 27 | """Support setup.py publish.""" 28 | 29 | description = 'Build and publish the package.' 30 | user_options = [] 31 | environment = None 32 | 33 | @staticmethod 34 | def status(s): 35 | """Prints things in bold.""" 36 | print('\033[1m{0}\033[0m'.format(s)) 37 | 38 | def initialize_options(self): 39 | pass 40 | 41 | def finalize_options(self): 42 | pass 43 | 44 | def run(self): 45 | try: 46 | self.status('Removing previous builds…') 47 | rmtree(os.path.join(here, 'dist')) 48 | rmtree(os.path.join(here, 'build')) 49 | except OSError: 50 | pass 51 | 52 | self.status('Building Source and Wheel (universal) distribution…') 53 | os.system( 54 | '{0} setup.py sdist bdist_wheel --universal'.format(sys.executable) 55 | ) 56 | 57 | self.status('Uploading the package to {env} via Twine…'\ 58 | .format(env=self.environment)) 59 | os.system('twine upload --repository {env} dist/*'.format(env=self.environment)) 60 | 61 | sys.exit() 62 | 63 | 64 | class ProductionPublishCommand(PublishCommand): 65 | environment = 'pypi' 66 | 67 | 68 | class DevelopmentPublishCommand(PublishCommand): 69 | environment = 'testpypi' 70 | 71 | 72 | setup( 73 | name=NAME, 74 | version=VERSION, 75 | description=DESCRIPTION, 76 | long_description=long_description, 77 | author=AUTHOR, 78 | author_email=EMAIL, 79 | url=URL, 80 | packages=find_packages(), 81 | include_package_data=True, 82 | license='MIT', 83 | classifiers=[ 84 | 'Environment :: Web Environment', 85 | 'Framework :: Django', 86 | 'Framework :: Django :: 1.9', 87 | 'Framework :: Django :: 1.10', 88 | 'Framework :: Django :: 1.11', 89 | 'Intended Audience :: Developers', 90 | 'License :: OSI Approved :: MIT License', 91 | 'Operating System :: OS Independent', 92 | 'Programming Language :: Python', 93 | 'Programming Language :: Python :: 2.7', 94 | 'Programming Language :: Python :: 3.4', 95 | 'Programming Language :: Python :: 3.5', 96 | 'Programming Language :: Python :: 3.6', 97 | ], 98 | cmdclass={ 99 | 'publish_dev': DevelopmentPublishCommand, 100 | 'publish_prod': ProductionPublishCommand, 101 | }, 102 | ) 103 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Contributor Covenant Code of Conduct 2 | 3 | ## Our Pledge 4 | 5 | In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation. 6 | 7 | ## Our Standards 8 | 9 | Examples of behavior that contributes to creating a positive environment include: 10 | 11 | * Using welcoming and inclusive language 12 | * Being respectful of differing viewpoints and experiences 13 | * Gracefully accepting constructive criticism 14 | * Focusing on what is best for the community 15 | * Showing empathy towards other community members 16 | 17 | Examples of unacceptable behavior by participants include: 18 | 19 | * The use of sexualized language or imagery and unwelcome sexual attention or advances 20 | * Trolling, insulting/derogatory comments, and personal or political attacks 21 | * Public or private harassment 22 | * Publishing others' private information, such as a physical or electronic address, without explicit permission 23 | * Other conduct which could reasonably be considered inappropriate in a professional setting 24 | 25 | ## Our Responsibilities 26 | 27 | Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior. 28 | 29 | Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful. 30 | 31 | ## Scope 32 | 33 | This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers. 34 | 35 | ## Enforcement 36 | 37 | Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by contacting the project team at info@dipcode.com. The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately. 38 | 39 | Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership. 40 | 41 | ## Attribution 42 | 43 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version] 44 | 45 | [homepage]: http://contributor-covenant.org 46 | [version]: http://contributor-covenant.org/version/1/4/ 47 | -------------------------------------------------------------------------------- /ajax_cbv/mixins.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | from django.conf import settings 4 | from django.http import Http404, JsonResponse 5 | from django.template.loader import render_to_string 6 | 7 | 8 | class AjaxResponseAction(): 9 | """ Represents list of actions available after ajax response """ 10 | 11 | NOTHING = "nothing" 12 | REDIRECT = "redirect" 13 | REFRESH = "refresh" 14 | 15 | choices = ( 16 | NOTHING, 17 | REDIRECT, 18 | REFRESH 19 | ) 20 | 21 | 22 | class AjaxResponseStatus(): 23 | """ Represents list of status available at ajax response """ 24 | 25 | ERROR = "error" 26 | SUCCESS = "success" 27 | 28 | choices = ( 29 | ERROR, 30 | SUCCESS, 31 | ) 32 | 33 | 34 | class AjaxResponseMixin(object): 35 | """ Mixin responsible to give the JSON response """ 36 | action = AjaxResponseAction.NOTHING 37 | json_status = AjaxResponseStatus.SUCCESS 38 | 39 | def json_to_response(self, action=None, json_status=None, success_url=None, 40 | json_data=None, **response_kwargs): 41 | """ Valid response with next action to be followed by the JS """ 42 | data = { 43 | "status": self.get_status(json_status), 44 | "action": self.get_action(action), 45 | "extra_data": self.get_json_data(json_data or {}) 46 | } 47 | 48 | if self.action == AjaxResponseAction.REDIRECT: 49 | data["action_url"] = success_url or self.get_success_url() 50 | return JsonResponse(data, **response_kwargs) 51 | 52 | def get_action(self, action=None): 53 | """ Returns action to take after call """ 54 | if action: 55 | self.action = action 56 | 57 | if self.action not in AjaxResponseAction.choices: 58 | raise ValueError( 59 | "Invalid action selected: '{}'".format(self.action)) 60 | 61 | return self.action 62 | 63 | def get_status(self, json_status=None): 64 | """ Returns status of for json """ 65 | if json_status: 66 | self.json_status = json_status 67 | 68 | if self.json_status not in AjaxResponseStatus.choices: 69 | raise ValueError( 70 | "Invalid status selected: '{}'".format(self.json_status)) 71 | 72 | return self.json_status 73 | 74 | def get_json_data(self, json_data=None): 75 | """ Returns any extra data to add to json """ 76 | return json_data or {} 77 | 78 | 79 | class FormAjaxMixin(AjaxResponseMixin): 80 | """ Mixin responsible to take care of form ajax submission """ 81 | 82 | def form_invalid(self, form, prefix=None): 83 | """ If form invalid return error list in JSON response """ 84 | response = super(FormAjaxMixin, self).form_invalid(form) 85 | if self.request.is_ajax(): 86 | data = { 87 | "errors_list": self.add_prefix(form.errors, prefix), 88 | } 89 | return self.json_to_response(status=400, json_data=data, 90 | json_status=AjaxResponseStatus.ERROR) 91 | return response 92 | 93 | def get_success_url(self): 94 | """ """ 95 | if not self.request.is_ajax(): 96 | return super(FormAjaxMixin, self).get_success_url() 97 | return None 98 | 99 | def form_valid(self, form): 100 | """ If form valid return response with action """ 101 | response = super(FormAjaxMixin, self).form_valid(form) 102 | if self.request.is_ajax(): 103 | return self.json_to_response() 104 | return response 105 | 106 | def add_prefix(self, errors, prefix): 107 | """Add form prefix to errors""" 108 | if not prefix: 109 | prefix = self.get_prefix() 110 | if prefix: 111 | return {"%s-%s" % (prefix, k): v for k, v in errors.items()} 112 | return errors 113 | 114 | 115 | class PartialAjaxMixin(object): 116 | """ Mixin responsible to return the JSON with template rendered """ 117 | partial_title = None 118 | 119 | def get_partial_title(self): 120 | return self.partial_title 121 | 122 | def get_context_data(self, **kwargs): 123 | context = super(PartialAjaxMixin, self).get_context_data(**kwargs) 124 | partial_title = self.get_partial_title() 125 | if partial_title: 126 | context.update({ 127 | 'title': partial_title 128 | }) 129 | return context 130 | 131 | def render_to_response(self, context, **response_kwargs): 132 | """ Returns the rendered template in JSON format """ 133 | if self.request.is_ajax(): 134 | data = { 135 | "content": render_to_string( 136 | self.get_template_names(), context, request=self.request) 137 | } 138 | return JsonResponse(data) 139 | if settings.DEBUG: 140 | return super(PartialAjaxMixin, self).render_to_response( 141 | context, **response_kwargs) 142 | raise Http404() 143 | -------------------------------------------------------------------------------- /ajax_cbv/tests/test_mixins.py: -------------------------------------------------------------------------------- 1 | from __future__ import unicode_literals 2 | 3 | import json 4 | 5 | from ajax_cbv.mixins import ( 6 | AjaxResponseAction, AjaxResponseMixin, AjaxResponseStatus, FormAjaxMixin, 7 | PartialAjaxMixin) 8 | from django.forms import Form 9 | from django.http import Http404 10 | from django.test import SimpleTestCase 11 | from django.views.generic import TemplateView 12 | from django.views.generic.edit import FormView 13 | from mock import MagicMock, patch 14 | 15 | 16 | class DummyForm(Form): 17 | """ """ 18 | 19 | 20 | class AjaxResponseMixinTest(SimpleTestCase): 21 | 22 | class OkObject(AjaxResponseMixin): 23 | """ """ 24 | 25 | def setUp(self): 26 | self.object = self.OkObject() 27 | 28 | def test_response_no_action(self): 29 | response = self.object.json_to_response() 30 | content = json.loads(response.content.decode("utf-8")) 31 | self.assertEqual(response.status_code, 200) 32 | self.assertEqual(content['action'], "nothing") 33 | self.assertEqual(content['status'], AjaxResponseStatus.SUCCESS) 34 | 35 | def test_response_with_action_refresh(self): 36 | response = self.object.json_to_response( 37 | action=AjaxResponseAction.REFRESH) 38 | content = json.loads(response.content.decode("utf-8")) 39 | self.assertEqual(response.status_code, 200) 40 | self.assertEqual(content['action'], "refresh") 41 | self.assertEqual(content['status'], AjaxResponseStatus.SUCCESS) 42 | 43 | def test_response_with_action_redirect(self): 44 | response = self.object.json_to_response( 45 | action=AjaxResponseAction.REDIRECT, 46 | success_url="/unit/test") 47 | content = json.loads(response.content.decode("utf-8")) 48 | self.assertEqual(response.status_code, 200) 49 | self.assertEqual(content['action'], "redirect") 50 | self.assertEqual(content['status'], AjaxResponseStatus.SUCCESS) 51 | self.assertEqual(content['action_url'], "/unit/test") 52 | 53 | def test_get_action(self): 54 | self.assertEqual(self.object.get_action(), AjaxResponseAction.NOTHING) 55 | 56 | def test_get_action_invalid(self): 57 | self.assertRaises(ValueError, self.object.get_action, 'invalid') 58 | 59 | def test_get_status(self): 60 | self.assertEqual(self.object.get_status(), AjaxResponseStatus.SUCCESS) 61 | 62 | def test_get_status_invalid(self): 63 | self.assertRaises(ValueError, self.object.get_status, 'invalid') 64 | 65 | def test_get_json_data(self): 66 | self.assertEqual(self.object.get_json_data(), {}) 67 | 68 | 69 | class FormAjaxMixinTest(SimpleTestCase): 70 | 71 | class DummyFormView(FormAjaxMixin, FormView): 72 | """ """ 73 | template_name = 'unit.html' 74 | prefix = 'unit' 75 | success_url = "/example/" 76 | 77 | def setUp(self): 78 | self.view = self.DummyFormView() 79 | self.view.request = MagicMock() 80 | self.form = MagicMock(spec=Form, errors={}) 81 | 82 | def test_form_invalid_no_ajax(self): 83 | self.view.request.is_ajax.return_value = False 84 | response = self.view.form_invalid(self.form) 85 | self.assertEqual(response.status_code, 200) 86 | 87 | def test_form_invalid_as_ajax(self): 88 | self.view.request.is_ajax.return_value = True 89 | response = self.view.form_invalid(self.form) 90 | content = json.loads(response.content.decode("utf-8")) 91 | self.assertEqual(response.status_code, 400) 92 | self.assertEqual(content['action'], "nothing") 93 | self.assertEqual(content['status'], AjaxResponseStatus.ERROR) 94 | 95 | def test_form_valid_no_ajax(self): 96 | self.view.request.is_ajax.return_value = False 97 | response = self.view.form_valid(self.form) 98 | self.assertEqual(response.status_code, 302) 99 | 100 | def test_form_valid_as_ajax(self): 101 | self.view.request.is_ajax.return_value = True 102 | response = self.view.form_valid(self.form) 103 | content = json.loads(response.content.decode("utf-8")) 104 | self.assertEqual(response.status_code, 200) 105 | self.assertEqual(content['action'], "nothing") 106 | self.assertEqual(content['status'], AjaxResponseStatus.SUCCESS) 107 | 108 | def test_add_prefix_no_prefix(self): 109 | result = self.view.add_prefix({}, None) 110 | self.assertEqual(result, {}) 111 | 112 | def test_add_prefix_with_prefix_view(self): 113 | self.view.prefix = None 114 | result = self.view.add_prefix({'field_1': 'invalid'}, None) 115 | self.assertEqual(result['field_1'], "invalid") 116 | 117 | def test_add_prefix_with_prefix(self): 118 | result = self.view.add_prefix({'field_1': 'invalid'}, 'test') 119 | self.assertEqual(result['test-field_1'], "invalid") 120 | 121 | def test_get_success_url(self): 122 | self.view.request.is_ajax.return_value = False 123 | self.assertEqual(self.view.get_success_url(), '/example/') 124 | 125 | def test_get_success_url_with_ajax(self): 126 | self.view.request.is_ajax.return_value = True 127 | self.assertIsNone(self.view.get_success_url()) 128 | 129 | 130 | class PartialAjaxMixinTest(SimpleTestCase): 131 | """ """ 132 | 133 | class DummyView(PartialAjaxMixin, TemplateView): 134 | """ """ 135 | 136 | def get_template_names(self): 137 | return "example.html" 138 | 139 | def setUp(self): 140 | self.view = self.DummyView() 141 | self.view.request = MagicMock() 142 | 143 | def test_get_partial_title(self): 144 | self.view.partial_title = 'Unit Test' 145 | result = self.view.get_partial_title() 146 | self.assertEqual(result, 'Unit Test') 147 | 148 | def test_get_context_data(self): 149 | self.view.partial_title = 'Unit' 150 | result = self.view.get_context_data() 151 | self.assertEqual(result['title'], 'Unit') 152 | 153 | def test_get_context_data_without_partial_title(self): 154 | self.view.partial_title = None 155 | context = self.view.get_context_data() 156 | self.assertFalse('title' in context) 157 | 158 | @patch('ajax_cbv.mixins.render_to_string', 159 | return_value="") 160 | def test_render_to_response(self, render_to_string): 161 | result = self.view.render_to_response({}) 162 | content = json.loads(result.content.decode("utf-8")) 163 | self.assertEqual(content['content'], "") 164 | render_to_string.assert_called_with( 165 | "example.html", {}, request=self.view.request) 166 | 167 | def test_render_to_response_without_ajax(self): 168 | self.view.request.is_ajax.return_value = False 169 | with self.assertRaises(Http404): 170 | self.view.render_to_response({}) 171 | 172 | def test_render_to_response_without_ajax_debug(self): 173 | self.view.request.is_ajax.return_value = False 174 | with self.settings(DEBUG=True): 175 | result = self.view.render_to_response({}) 176 | self.assertEqual(result.status_code, 200) 177 | -------------------------------------------------------------------------------- /ajax_cbv/static/ajax_cbv/forms.jquery.js: -------------------------------------------------------------------------------- 1 | /* globals jQuery */ 2 | 3 | /****************************************************************** 4 | * Author: Dipcode 5 | * 6 | * Django forms ajax submission 7 | * Example: 8 | *
9 | *****************************************************************/ 10 | 11 | 12 | 13 | (function($) { 14 | "use strict"; 15 | 16 | function CacheFile(name, filename, file) { 17 | this.name = name; 18 | this.filename = filename; 19 | this.file = new Blob([file]); 20 | } 21 | 22 | function ManageCacheFile($form) { 23 | this.$form = $form; 24 | this.$inputFiles = this.$form.find("input[type='file']"); 25 | this.cachedFiles = {}; 26 | this.bindEvents(); 27 | } 28 | 29 | ManageCacheFile.prototype = { 30 | 31 | getFormData: function getFormData() 32 | { 33 | this.$inputFiles.prop('disabled', true); 34 | 35 | var data = new FormData(this.$form.get(0)); 36 | 37 | for (var i in this.cachedFiles) { 38 | var cachedFile = this.cachedFiles[i]; 39 | data.append(cachedFile.name, cachedFile.file, cachedFile.filename); 40 | } 41 | 42 | this.$inputFiles.prop('disabled', false); 43 | 44 | return data; 45 | }, 46 | 47 | bindEvents: function bindEvents() 48 | { 49 | var self = this; 50 | 51 | this.$form.find("input[type='file']").on('change', function(e) 52 | { 53 | var name = $(this).attr('name'), 54 | file = e.target.files[0], 55 | reader = new FileReader(); 56 | 57 | if (file !== undefined) { 58 | reader.onload = function(evt) { 59 | self.cachedFiles[name] = new CacheFile(name, file.name, evt.target.result); 60 | }; 61 | 62 | reader.readAsBinaryString(file); 63 | } 64 | else if (name in self.cachedFiles) { 65 | delete self.cachedFiles[name]; 66 | } 67 | }); 68 | } 69 | }; 70 | 71 | var methods = { 72 | submit: function (data) { 73 | var daf = this.data('daf-data'); 74 | if (daf) { 75 | daf.submit(data); 76 | } 77 | } 78 | }; 79 | 80 | $.fn.djangoAjaxForms = function(options) 81 | { 82 | var opts = $.extend({ 83 | fieldIdSelector: "div_id_", 84 | fieldErrorClass: "form-control-feedback", 85 | errorClass: "has-danger", 86 | cacheFilesAttr: "[data-ajax-submit-cachefiles]", 87 | canSubmitFn: null, 88 | onRenderErrorFn: null, 89 | handleSubmitEvent: true, 90 | }, $.fn.djangoAjaxForms.defaults, options); 91 | 92 | function DjangoAjaxForms($form) 93 | { 94 | this.$form = $form; 95 | this.$form.data('daf-data', this); 96 | 97 | if (opts.handleSubmitEvent) { 98 | var self = this, 99 | canSubmit = true; 100 | 101 | this.$form.on('submit', function(e) { 102 | e.preventDefault(); 103 | 104 | if ( $.isFunction( opts.canSubmitFn ) ) { 105 | canSubmit = opts.canSubmitFn(self.$form); 106 | } 107 | 108 | if (self.$form.length > 0 && canSubmit) { 109 | self.submit(); 110 | } 111 | }); 112 | } 113 | 114 | if (this.$form.filter(opts.cacheFilesAttr).length) { 115 | this.cachedFiles = new ManageCacheFile(this.$form); 116 | } 117 | } 118 | 119 | DjangoAjaxForms.prototype = { 120 | 121 | request: function request(url, data, isCustomData) 122 | { 123 | var options = { 124 | data: data, 125 | method: 'POST', 126 | dataType: 'json' 127 | }; 128 | 129 | if ( !isCustomData ) { 130 | options.contentType = false; 131 | options.processData = false; 132 | } 133 | 134 | return $.ajax(url, options); 135 | }, 136 | 137 | submit: function submit(customData) 138 | { 139 | var self = this; 140 | 141 | this.$form.trigger("ajaxforms:beforesubmit"); 142 | 143 | var url = this.$form.attr("action") || window.location.href; 144 | var disabled_fields = this.$form.find(":input:disabled"); 145 | var data = customData; 146 | var isCustomData = true; 147 | 148 | if (customData === undefined) { 149 | data = new FormData(this.$form.get(0)); 150 | isCustomData = false; 151 | } 152 | 153 | if (this.$form.filter(opts.cacheFilesAttr).length) { 154 | data = this.cachedFiles.getFormData(); 155 | } 156 | 157 | this.$form.find(':input').prop('disabled', true); 158 | this.$form.trigger("ajaxforms:submit"); 159 | 160 | return this.request(url, data, isCustomData) 161 | 162 | .done(function(response) { 163 | self.$form.trigger("ajaxforms:submitsuccess"); 164 | self.$form.trigger('form:submit:success'); 165 | 166 | if( response.action ){ 167 | self.processResponse(response.action, response.action_url); 168 | } 169 | }) 170 | 171 | .fail(function ($xhr) { 172 | var response = $xhr.responseJSON; 173 | 174 | if (response && response.hasOwnProperty('extra_data')) { 175 | var errors_list = response.extra_data.errors_list; 176 | 177 | self.processFormErrors(self.$form, errors_list); 178 | } else { 179 | self.$form.trigger("ajaxforms:fail"); 180 | } 181 | 182 | self.$form.find(':input').not(disabled_fields).prop('disabled', false); 183 | }) 184 | 185 | .always(function() { 186 | self.$form.trigger("ajaxforms:submitdone"); 187 | }); 188 | }, 189 | 190 | processFormErrors: function processFormErrors($form, errors_list) 191 | { 192 | var $wrappers = $form.find("[id^='" + opts.fieldIdSelector + "']"); 193 | var nonfielderror = false; 194 | 195 | $wrappers.removeClass(opts.errorClass).find("." + opts.fieldErrorClass).remove(); 196 | 197 | for (var fieldName in errors_list) { 198 | var errors = errors_list[fieldName]; 199 | 200 | if (fieldName.search("__all__") >= 0) { 201 | $form.trigger("ajaxforms:nonfielderror", [errors]); 202 | nonfielderror = true; 203 | } else { 204 | var $field = $form.find("#" + opts.fieldIdSelector + fieldName); 205 | 206 | var onChange = function () { 207 | $field.removeClass('error', 200).find('.errorlist').fadeOut(200, function () { 208 | $(this).remove(); 209 | }); 210 | }; 211 | 212 | $field.addClass(opts.errorClass).append(this.renderErrorList(errors)); 213 | $field.one('change', onChange); 214 | } 215 | } 216 | if ( !nonfielderror ){ 217 | $form.trigger("ajaxforms:fielderror"); 218 | } 219 | }, 220 | 221 | processResponse: function processResponse(action, value) 222 | { 223 | switch (action) { 224 | case 'refresh': 225 | window.location.reload(true); 226 | break; 227 | case 'redirect': 228 | window.location.href = value; 229 | break; 230 | default: 231 | return; 232 | } 233 | }, 234 | 235 | renderErrorList: function renderErrorList(errorsList) 236 | { 237 | var $elem = $("
").addClass(opts.fieldErrorClass).text(errorsList.join(', ')); 238 | 239 | if ( $.isFunction( opts.onRenderErrorFn ) ) { 240 | $elem = opts.onRenderErrorFn( $elem, errorsList ); 241 | } 242 | 243 | return $elem; 244 | } 245 | }; 246 | 247 | if ( methods[options] ) { 248 | return methods[ options ].apply( this, Array.prototype.slice.call( arguments, 1 )); 249 | } else if ( typeof options === 'object' || ! options ) { 250 | return this.each(function() 251 | { 252 | var $this = $(this); 253 | var daf = $this.data('daf-data'); 254 | 255 | if (!daf) { 256 | new DjangoAjaxForms($this); 257 | } 258 | }); 259 | } else { 260 | $.error( 'Method ' + options + ' does not exist.' ); 261 | } 262 | }; 263 | 264 | $.fn.djangoAjaxForms.defaults = {}; 265 | 266 | })(jQuery); 267 | --------------------------------------------------------------------------------