├── .coveragerc ├── .gitignore ├── CHANGELOG ├── LICENSE ├── MANIFEST.in ├── Makefile ├── README.md ├── runtests.py ├── setup.py ├── test_urls.py ├── tests ├── __init__.py ├── settings.py ├── test_forms.py ├── test_middleware.py ├── test_unpoly_helper.py └── test_views.py ├── unpoly ├── __init__.py ├── forms.py ├── middleware.py ├── typehints.py ├── unpoly.py └── views.py └── wordlist.dic /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = unpoly_django 3 | omit = 4 | tests/* 5 | branch = True 6 | data_file = .coverage 7 | timid = False 8 | 9 | [report] 10 | exclude_lines = 11 | pragma: no cover 12 | precision = 0 13 | show_missing = True 14 | omit = 15 | unpoly_django/tests/* 16 | 17 | [xml] 18 | output = coverage.xml 19 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | *.orig 3 | *.swp 4 | *.pyc 5 | .mypy_cache 6 | secret.txt 7 | static/* 8 | .idea/* 9 | -------------------------------------------------------------------------------- /CHANGELOG: -------------------------------------------------------------------------------- 1 | Change history for unpoly_django 2 | ------------------------------------------------------------- 3 | 0.1.0 4 | ^^^^^^ 5 | * Initial release. 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | Copyright (c) 2021, Dave Burkholder 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | 6 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | 8 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 9 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include README.md 3 | include CHANGELOG 4 | global-include *.md *.py *.html 5 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | help: 2 | @echo "clean - get rid of build artifacts & metadata" 3 | @echo "test - execute tests" 4 | @echo "dist - build a distribution; calls test, clean-build and clean-pyc" 5 | @echo "check - check the quality of the built distribution; calls dist for you" 6 | @echo "release - register and upload to PyPI" 7 | 8 | clean: clean-pyc 9 | rm -fr build/ 10 | rm -fr dist/ 11 | rm -fr .eggs/ 12 | find . -name '*.egg-info' -exec rm -fr {} + 13 | find . -name '*.egg' -exec rm -f {} + 14 | 15 | clean-pyc: 16 | find . -name '*.pyc' -exec rm -f {} + 17 | find . -name '*.pyo' -exec rm -f {} + 18 | find . -name '*~' -exec rm -f {} + 19 | find . -name '__pycache__' -exec rm -fr {} + 20 | 21 | test: clean-pyc 22 | python3.9 runtests.py 23 | 24 | dist: test clean clean-pyc 25 | python3.9 setup.py sdist bdist_wheel 26 | 27 | release: 28 | @echo "INSTRUCTIONS:" 29 | @echo "- pip install wheel twine" 30 | @echo "- python3.9 setup.py sdist bdist_wheel" 31 | @echo "- ls dist/" 32 | @echo "- twine register dist/???" 33 | @echo "- twine upload dist/*" 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | django-unpoly 2 | ============= 3 | 4 | What it does 5 | ------------ 6 | 7 | ``unpoly`` is a reusable app for [Django](https://www.djangoproject.com/) implementing a Django-flavored 8 | [Unpoly v2 Server Protocol](https://unpoly.com/up.protocol). 9 | 10 | It provides: 11 | 12 | 1. A middleware which adds `is_unpoly` `unpoly_target`, and `unpoly_validate` methods to the request object. 13 | 14 | 2. A view mixin classes support both [Django Generic Views](https://docs.djangoproject.com/en/dev/topics/class-based-views/generic-display/) and [Vanilla Views](http://django-vanilla-views.org/). 15 | 16 | 3. A Form mixin class for [Crispy Forms](https://django-crispy-forms.readthedocs.io). 17 | 18 | 19 | Installation 20 | ------------ 21 | 22 | pip install unpoly_django 23 | 24 | Configuration 25 | ------------- 26 | You need to add ``unpoly.middleware.UnpolyMiddleware`` to your ``MIDDLEWARE``. 27 | 28 | To install all the middleware, you want something like: 29 | 30 | 31 | ```python 32 | MIDDLEWARE = ( 33 | # ... 34 | 'unpoly.middleware.UnpolyMiddleware', 35 | # ... 36 | ) 37 | ``` 38 | 39 | Settings 40 | -------- 41 | 42 | If using the View mixins following constants to settings.py: 43 | 44 | ```python 45 | MAIN_UP_TARGET = '#your_main_up_target' 46 | MAIN_UP_FAIL_TARGET = '#your_main_upfail_target' 47 | 48 | DEFAULT_UP_ERROR_TEMPLATE = 'your-unpoly-error-template.html' 49 | 50 | # Default Templates for various Unpoly layers. 51 | # Override on individual View attributes. 52 | UNPOLY_MODAL_TEMPLATE = 'your-unpoly-modal-template.html' 53 | UNPOLY_DRAWER_TEMPLATE = 'your-unpoly-drawer-template.html' 54 | UNPOLY_POPUP_TEMPLATE = 'your-unpoly-popup-template.html' 55 | UNPOLY_COVER_TEMPLATE = 'your-unpoly-cover-template.html' 56 | 57 | # Routing URL kwarg set when an optimized response is to be sent 58 | OPTIMIZED_SUCCESS_RESPONSE = 'optimized_success_response' 59 | 60 | DEFAULT_ERROR_VIEW = 'appname:error_view_name' 61 | ``` 62 | 63 | CSRF Token 64 | ---------- 65 | 66 | Ensure that the `csrf_token` meta tag is included in the head section of your templates 67 | 68 | ```html 69 | 70 | ``` 71 | 72 | View Mixins 73 | ----------- 74 | 75 | View mixin classes add hooks and properties for sending optimized server 76 | responses to the frontend. 77 | 78 | Add view mixin classes to Django's Generic views, or Vanilla Views: 79 | 80 | ```python 81 | from django.views.generic import FormView, TemplateView 82 | from unpoly.views import UnpolyViewMixin, UnpolyFormViewMixin, UnpolyCrispyFormViewMixin 83 | 84 | 85 | class YourTemplateView(UnpolyViewMixin, TemplateView): 86 | pass 87 | 88 | class YourFormView(UnpolyViewMixin, FormView): 89 | pass 90 | 91 | class YourCrispyFormView(UnpolyCrispyFormViewMixin, FormView): 92 | pass 93 | ``` 94 | 95 | Crispy Form Mixin 96 | ----------------- 97 | 98 | The Crispy Form mixin can be used when form mixins are used, which enables passing 99 | `up-target`, `up-layer`, `up-fail-layer` and `up-fail-target` params from views to forms: 100 | 101 | ```python 102 | from django.forms import ModelForm 103 | from unpoly.forms import UnpolyCrispyFormMixin 104 | 105 | class UnpolyCrispyForm(UnpolyCrispyFormMixin, ModelForm): 106 | pass 107 | ``` 108 | 109 | 110 | Running the tests 111 | ----------------- 112 | 113 | If you have a cloned copy, run:: 114 | 115 | python3 runtests.py 116 | 117 | Contributing 118 | ------------ 119 | 120 | Contributions welcome! The project lives in the Github repo at [thinkwelltwd/unpoly_django](https://github.com/thinkwelltwd/unpoly_django/) 121 | repository. 122 | 123 | Bug reports and feature requests can be filed on the repository's [issue tracker](https://github.com/thinkwelltwd/unpoly_django/issues/). 124 | 125 | 126 | License 127 | ------- 128 | 129 | Released under the [MIT License](https://mit-license.org/). There's should be a ``LICENSE`` file in the root of the repository. 130 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import os 3 | import sys 4 | 5 | import django 6 | from django.conf import settings 7 | from django.test.utils import get_runner 8 | 9 | 10 | if __name__ == "__main__": 11 | os.environ['DJANGO_SETTINGS_MODULE'] = 'tests.settings' 12 | django.setup() 13 | TestRunner = get_runner(settings) 14 | test_runner = TestRunner() 15 | 16 | try: 17 | tests_to_run = sys.argv[1] 18 | except IndexError: 19 | tests_to_run = 'tests' 20 | 21 | failures = test_runner.run_tests([tests_to_run]) 22 | sys.exit(bool(failures)) 23 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | from setuptools import setup 5 | from unpoly import get_version 6 | 7 | HERE = os.path.abspath(os.path.dirname(__file__)) 8 | 9 | 10 | def make_readme(root_path): 11 | consider_files = ("README.md", "LICENSE", "CHANGELOG") 12 | for filename in consider_files: 13 | filepath = os.path.realpath(os.path.join(root_path, filename)) 14 | if os.path.isfile(filepath): 15 | with open(filepath, mode="r") as f: 16 | yield f.read() 17 | 18 | 19 | LICENSE = "MIT License" 20 | URL = "https://github.com/thinkwelltwd/unpoly_django" 21 | LONG_DESCRIPTION = "\r\n\r\n----\r\n\r\n".join(make_readme(HERE)) 22 | SHORT_DESCRIPTION = "An app for Django to implement the Unpoly v2 Server Protocol" 23 | KEYWORDS = ( 24 | "django", 25 | "unpoly", 26 | ) 27 | 28 | setup( 29 | name="unpoly_django", 30 | version=get_version(), 31 | author="Dave Burkholder", 32 | author_email="dave@thinkwelldesigns.com", 33 | description=SHORT_DESCRIPTION[0:200], 34 | long_description=LONG_DESCRIPTION, 35 | long_description_content_type='text/markdown', 36 | packages=[ 37 | "unpoly", 38 | ], 39 | include_package_data=True, 40 | install_requires=[ 41 | "Django>=3.1", 42 | ], 43 | tests_require=[ 44 | "django-crispy-forms", 45 | ], 46 | zip_safe=False, 47 | keywords=" ".join(KEYWORDS), 48 | license=LICENSE, 49 | url=URL, 50 | classifiers=[ 51 | "Development Status :: 3 - Alpha", 52 | "Intended Audience :: Developers", 53 | "License :: OSI Approved :: {}".format(LICENSE), 54 | "Natural Language :: English", 55 | "Programming Language :: Python :: 3.8", 56 | "Programming Language :: Python :: 3.9", 57 | "Framework :: Django", 58 | "Framework :: Django :: 3.1", 59 | ], 60 | ) 61 | -------------------------------------------------------------------------------- /test_urls.py: -------------------------------------------------------------------------------- 1 | from django.urls import reverse 2 | 3 | urlpatterns = [ 4 | ] 5 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/thinkwelltwd/unpoly_django/61a148e1c4019841eb220a99c61b17fd2044dcb6/tests/__init__.py -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | MAIN_UP_LAYER = 'root' 4 | MAIN_UP_FAIL_LAYER = 'current' 5 | MAIN_UP_TARGET = 'body' 6 | MAIN_UP_TARGET_FORM_VIEW = '#main_up_target' 7 | MAIN_UP_FAIL_TARGET = '#main_fail_target' 8 | 9 | DEFAULT_UP_ERROR_TEMPLATE = 'unpoly_modal_error.html' 10 | UNPOLY_MODAL_TEMPLATE = 'unpoly_modal_form.html' 11 | UNPOLY_DRAWER_TEMPLATE = '' 12 | UNPOLY_POPUP_TEMPLATE = '' 13 | UNPOLY_COVER_TEMPLATE = '' 14 | 15 | DEBUG = os.environ.get('DEBUG', 'on') == 'on' 16 | SECRET_KEY = os.environ.get('SECRET_KEY', 'TESTTESTTESTTESTTESTTESTTESTTEST') 17 | ALLOWED_HOSTS = os.environ.get('ALLOWED_HOSTS', 'localhost,testserver,*').split(',') 18 | BASE_DIR = os.path.abspath(os.path.dirname(os.path.abspath(__file__))) 19 | 20 | DATABASES = { 21 | 'default': { 22 | 'ENGINE': 'django.db.backends.sqlite3', 23 | 'NAME': os.path.join(BASE_DIR, 'db.sqlite3'), 24 | } 25 | } 26 | 27 | INSTALLED_APPS = [ 28 | 'django.contrib.sessions', 29 | 'django.contrib.contenttypes', 30 | 'django.contrib.admin', 31 | 'django.contrib.staticfiles', 32 | 'django.contrib.auth', 33 | 'django.contrib.messages', 34 | 'unpoly', 35 | ] 36 | 37 | STATIC_URL = '/__static__/' 38 | MEDIA_URL = '/__media__/' 39 | MESSAGE_STORAGE = 'django.contrib.messages.storage.cookie.CookieStorage' 40 | SESSION_ENGINE = 'django.contrib.sessions.backends.signed_cookies' 41 | SESSION_COOKIE_HTTPONLY = True 42 | 43 | ROOT_URLCONF = 'test_urls' 44 | 45 | # Use a fast hasher to speed up tests. 46 | PASSWORD_HASHERS = ( 47 | 'django.contrib.auth.hashers.MD5PasswordHasher', 48 | ) 49 | 50 | SITE_ID = 1 51 | 52 | TEMPLATES = [ 53 | { 54 | 'BACKEND': 'django.template.backends.django.DjangoTemplates', 55 | 'DIRS': [os.path.join(BASE_DIR, "test_templates")], 56 | 'OPTIONS': { 57 | 'context_processors': [ 58 | "django.template.context_processors.i18n", 59 | "django.contrib.auth.context_processors.auth", 60 | "django.template.context_processors.debug", 61 | "django.template.context_processors.media", 62 | "django.template.context_processors.static", 63 | "django.template.context_processors.tz", 64 | "django.contrib.messages.context_processors.messages", 65 | "django.template.context_processors.request", 66 | ], 67 | 'loaders': [ 68 | ( 69 | 'django.template.loaders.cached.Loader', ( 70 | 'django.template.loaders.filesystem.Loader', 71 | 'django.template.loaders.app_directories.Loader', 72 | ) 73 | ), 74 | ], 75 | }, 76 | }, 77 | ] 78 | 79 | 80 | MIDDLEWARE = [ 81 | "django.contrib.sessions.middleware.SessionMiddleware", 82 | "django.contrib.auth.middleware.AuthenticationMiddleware", 83 | "django.middleware.common.CommonMiddleware", 84 | "django.middleware.csrf.CsrfViewMiddleware", 85 | "django.contrib.messages.middleware.MessageMiddleware", 86 | "unpoly.middleware.UnpolyMiddleware", 87 | ] 88 | 89 | 90 | STATIC_ROOT = os.path.join(BASE_DIR, 'test_collectstatic') 91 | MEDIA_ROOT = os.path.join(BASE_DIR, 'test_media') 92 | 93 | USE_TZ = True 94 | 95 | SILENCED_SYSTEM_CHECKS = ['1_8.W001'] 96 | 97 | LOGGING = { 98 | 'version': 1, 99 | 'disable_existing_loggers': False, 100 | 'filters': { 101 | 'require_debug_false': { 102 | '()': 'django.utils.log.RequireDebugFalse', 103 | } 104 | }, 105 | 'formatters': { 106 | 'verbose': { 107 | 'format': "[%(asctime)s] %(levelname)s [%(name)s:%(lineno)s] %(message)s", 108 | 'datefmt': "%Y-%m-%d %H:%M:%S", 109 | }, 110 | 'console': { 111 | 'format': "[%(asctime)s] %(levelname)s [%(name)s:%(lineno)s] %(message)s", 112 | 'datefmt': "%Y-%m-%d %H:%M:%S", 113 | }, 114 | 'simple': { 115 | 'format': '%(levelname)s %(message)s' 116 | }, 117 | }, 118 | 'handlers': { 119 | 'console': { 120 | 'class': 'logging.StreamHandler', 121 | }, 122 | }, 123 | 'loggers': { 124 | 'django': { 125 | 'handlers': ['console'], 126 | 'propagate': True, 127 | 'level': 'INFO', 128 | }, 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /tests/test_forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from django.test import SimpleTestCase 3 | 4 | from unpoly.forms import UnpolyCrispyFormMixin 5 | from .settings import MAIN_UP_TARGET, MAIN_UP_FAIL_TARGET 6 | 7 | 8 | class UnpolyForm(UnpolyCrispyFormMixin, forms.Form): 9 | name = forms.CharField() 10 | 11 | 12 | class UnpolyCrispyFormMixinTest(SimpleTestCase): 13 | 14 | def test_unpoly_form_attrs(self): 15 | 16 | form = UnpolyForm( 17 | form_action='/up', 18 | multi_layer=False, 19 | up_target=MAIN_UP_TARGET, 20 | up_fail_target=MAIN_UP_FAIL_TARGET, 21 | up_validate='.name', 22 | ) 23 | 24 | self.assertEqual(form.helper.form_action, '/up') 25 | self.assertEqual(form.helper.attrs['up-target'], MAIN_UP_TARGET) 26 | self.assertEqual(form.helper.attrs['up-fail-target'], MAIN_UP_FAIL_TARGET) 27 | 28 | def test_unpoly_multi_layerform_attrs(self): 29 | """ 30 | up-layer should be changed when multiple layers are opened 31 | """ 32 | form = UnpolyForm( 33 | form_action='/up', 34 | multi_layer=True, 35 | up_target=MAIN_UP_TARGET, 36 | up_fail_target=MAIN_UP_FAIL_TARGET, 37 | up_validate='.name', 38 | ) 39 | 40 | self.assertEqual(form.helper.attrs['up-layer'], 'current') 41 | -------------------------------------------------------------------------------- /tests/test_middleware.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpResponse 2 | from django.test import RequestFactory, SimpleTestCase 3 | 4 | from unpoly.middleware import UnpolyMiddleware 5 | 6 | 7 | def get_response(req): 8 | """Callable to pass in to new-style Middleware init method""" 9 | response = HttpResponse({}) 10 | return response 11 | 12 | 13 | class UnpolyMiddlewareTestCase(SimpleTestCase): 14 | 15 | def setUp(self): 16 | self.factory = RequestFactory() 17 | 18 | def test_no_headers_not_unpoly(self): 19 | middleware = UnpolyMiddleware(get_response) 20 | request = self.factory.get('/') 21 | response = middleware(request) 22 | self.assertFalse(request.is_unpoly()) 23 | 24 | def test_target_header_is_unpoly(self): 25 | middleware = UnpolyMiddleware(get_response) 26 | request = self.factory.get('/', HTTP_X_UP_TARGET='.breadcrumb') 27 | response = middleware(request) 28 | self.assertTrue(request.is_unpoly()) 29 | 30 | def test_validate_header_is_unpoly(self): 31 | middleware = UnpolyMiddleware(get_response) 32 | request = self.factory.get('/', HTTP_X_UP_VALIDATE='.breadcrumb') 33 | response = middleware(request) 34 | self.assertTrue(request.is_unpoly()) 35 | 36 | def test_check_response_headers(self): 37 | """ 38 | Response should set X-Up-Method headers 39 | """ 40 | middleware = UnpolyMiddleware(get_response) 41 | request = self.factory.get('/') 42 | response = middleware(request) 43 | headers = response.headers 44 | 45 | self.assertEqual(headers.get('X-Up-Method'), 'GET') 46 | -------------------------------------------------------------------------------- /tests/test_unpoly_helper.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from django.http import HttpResponse 4 | from django.test import SimpleTestCase 5 | 6 | from unpoly.unpoly import Unpoly 7 | from .settings import MAIN_UP_TARGET, MAIN_UP_FAIL_TARGET 8 | 9 | 10 | request_meta = { 11 | 'HTTP_X_UP_VERSION': '2.0', 12 | 'HTTP_X_UP_MODE': 'modal', 13 | 'HTTP_X_UP_TARGET': MAIN_UP_TARGET, 14 | 'HTTP_X_UP_FAIL_TARGET': MAIN_UP_FAIL_TARGET, 15 | } 16 | query_params = { 17 | 'multi_layer': True, 18 | } 19 | 20 | 21 | class UnpolyHelperTest(SimpleTestCase): 22 | 23 | def test_unpoly_request_attrs(self): 24 | 25 | up = Unpoly(meta=request_meta) 26 | 27 | self.assertTrue(up.is_unpoly()) 28 | self.assertFalse(up.is_validating()) 29 | self.assertFalse(up.multi_layer()) 30 | self.assertEqual(up.mode(), request_meta['HTTP_X_UP_MODE']) 31 | self.assertEqual(up.target(), request_meta['HTTP_X_UP_TARGET']) 32 | self.assertEqual(up.fail_target(), request_meta['HTTP_X_UP_FAIL_TARGET']) 33 | self.assertEqual(up.fail_target(), request_meta['HTTP_X_UP_FAIL_TARGET']) 34 | 35 | meta = request_meta.copy() 36 | meta['HTTP_X_UP_VALIDATE'] = '.company_id' 37 | up = Unpoly(meta=meta, query_params=query_params) 38 | 39 | self.assertTrue(up.multi_layer()) 40 | self.assertTrue(up.is_validating()) 41 | self.assertEqual(up.validate(), meta['HTTP_X_UP_VALIDATE']) 42 | 43 | def test_unpoly_response_accept_layer(self): 44 | 45 | up = Unpoly(meta=request_meta) 46 | response = HttpResponse(200) 47 | 48 | data = {'id': 1, 'name': 'boxcar'} 49 | up.accept_layer(response, data) 50 | self.assertTrue(response.has_header('X-Up-Accept-Layer')) 51 | self.assertEqual(response['X-Up-Accept-Layer'], json.dumps(data)) 52 | 53 | def test_unpoly_response_emit(self): 54 | up = Unpoly(meta=request_meta) 55 | response = HttpResponse(200) 56 | 57 | data = {'id': 1, 'name': 'boxcar', 'action': 'unhitch'} 58 | up.emit(response, 'boxcar:unhitched', data) 59 | self.assertTrue(response.has_header('X-Up-Events')) 60 | self.assertEqual(response['X-Up-Events'], json.dumps([data])) 61 | 62 | def test_unpoly_response_emit_layer(self): 63 | up = Unpoly(meta=request_meta) 64 | 65 | # Test with no layer specified 66 | response = HttpResponse(200) 67 | data = {'id': 1, 'name': 'boxcar', 'action': 'unhitch'} 68 | up.layer_emit(response, 'boxcar:unhitched', data) 69 | self.assertTrue(response.has_header('X-Up-Events')) 70 | 71 | data['layer'] = 'current' 72 | self.assertEqual(response['X-Up-Events'], json.dumps([data])) 73 | 74 | # Test with specific layer specified 75 | response = HttpResponse(200) 76 | data = {'id': 1, 'name': 'boxcar', 'action': 'unhitch', 'layer': 'overlay'} 77 | up.layer_emit(response, 'boxcar:unhitched', data) 78 | self.assertTrue(response.has_header('X-Up-Events')) 79 | self.assertEqual(response['X-Up-Events'], json.dumps([data])) 80 | 81 | event_data = json.loads(response['X-Up-Events'])[0] 82 | self.assertIn('type', event_data) 83 | self.assertEqual(event_data['type'], 'boxcar:unhitched') 84 | -------------------------------------------------------------------------------- /tests/test_views.py: -------------------------------------------------------------------------------- 1 | from vanilla import CreateView as VanillaCreateView 2 | 3 | from django.conf import settings 4 | from django.db import models 5 | from django import forms 6 | from django.test import RequestFactory, SimpleTestCase 7 | from django.views.generic import TemplateView, CreateView as DjangoCreateView 8 | 9 | from unpoly.forms import UnpolyCrispyFormMixin 10 | from unpoly.unpoly import Unpoly 11 | from unpoly.views import UnpolyFormViewMixin, UnpolyCrispyFormViewMixin, UnpolyViewMixin 12 | 13 | 14 | def get_view(view, url='/up', **headers): 15 | request = RequestFactory().get(url, **headers) 16 | view = view() 17 | view.setup(request) 18 | return view 19 | 20 | 21 | class Note(models.Model): 22 | name = models.TextField() 23 | 24 | class Meta: 25 | app_label = 'app_label' 26 | ordering = ['-level', 'name'] 27 | 28 | 29 | class NoteForm(UnpolyCrispyFormMixin, forms.ModelForm): 30 | 31 | class Meta: 32 | model = Note 33 | fields = [ 34 | 'name' 35 | ] 36 | 37 | 38 | class UnpolyViewMixinTest(SimpleTestCase): 39 | 40 | def test_unpoly_helper(self): 41 | 42 | class UnpolyView(UnpolyViewMixin, TemplateView): 43 | template_name = 'any_template.html' 44 | 45 | view = get_view(UnpolyView) 46 | self.assertIsInstance(view.up, Unpoly) 47 | 48 | 49 | class UnpolyFormViewMixinTest(SimpleTestCase): 50 | 51 | def test_unpoly_target(self): 52 | 53 | class UnpolyView(UnpolyFormViewMixin, TemplateView): 54 | template_name = 'any_template.html' 55 | 56 | view = get_view(UnpolyView) 57 | self.assertEqual(view.get_unpoly_target(), settings.MAIN_UP_TARGET) 58 | context = view.get_context_data(**{}) 59 | self.assertEqual(context['up_target'], settings.MAIN_UP_TARGET) 60 | 61 | not_main_target = 'body' 62 | 63 | class UnpolyView(UnpolyFormViewMixin, TemplateView): 64 | template_name = 'any_template.html' 65 | _unpoly_main_target = not_main_target 66 | 67 | view = get_view(UnpolyView) 68 | self.assertEqual(view.get_unpoly_target(), not_main_target) 69 | context = view.get_context_data(**{}) 70 | self.assertEqual(context['up_target'], not_main_target) 71 | 72 | 73 | class UnpolyCrispyFormVanillaViewMixinTest(SimpleTestCase): 74 | 75 | def test_standard_view_form_attrs(self): 76 | """ 77 | Views should load unpoly attrs only when request was performed by Unpoly. 78 | """ 79 | class UnpolyView(UnpolyCrispyFormViewMixin, VanillaCreateView): 80 | template_name = 'any_template.html' 81 | 82 | form_kwargs = { 83 | 'form_action': '/up', 84 | 'multi_layer': None, 85 | 'up_fail_layer': 'current', 86 | 'up_fail_target': '#main_fail_target', 87 | 'up_layer': 'root', 88 | 'up_target': '#main_up_target', 89 | } 90 | 91 | view = get_view(UnpolyView, HTTP_X_UP_VERSION='2.5.1') 92 | self.assertEqual(view.get_form_kwargs(), form_kwargs) 93 | 94 | def test_unpoly_view_form_attrs(self): 95 | """ 96 | Views should load unpoly attrs when request was performed by Unpoly. 97 | """ 98 | class UnpolyView(UnpolyCrispyFormViewMixin, VanillaCreateView): 99 | template_name = 'any_template.html' 100 | 101 | url_action = '/up' 102 | view = get_view(UnpolyView, url_action, HTTP_X_UP_VALIDATE='.breadcrumb') 103 | form_attrs = view.get_form_kwargs() 104 | 105 | self.assertEqual(form_attrs['form_action'], url_action) 106 | self.assertEqual(form_attrs['up_target'], settings.MAIN_UP_TARGET_FORM_VIEW) 107 | self.assertEqual(form_attrs['up_fail_target'], settings.MAIN_UP_FAIL_TARGET) 108 | 109 | context = view.get_context_data() 110 | self.assertEqual(context['up_target'], settings.MAIN_UP_TARGET_FORM_VIEW) 111 | self.assertEqual(context['up_fail_target'], settings.MAIN_UP_FAIL_TARGET) 112 | 113 | 114 | class UnpolyCrispyFormDjangoViewMixinTest(SimpleTestCase): 115 | 116 | def test_unpoly_view_form_attrs(self): 117 | """ 118 | Views should load unpoly attrs when request was performed by Unpoly. 119 | """ 120 | class UnpolyView(UnpolyCrispyFormViewMixin, DjangoCreateView): 121 | model = Note 122 | form_class = NoteForm 123 | template_name = 'any_template.html' 124 | object = None 125 | 126 | url_action = '/up' 127 | view = get_view(UnpolyView, url_action, HTTP_X_UP_VALIDATE='.breadcrumb') 128 | form_attrs = view.get_form_kwargs() 129 | 130 | self.assertEqual(form_attrs['form_action'], url_action) 131 | self.assertEqual(form_attrs['up_target'], settings.MAIN_UP_TARGET_FORM_VIEW) 132 | self.assertEqual(form_attrs['up_fail_target'], settings.MAIN_UP_FAIL_TARGET) 133 | 134 | context = view.get_context_data() 135 | self.assertEqual(context['up_target'], settings.MAIN_UP_TARGET_FORM_VIEW) 136 | self.assertEqual(context['up_fail_target'], settings.MAIN_UP_FAIL_TARGET) 137 | -------------------------------------------------------------------------------- /unpoly/__init__.py: -------------------------------------------------------------------------------- 1 | __version_info__ = __version__ = version = VERSION = '0.3.4' 2 | 3 | 4 | def get_version(): 5 | return version 6 | 7 | -------------------------------------------------------------------------------- /unpoly/forms.py: -------------------------------------------------------------------------------- 1 | from crispy_forms.helper import FormHelper 2 | 3 | 4 | class UnpolyCrispyFormMixin: 5 | """ 6 | For projects using Crispy Forms, use this mixin class to have 7 | Unpoly attributes injected into the form tag. 8 | """ 9 | 10 | def __init__(self, *args, **kwargs: dict) -> None: 11 | """Set any Unpoly attributes on Crispy FormHelper. 12 | 13 | Use view mixins to set attributes in view.get_form method 14 | """ 15 | self.form_action: str = kwargs.pop('form_action', '') 16 | self.multi_layer: bool = kwargs.pop('multi_layer', False) 17 | self.up_layer: str = kwargs.pop('up_layer', 'parent current') 18 | self.up_target: str = kwargs.pop('up_target', '') 19 | self.up_fail_layer: str = kwargs.pop('up_fail_layer', 'current') 20 | self.up_fail_target: str = kwargs.pop('up_fail_target', '') 21 | self.up_validate: str = kwargs.pop('up_validate', '') 22 | 23 | super().__init__(*args, **kwargs) 24 | self.helper = FormHelper() 25 | if self.form_action: 26 | self.helper.form_action = self.form_action 27 | self._set_unpoly_attrs() 28 | 29 | def unpoly_form_attrs(self) -> dict: 30 | """Unpoly html attributes that should be set on the form. 31 | 32 | Override on subclasses to customize. 33 | """ 34 | if not self.up_target: 35 | return {} 36 | 37 | unpoly_attrs = { 38 | 'up-target': self.up_target, 39 | 'up-layer': self.up_layer, 40 | 'up-fail-layer': self.up_fail_layer, 41 | 'up-fail-target': self.up_fail_target, 42 | } 43 | if self.multi_layer: 44 | unpoly_attrs['up-layer'] = 'current' 45 | 46 | return unpoly_attrs 47 | 48 | def _set_unpoly_attrs(self): 49 | """Set Unpoly attributes on Crispy FormHelper. 50 | """ 51 | unpoly_attrs = self.unpoly_form_attrs() 52 | 53 | if not unpoly_attrs: 54 | return 55 | 56 | self.helper.attrs.update(unpoly_attrs) 57 | 58 | 59 | __all__ = [ 60 | 'UnpolyCrispyFormMixin', 61 | ] 62 | -------------------------------------------------------------------------------- /unpoly/middleware.py: -------------------------------------------------------------------------------- 1 | from django.conf import settings 2 | from django.http import HttpRequest, HttpResponse 3 | from django.utils.deprecation import MiddlewareMixin 4 | 5 | SECURE_COOKIE = not settings.DEBUG 6 | 7 | 8 | def _unpoly_validate(self) -> bool: 9 | """Unpoly is validating but not saving a form""" 10 | return 'HTTP_X_UP_VALIDATE' in self.META 11 | 12 | 13 | def _unpoly_target(self) -> str: 14 | """ 15 | Comma-separated string of Target selector 16 | names of the element(s) Unpoly is swapping 17 | 18 | #content_panel,#breadcrumb_bar,.item_list 19 | """ 20 | return self.META.get('HTTP_X_UP_TARGET', '') 21 | 22 | 23 | def _is_unpoly(self) -> bool: 24 | """Request is triggered by Unpoly""" 25 | return ( 26 | 'HTTP_X_UP_VERSION' in self.META 27 | or 'HTTP_X_UP_MODE' in self.META 28 | or 'HTTP_X_UP_TARGET' in self.META 29 | or 'HTTP_X_UP_VALIDATE' in self.META 30 | ) 31 | 32 | 33 | class UnpolyMiddleware(MiddlewareMixin): 34 | 35 | def set_headers(self, request: HttpRequest, response: HttpResponse) -> HttpResponse: 36 | """ 37 | For fullest browser support, set headers & cookies 38 | so Unpoly can detect method & location. 39 | """ 40 | method = request.method 41 | response['X-Up-Method'] = method 42 | 43 | if method != 'GET': 44 | response.set_cookie('_up_method', method, secure=SECURE_COOKIE) 45 | else: 46 | response.delete_cookie('_up_method') 47 | 48 | return response 49 | 50 | def __call__(self, request: HttpRequest): 51 | request.unpoly_target = _unpoly_target.__get__(request) 52 | request.unpoly_validate = _unpoly_validate.__get__(request) 53 | request.is_unpoly = _is_unpoly.__get__(request) 54 | 55 | response = self.get_response(request) 56 | 57 | return self.set_headers(request, response) 58 | 59 | 60 | __all__ = [ 61 | 'UnpolyMiddleware', 62 | ] 63 | -------------------------------------------------------------------------------- /unpoly/typehints.py: -------------------------------------------------------------------------------- 1 | from django.http import HttpRequest 2 | 3 | 4 | class UnpolyHttpRequest(HttpRequest): 5 | """ 6 | HttpRequest class for using as type hint to Django Generic Class-based Views, 7 | Vanilla views, and View Mixin classes. 8 | 9 | from django.views.generic import CreateView 10 | class UserCreate(CreateView): 11 | request: UnpolyHttpRequest 12 | """ 13 | 14 | def is_unpoly(self) -> bool: 15 | return False 16 | 17 | def unpoly_target(self) -> str: 18 | return '' 19 | 20 | def unpoly_validate(self) -> bool: 21 | return False 22 | 23 | 24 | __all__ = [ 25 | 'UnpolyHttpRequest', 26 | ] 27 | -------------------------------------------------------------------------------- /unpoly/unpoly.py: -------------------------------------------------------------------------------- 1 | from ast import literal_eval 2 | import json 3 | from django.conf import settings 4 | from django.http import HttpResponse 5 | 6 | 7 | class Unpoly: 8 | """Partial port of Unpoly rails gem from 9 | https://github.com/unpoly/unpoly/blob/master/lib/unpoly/rails/change.rb 10 | 11 | This object allows the server to inspect the current request 12 | for Unpoly-related concerns such as "is this a page fragment update?". 13 | 14 | Available through the `up` method in all controllers, helpers and views. 15 | """ 16 | 17 | def __init__(self, meta: dict, query_params: dict = None) -> None: 18 | self.meta: dict = meta 19 | self.query_params: dict = query_params or {} 20 | 21 | def is_unpoly(self) -> bool: 22 | """Request is triggered by Unpoly 23 | """ 24 | return 'HTTP_X_UP_VERSION' in self.meta or self.is_validating() 25 | 26 | def mode(self) -> str: 27 | """Unpoly allows you to stack multiple pages on top of each other. 28 | 29 | Each stack element is called a layer. The kind of layer 30 | (e.g. a modal dialog vs. a popup box) is called mode. 31 | The initial page is called the root layer. 32 | An overlay is any layer that is not the root layer. 33 | """ 34 | return self.meta.get('HTTP_X_UP_MODE', settings.MAIN_UP_LAYER) 35 | 36 | def fail_mode(self) -> str: 37 | """Return layer mode requested by Unpoly when a request failure occurs. 38 | """ 39 | return self.meta.get('HTTP_X_UP_FAIL_MODE', settings.MAIN_UP_FAIL_LAYER) 40 | 41 | def layer(self) -> str: 42 | """Return layer mode requested by Unpoly when a request succeeds. 43 | """ 44 | return self.meta.get('HTTP_X_UP_LAYER', settings.MAIN_UP_LAYER) 45 | 46 | def fail_layer(self) -> str: 47 | """Return layer mode requested by Unpoly when a request fails. 48 | """ 49 | return self.meta.get('HTTP_X_UP_FAIL_LAYER', settings.MAIN_UP_FAIL_LAYER) 50 | 51 | def multi_layer(self) -> bool: 52 | """Check query params for key indicating that this layer is multiple overlay. 53 | 54 | Needed to indicate when a layer was launched from a non-root layer, such as when 55 | a `select` field URL launched a form to create a new Record, required to finish 56 | the original form. 57 | 58 | Not part of Unpoly protocol. 59 | """ 60 | return self.query_params.get('multi_layer') 61 | 62 | def accept_layer(self, response: HttpResponse, data: dict) -> HttpResponse: 63 | """Send X-Up-Accept-Layer and data to frontend to close the current layer. 64 | """ 65 | response['X-Up-Accept-Layer'] = json.dumps(data, default=str) 66 | return response 67 | 68 | def fail_target(self) -> str: 69 | """Returns the CSS selector for a fragment that Unpoly will update in 70 | case of a failed response. Server errors or validation failures are 71 | all examples for a failed response (non-200 status code). 72 | 73 | The Unpoly frontend will expect an HTML response containing an element 74 | that matches this selector. 75 | 76 | Server-side code is free to optimize its response by only returning HTML 77 | that matches this selector. 78 | """ 79 | return self.meta.get('HTTP_X_UP_FAIL_TARGET') or settings.MAIN_UP_FAIL_TARGET 80 | 81 | def target(self) -> str: 82 | """Returns the CSS selector for a fragment that Unpoly will update in 83 | case of a successful response (200 status code). 84 | 85 | The Unpoly frontend will expect an HTML response containing an element 86 | that matches this selector. 87 | 88 | Server-side code is free to optimize its successful response by only returning HTML 89 | that matches this selector. 90 | """ 91 | return self.meta.get('HTTP_X_UP_TARGET') or settings.MAIN_UP_TARGET 92 | 93 | def is_validating(self) -> bool: 94 | """Returns whether the current form submission should be 95 | [validated](https://unpoly.com/input-up-validate) (and not be saved to the database). 96 | """ 97 | return 'HTTP_X_UP_VALIDATE' in self.meta 98 | 99 | def validate(self) -> str: 100 | """If the current form submission is a [validation](https://unpoly.com/input-up-validate), 101 | this returns the name attribute of the form field that has triggered 102 | the validation. 103 | """ 104 | return self.meta.get('HTTP_X_UP_VALIDATE', '') 105 | 106 | def title(self, response: HttpResponse, title: str) -> HttpResponse: 107 | """Forces Unpoly to use the given string as the document title. 108 | 109 | This is useful when you skip rendering the `` in an Unpoly request. 110 | """ 111 | response['X-Up-Title'] = title 112 | return response 113 | 114 | def emit(self, response: HttpResponse, event: str, data: dict = None) -> HttpResponse: 115 | """The server may set a response header to emit events with the requested fragment update. 116 | 117 | The header value is a JSON array. Each element in the array is a JSON object representing 118 | an event to be emitted on the document. 119 | 120 | The object property { "type" } defines the event's type. Other properties become properties 121 | of the emitted event object. 122 | 123 | :param response: Response object that will be returned from view 124 | :param event: Event name, such as user:created 125 | :param data: Data dict to ship to the front end as the event value 126 | 127 | Eventual header value will look like so: 128 | X-Up-Events: [{"type": "user:created", "id": 5012 }, { "type": "signup:completed"}] 129 | """ 130 | try: 131 | events = literal_eval(response['X-Up-Events']) 132 | except KeyError: 133 | events = [] 134 | 135 | data = data or {} 136 | data['type'] = event 137 | events.append(data) 138 | response['X-Up-Events'] = json.dumps(events, default=str) 139 | 140 | return response 141 | 142 | def layer_emit(self, response: HttpResponse, event: str, data: dict = None) -> HttpResponse: 143 | """The server may also choose to emit the event on the layer being updated. 144 | 145 | To do so, add a property { "layer": "current" } to the JSON object of an event: 146 | 147 | Eventual header value will look like so: 148 | X-Up-Events: [{"type": "user:created", "name:" "foobar", "layer": "current"}] 149 | """ 150 | data = data or {} 151 | if 'layer' not in data: 152 | data['layer'] = 'current' 153 | return self.emit(response, event, data) 154 | 155 | def template_name(self) -> str: 156 | """Return the template name that Unpoly or any other XHR request specified. 157 | 158 | Not part of the official Unpoly Server Protocol, but can be useful to as a 159 | way to signal to the server the exact template that should be rendered and 160 | returned as the response. 161 | """ 162 | return self.meta.get('HTTP_X_TEMPLATE_NAME', '') 163 | 164 | def template_type(self) -> str: 165 | """Return the template type that Unpoly or any other XHR request specified. 166 | 167 | Not part of the official Unpoly Server Protocol, but can be useful to as a 168 | way to signal to the server that a given template type is desired. 169 | """ 170 | return self.meta.get('HTTP_X_TEMPLATE_TYPE', '') 171 | -------------------------------------------------------------------------------- /unpoly/views.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import List, Optional, TYPE_CHECKING 3 | 4 | from django.conf import settings 5 | from django.contrib import messages 6 | from django.core.exceptions import ImproperlyConfigured 7 | from django.db import DatabaseError 8 | from django.http import ( 9 | HttpResponse, 10 | HttpResponseRedirect, 11 | ) 12 | from django.shortcuts import reverse 13 | from django.template.response import TemplateResponse 14 | 15 | from .unpoly import Unpoly 16 | 17 | if TYPE_CHECKING: 18 | from .typehints import UnpolyHttpRequest 19 | 20 | logger = logging.getLogger(__name__) 21 | 22 | 23 | class UnpolyViewMixin: 24 | """ 25 | This object allows the server to inspect the current request 26 | for Unpoly-related concerns such as "is this a page fragment update?". 27 | 28 | Available through the `up` method in all controllers, helpers and views. 29 | """ 30 | if TYPE_CHECKING: 31 | request: 'UnpolyHttpRequest' 32 | 33 | action: str = '' 34 | _send_optimized_response: bool = False 35 | 36 | # Templates to use when returning an optimized response and Unpoly is returning a layer mode 37 | # https://v2.unpoly.com/layer-terminology 38 | unpoly_modal_template: str = settings.UNPOLY_MODAL_TEMPLATE 39 | unpoly_drawer_template: str = settings.UNPOLY_DRAWER_TEMPLATE 40 | unpoly_popup_template: str = settings.UNPOLY_POPUP_TEMPLATE 41 | unpoly_cover_template: str = settings.UNPOLY_COVER_TEMPLATE 42 | 43 | def __init__(self, *args, **kwargs): 44 | super().__init__(*args, **kwargs) 45 | self._up: Optional[Unpoly] = None 46 | self._record_event_data = {} 47 | 48 | @property 49 | def up(self) -> Unpoly: 50 | if not self._up: 51 | self._up = Unpoly(meta=self.request.META, query_params=self.request.GET) 52 | return self._up 53 | 54 | def up_mode(self) -> str: 55 | """Override on subclasses to handle fail modes.""" 56 | return self.up.mode() 57 | 58 | def get_unpoly_layer(self) -> str: 59 | """Override on subclasses to customize logic.""" 60 | return self.up.layer() 61 | 62 | def get_unpoly_fail_layer(self): 63 | """Override on subclasses to customize logic.""" 64 | return self.up.fail_layer() 65 | 66 | def get_template_names(self) -> List[str]: 67 | """Return Unpoly template name for the specified layer.""" 68 | if self.up.is_unpoly(): 69 | requested_name = self.up.template_name() 70 | if requested_name: 71 | return [requested_name] 72 | 73 | up_mode = self.up_mode() 74 | if up_mode == 'root': 75 | return super().get_template_names() 76 | 77 | template_name = getattr(self, f'unpoly_{up_mode}_template', '') 78 | if template_name: 79 | return [template_name] 80 | 81 | return super().get_template_names() 82 | 83 | def send_optimized_response(self) -> bool: 84 | """Should the server send an optimized HTML response? 85 | 86 | When this view is called, should the controller 87 | return optimized HTML response containing only the 88 | target(s) that Unpoly would insert / replace on page? 89 | 90 | Override on the subclassed view to extend logic 91 | determining when to send only HTML vs page redirect. 92 | """ 93 | return self._send_optimized_response and self.up.is_unpoly() 94 | 95 | def record_event_data(self, **kwargs) -> dict: 96 | """Data that should be included in `record:crud` event 97 | 98 | Override on subclasses to customize. 99 | """ 100 | if not self._record_event_data: 101 | try: 102 | record_id = self.object.id 103 | except AttributeError: 104 | self._record_event_data = kwargs 105 | return kwargs 106 | 107 | try: 108 | model_name = self.object.model_name() 109 | except AttributeError: 110 | model_name = self.object.__class__.__name__.lower() 111 | 112 | self._record_event_data = { 113 | 'id': record_id, 114 | 'action': self.action, 115 | 'model_name': model_name, 116 | } 117 | 118 | self._record_event_data.update(kwargs) 119 | 120 | return self._record_event_data 121 | 122 | def optimized_response(self) -> TemplateResponse: 123 | """Return optimized HTML response containing the target(s) Unpoly requests. 124 | 125 | Return optimized HTML response containing the target(s) 126 | that Unpoly to insert / replace on page. 127 | 128 | Each view must implement this function. 129 | 130 | return TemplateResponse( 131 | request=self.request, 132 | template='main_body.html', 133 | context={ 134 | 'key1': 'value1', 135 | 'key2': 'value2', 136 | 'object': self.object, 137 | }, 138 | ) 139 | """ 140 | raise NotImplementedError('Specify this method on each view') 141 | 142 | 143 | class UnpolyFormViewMixin(UnpolyViewMixin): 144 | """ 145 | Mixin class for views such as CreateView / UpdateView. 146 | Enables setting Unpoly target on forms and returning 147 | optimized responses. 148 | """ 149 | _send_optimized_success_response: bool = False 150 | 151 | enable_messages_framework: bool = True 152 | success_message: str = '' 153 | 154 | def form_valid(self, form): 155 | """When form is saved, handle various situations that might occur. 156 | 157 | - If DatabaseError was raised, display message to user. 158 | - Return optimized response. 159 | - Return HttpResponseRedirect like normal `form_valid` behavior. 160 | - If multiple layer, launched from select field, Accept the layer, 161 | so the underlying select field on the Parent layer can be updated. 162 | """ 163 | try: 164 | self.object = form.save() 165 | except DatabaseError as e: 166 | logger.exception(e) 167 | return self.handle_integrity_error_response() 168 | 169 | launched_from_select_field = self.request.GET.get('parent_select_field_id', '') 170 | if self.up.is_unpoly() and launched_from_select_field: 171 | return self.send_accept_layer(form, launched_from_select_field) 172 | 173 | msg = self.get_success_message(form.cleaned_data) 174 | if msg: 175 | messages.success(self.request, msg, extra_tags='safe') 176 | 177 | if self.send_optimized_success_response(): 178 | response = self.optimized_success_response() 179 | self.up.emit(response, 'record:crud', self.record_event_data()) 180 | return response 181 | 182 | return HttpResponseRedirect(self.get_success_url()) 183 | 184 | def up_mode(self) -> str: 185 | if getattr(self, 'invalid_form_submission', False): 186 | return self.up.fail_mode() 187 | return super().up_mode() 188 | 189 | def form_invalid(self, form): 190 | """ 191 | Set view attribute when form submission fails, to know 192 | how to choose the correct response template. 193 | """ 194 | self.invalid_form_submission = True 195 | return super().form_invalid(form) 196 | 197 | def send_accept_layer(self, form, select_field_id) -> HttpResponse: 198 | """ 199 | When Unpoly has opened multiple overlays and the form is saved successfully, then 200 | send the JSON details to enable updating the Select Field on the parent layer. 201 | """ 202 | self.object = form.save() 203 | resp = HttpResponse(b'', status=200) 204 | data = { 205 | 'id': self.object.id, 206 | 'name': str(self.object), 207 | 'parent_select_field_id': select_field_id, 208 | } 209 | self.up.accept_layer(resp, data) 210 | 211 | return resp 212 | 213 | def send_optimized_success_response(self) -> bool: 214 | """Should server send optimized HTML response after form is saved? 215 | 216 | When form is successfully saved, should the controller 217 | return optimized HTML response containing only the 218 | target(s) that Unpoly would insert / replace on page? 219 | 220 | Override on the subclassed view to extend logic 221 | determining when to send only HTML vs page redirect. 222 | 223 | When overriding, call this superclass method first 224 | to check for Unpoly status first. 225 | """ 226 | return self._send_optimized_success_response and self.up.is_unpoly() 227 | 228 | def optimized_success_response(self) -> TemplateResponse: 229 | """When form is saved, return only HTML that matches Unpoly target(s). 230 | 231 | When form is successfully saved, return optimized HTML response containing 232 | the target(s) that Unpoly to insert / replace on page. 233 | 234 | Each view must implement this function. 235 | 236 | return TemplateResponse( 237 | request=self.request, 238 | template='single_row_table.html', 239 | context={ 240 | 'body_id': 'membership_body', 241 | 'object': self.object, 242 | }, 243 | ) 244 | """ 245 | raise NotImplementedError('Specify this method on each view') 246 | 247 | def get_success_message(self, cleaned_data: dict) -> str: 248 | """Add success message to response if desired. 249 | 250 | When returning Unpoly optimized response, the template must include the `messages` 251 | markup or the `messages` markup should have the `up_hungry` html attribute set. 252 | """ 253 | if not self.enable_messages_framework: 254 | return '' 255 | 256 | try: 257 | return self.success_message % cleaned_data 258 | except Exception as e: 259 | logger.exception(e) 260 | return '' 261 | 262 | def perform_unpoly_validation(self, request): 263 | """ 264 | Unpoly form validation calls form validation but should not save form. 265 | """ 266 | try: 267 | instance = self.get_object() 268 | except (AttributeError, ImproperlyConfigured): 269 | instance = None 270 | 271 | form = self.get_form( 272 | data=request.POST, 273 | files=request.FILES, 274 | instance=instance, 275 | up_validate=True, 276 | ) 277 | form.is_valid() 278 | return self.form_invalid(form) 279 | 280 | def handle_integrity_error_response(self): 281 | """Return helpful error to the user when an unhandled error occurs. 282 | 283 | Trying hard to return a slightly more intelligent error than Internal Server Error 284 | when database integrity errors are raised. Mostly we want to catch such errors and 285 | return messages in the form, but sometimes it's pretty hard to do. 286 | 287 | So at least return something more useful than a 500. 288 | """ 289 | if self.up.is_unpoly(): 290 | return TemplateResponse(self.request, settings.DEFAULT_UP_ERROR_TEMPLATE, status=409) 291 | 292 | return HttpResponseRedirect(reverse(settings.DEFAULT_ERROR_VIEW)) 293 | 294 | def get_unpoly_target(self) -> str: 295 | """Sets Unpoly target in template context 296 | 297 | Forms can have html target set that Unpoly will replace when 298 | the server returns POST response when the form action is completed. 299 | 300 | Override on subclasses to customize logic for determining target. 301 | """ 302 | return self.up.target() 303 | 304 | def get_unpoly_fail_target(self) -> str: 305 | """Sets Unpoly fail target in template context 306 | 307 | Forms can have html fail target set that Unpoly will replace when 308 | the server returns POST response when the form action is completed. 309 | 310 | Override on subclasses to customize logic for determining fail target. 311 | """ 312 | return self.up.fail_target() 313 | 314 | def get_context_data(self, **kwargs) -> dict: 315 | """ 316 | Add Unpoly target to context, so it can be used in templates. 317 | """ 318 | return super().get_context_data( 319 | up_target=self.get_unpoly_target(), 320 | up_fail_target=self.get_unpoly_fail_target(), 321 | **kwargs, 322 | ) 323 | 324 | def post(self, request, *args, **kwargs) -> HttpResponse: 325 | """Perform Unpoly form validation and return if Unpoly is in form validation mode. 326 | """ 327 | if self.up.is_validating(): 328 | return self.perform_unpoly_validation(request=request) 329 | 330 | return super().post(request, *args, **kwargs) 331 | 332 | 333 | class UnpolyCrispyFormViewMixin(UnpolyFormViewMixin): 334 | """For views loading `django-crispy-forms`. 335 | 336 | Supports both Django Create / Update Generic views and Vanilla views. 337 | 338 | Initialize the crispy form with Unpoly data attributes 339 | that should be included on the form's HTML. 340 | """ 341 | 342 | def __init__(self, *args, **kwargs): 343 | superclass = super() 344 | superclass.__init__(*args, **kwargs) 345 | self.is_vanilla_view = not hasattr(superclass, 'get_form_kwargs') 346 | 347 | def get_unpoly_target(self) -> str: 348 | try: 349 | return settings.MAIN_UP_TARGET_FORM_VIEW 350 | except AttributeError: 351 | return super().get_unpoly_target() 352 | 353 | def get_form_kwargs(self) -> dict: 354 | """Return the Unpoly form data attributes that should be included on the Crispy form tag. 355 | 356 | Override on subclassed views to refine if necessary. 357 | """ 358 | if not self.up.is_unpoly(): 359 | return {} 360 | 361 | up_kwargs = { 362 | 'up_layer': self.get_unpoly_layer(), 363 | 'up_fail_layer': self.get_unpoly_fail_layer(), 364 | 'up_target': self.get_unpoly_target(), 365 | 'up_fail_target': self.get_unpoly_fail_target(), 366 | 'multi_layer': self.up.multi_layer(), 367 | 'form_action': self.request.get_full_path(), 368 | } 369 | 370 | if not self.is_vanilla_view: 371 | kwargs = super().get_form_kwargs() 372 | up_kwargs.update(kwargs) 373 | 374 | return up_kwargs 375 | 376 | def get_form(self, *args, **kwargs): 377 | if self.is_vanilla_view: 378 | kwargs.update(self.get_form_kwargs()) 379 | return super().get_form(*args, **kwargs) 380 | 381 | 382 | __all__ = ( 383 | 'UnpolyViewMixin', 384 | 'UnpolyFormViewMixin', 385 | 'UnpolyCrispyFormViewMixin', 386 | ) 387 | -------------------------------------------------------------------------------- /wordlist.dic: -------------------------------------------------------------------------------- 1 | unpoly 2 | --------------------------------------------------------------------------------