diff --git a/.gitignore b/.gitignore index e772646..d50d4f8 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,13 @@ htmlcov dist *.pyo -*.pyc.coverage +*.pyc +.coverage +__pycache__ *.egg* *.sqlite3 +/.tox +/env +/venv +.*.sw? +.*~ diff --git a/.travis.yml b/.travis.yml index b475f05..bf8c1c9 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,5 +1,5 @@ language: python -python: 3.5 +python: 3.7 sudo: false notifications: @@ -13,16 +13,9 @@ cache: - $HOME/.cache/pip env: - - TOX_ENV=py27-dj18 - - TOX_ENV=py27-dj19 - - TOX_ENV=py27-dj110 - - TOX_ENV=py33-dj18 - - TOX_ENV=py34-dj18 - - TOX_ENV=py34-dj19 - - TOX_ENV=py34-dj110 - - TOX_ENV=py35-dj18 - - TOX_ENV=py35-dj19 - - TOX_ENV=py35-dj110 - + - TOX_ENV=py37-dj111 + - TOX_ENV=py37-dj21 + - TOX_ENV=py37-dj22 + script: - tox -e $TOX_ENV diff --git a/README.rst b/README.rst index baa8ebb..ebabc2f 100644 --- a/README.rst +++ b/README.rst @@ -27,7 +27,7 @@ What it does ``intercooler_helpers`` is a small reusable app for `Django`_ which provides a few improvements for working with `Intercooler.js`_. -It providea a middleware which extracts relevant `Intercooler.js`_ data from the +It provides a middleware which extracts relevant `Intercooler.js`_ data from the querystring, and attaches it to the request as a separate ``QueryDict`` (ie: it behaves like ``request.POST`` or ``request.GET``) @@ -107,7 +107,7 @@ The following properties exist, mapping back to the keys mentioned in the - ``request.intercooler_data.url`` returns a ``namedtuple`` containing - - returns the ``ic-current-url`` (converted via ``urlparse``) or ``None`` + - the ``ic-current-url`` (converted via ``urlparse``) or ``None`` - A `Django`_ ``ResolverMatch`` pointing to the view which made the request (based on ``ic-current-url``) or ``None`` - ``request.intercooler_data.element`` returns a ``namedtuple`` containing diff --git a/intercooler_helpers/middleware.py b/intercooler_helpers/middleware.py index 690f7e9..6b83fe8 100644 --- a/intercooler_helpers/middleware.py +++ b/intercooler_helpers/middleware.py @@ -4,18 +4,12 @@ from collections import namedtuple from contextlib import contextmanager +from django.conf import settings from django.http import QueryDict, HttpResponse -try: - from django.urls import Resolver404, resolve -except ImportError: # Django <1.10 - from django.core.urlresolvers import Resolver404, resolve +from django.urls import Resolver404, resolve from django.utils.functional import SimpleLazyObject from django.utils.six.moves.urllib.parse import urlparse -try: - from django.utils.deprecation import MiddlewareMixin -except ImportError: # < Django 1.10 - class MiddlewareMixin(object): - pass +from django.utils.deprecation import MiddlewareMixin __all__ = ['IntercoolerData', 'HttpMethodOverride'] @@ -30,11 +24,15 @@ class HttpMethodOverride(MiddlewareMixin): with support for newer Django (ie: implements MiddlewareMixin), without dropping older versions, I could possibly replace this with that. """ + caring_methods = ['POST'] + target_methods = {'POST', 'PUT', 'PATCH', 'DELETE'} + def process_request(self, request): request.changed_method = False - if request.method != 'POST': + if request.method in self.caring_methods: pass + else: return - methods = {'GET', 'HEAD', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'} + methods = self.target_methods potentials = ((request.META, 'HTTP_X_HTTP_METHOD_OVERRIDE'), (request.GET, '_method'), (request.POST, '_method')) @@ -42,15 +40,19 @@ def process_request(self, request): if key in querydict and querydict[key].upper() in methods: newmethod = querydict[key].upper() # Don't change the method data if the calling method was - # the same as the indended method. + # the same as the intended method. if newmethod == request.method: return + else: pass request.original_method = request.method if hasattr(querydict, '_mutable'): with _mutate_querydict(querydict): querydict.pop(key) - if not hasattr(request, newmethod): - setattr(request, newmethod, request.POST) + # ```This could not be tested so may be never happen! + # if hasattr(request, newmethod): pass + # else: + setattr(request, newmethod, request.POST) + # ``` request.method = newmethod request.changed_method = True return @@ -76,6 +78,10 @@ def _mutate_querydict(qd): class IntercoolerQueryDict(QueryDict): + """ + This is proxy to access Intercooler data regardless HTTP method. + """ + @property def url(self): url = self.get('ic-current-url', None) @@ -127,38 +133,24 @@ def __repr__(self): def intercooler_data(self): - if not hasattr(self, '_processed_intercooler_data'): - IC_KEYS = ['ic-current-url', 'ic-element-id', 'ic-element-name', - 'ic-id', 'ic-prompt-value', 'ic-target-id', - 'ic-trigger-id', 'ic-trigger-name', 'ic-request'] - ic_qd = IntercoolerQueryDict('', encoding=self.encoding) - if self.method in ('GET', 'HEAD', 'OPTIONS'): - query_params = self.GET - else: - query_params = self.POST - query_keys = tuple(query_params.keys()) - for ic_key in IC_KEYS: - if ic_key in query_keys: - # emulate how .get() behaves, because pop returns the - # whole shebang. - # For a little while, we need to pop data out of request.GET - with _mutate_querydict(query_params) as REQUEST_DATA: - try: - removed = REQUEST_DATA.pop(ic_key)[-1] - except IndexError: - removed = [] - with _mutate_querydict(ic_qd) as IC_DATA: - IC_DATA.update({ic_key: removed}) - # Don't pop these ones off, so that decisions can be made for - # handling _method - ic_request = query_params.get('_method') - with _mutate_querydict(ic_qd) as IC_DATA: - IC_DATA.update({'_method': ic_request}) - # If HttpMethodOverride is in the middleware stack, this may - # return True. - IC_DATA.changed_method = getattr(self, 'changed_method', False) - self._processed_intercooler_data = ic_qd - return self._processed_intercooler_data + try: + return self._processed_intercooler_data + except AttributeError: pass + if self.method in ('GET', 'HEAD', 'OPTIONS'): + query_params = self.GET + else: + query_params = self.POST + + # Make mutable copy + # ic_qd = IntercoolerQueryDict(query_params, encoding=self.encoding) + # Just cast needed class to existing object + # https://stackoverflow.com/a/3464154/4763528 + ic_qd = query_params + ic_qd.__class__ = IntercoolerQueryDict + + ic_qd.changed_method = getattr(self, 'changed_method', False) + self._processed_intercooler_data = ic_qd + return ic_qd class IntercoolerData(MiddlewareMixin): @@ -168,7 +160,6 @@ def process_request(self, request): request.intercooler_data = SimpleLazyObject(intercooler_data.__get__(request)) - class IntercoolerRedirector(MiddlewareMixin): def process_response(self, request, response): if not request.is_intercooler(): diff --git a/intercooler_helpers/tests/test_middleware.py b/intercooler_helpers/tests/test_middleware.py index 4b213e0..526a9a3 100644 --- a/intercooler_helpers/tests/test_middleware.py +++ b/intercooler_helpers/tests/test_middleware.py @@ -73,21 +73,36 @@ def test_intercooler_data(rf, ic_mw): with pytest.raises(AttributeError): request._processed_intercooler_data data = request.intercooler_data + assert data.id == 3 + assert data['ic-id'] == '3' url = urlparse('/lol/') assert request.intercooler_data.current_url == (url, None) assert data.element == ('html_name', 'html_id') - assert data.id == 3 assert data.request is True + assert data['ic-request'] == 'true' assert data.target_id == 'target_html_id' assert data.trigger == ('triggered_by_html_name', 'triggered_by_id') assert data.prompt_value == 'undocumented' assert data._mutable is False - assert data.dict() == querystring_data + expecting = request.GET.copy() + assert data.dict() == expecting.dict() # ensure that after calling the property (well, SimpleLazyObject) # the request has cached the data structure to an attribute. request._processed_intercooler_data -def test_intercooler_data_removes_data_from_GET(rf, ic_mw): +def test_intercooler_data_special_url(rf, ic_mw): + querystring_data = { + 'ic-request': 'true', + 'ic-current-url': ' ', + '_method': 'POST', + } + request = rf.post('/', data=querystring_data, + HTTP_X_REQUESTED_WITH='XMLHttpRequest') + ic_mw.process_request(request) + url = urlparse('') + assert request.intercooler_data.current_url == (url, None) + +def test_intercooler_data_not_removes_data_from_GET(rf, ic_mw): querystring_data = { 'ic-id': '3', 'ic-request': 'true', @@ -108,13 +123,11 @@ def test_intercooler_data_removes_data_from_GET(rf, ic_mw): ic_mw.process_request(request) assert len(request.GET) == 10 url = urlparse('/lol/') - assert request.intercooler_data.current_url == (url, None) - # After evaluation, only _method should be left. - assert len(request.GET) == 1 - - -# TODO : test removes data from POST - + data = request.intercooler_data + assert data.current_url == (url, None) + # Should not change any requesting data + expecting = request.GET.copy() + assert data.dict() == expecting.dict() def test_http_method_override_via_querystring(rf, http_method_mw): request = rf.post('/?_method=patch', HTTP_X_REQUESTED_WITH='XMLHttpRequest') @@ -124,6 +137,17 @@ def test_http_method_override_via_querystring(rf, http_method_mw): assert request.original_method == 'POST' assert request.PATCH is request.POST +def test_http_method_override_via_querystring_same_method(rf, http_method_mw): + test_data = {'test': 'test'} + request = rf.post('/?_method=post', data=test_data, + HTTP_X_REQUESTED_WITH='XMLHttpRequest') + http_method_mw.process_request(request) + assert request.changed_method is False + assert request.method == 'POST' + assert hasattr(request, 'original_method') == False + test_data['test'] = [test_data['test']] + assert request.POST == test_data + def test_http_method_override_via_postdata(rf, http_method_mw): request = rf.post('/', data={'_method': 'PUT'}, HTTP_X_REQUESTED_WITH='XMLHttpRequest') http_method_mw.process_request(request) @@ -148,3 +172,19 @@ def test_intercooler_querydict_copied_change_method_from_request(rf, http_method ic_mw.process_request(request) assert request.changed_method is True assert request.intercooler_data.changed_method is True + + +def test_intercooler_querydict_repr(rf, http_method_mw, ic_mw): + request = rf.post('/', data={'ic-request': 'true'}, + HTTP_X_REQUESTED_WITH='XMLHttpRequest') + http_method_mw.process_request(request) + ic_mw.process_request(request) + ic_data = request.intercooler_data + # To get actual instance from SimpleLazyObject class + assert ic_data.request == True + expecting = ('>') + assert repr(ic_data) == expecting diff --git a/intercooler_helpers/tests/test_views.py b/intercooler_helpers/tests/test_views.py new file mode 100644 index 0000000..9078209 --- /dev/null +++ b/intercooler_helpers/tests/test_views.py @@ -0,0 +1,104 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, unicode_literals + +from django import urls +from django.core import exceptions +import pytest + +from intercooler_helpers import views + + +def test_get_without_ic(client): + response = client.get(urls.reverse('ic_dispatch')) + # print(response, '\n', *dir(response), sep='\t') + assert 'In GET' in response.content.decode('utf-8') + +def test_post_without_ic(client): + response = client.post(urls.reverse('ic_dispatch')) + assert response.content.decode('utf-8') == 'In POST' + +@pytest.mark.parametrize('pair', [ + ({'id': 'test'}, './/*[@id="test"]'), + ({'id': 'test', 'name': 'test_name'}, + './/*[@id="test" and @name="test_name"]'), + ]) +def test_build_xpath(pair): + xpath = views.ICTemplateResponse.build_xpath(attrbs=pair[0]) + assert xpath == pair[1] + +@pytest.mark.parametrize('target_id', ['test_class', 'target_1', 'target_2']) +def test_post_ic_get_html_part(client, target_id): + data = { + 'ic-request': 'true', + 'ic-trigger-id': 'post-btn', + 'ic-target-id': target_id, + 'message': 'message', + } + response = client.post(urls.reverse('ic_dispatch'), data=data, + HTTP_X_IC_REQUEST="true", HTTP_X_REQUESTED_WITH='XMLHttpRequest') + content = response.content.decode('utf-8') + assert 'Dispatched to post: %s' % data['message'] in content + # No other html tag than the message one + assert ' %r' % (esc_first, second)) + return re.match(esc_first, str(second)) + try: + target, method_name = next((target, method_name) + for method, trigger, target, method_name in self.ic_tuples + if match(method, req_method) + and match(trigger, req_trigger) + and match(target, req_target) + ) + method = getattr(self, method_name) + self.ic_data.matched_target = target + # Not catch AttributeError for developer to know what is the wrong + # method name + except StopIteration: pass + # Explicit is better than implicit + # return http.HttpResponseServerError( + # '%s: Not implemented method %s, trigger %s, target %s' + # % (self.__class__.__name__, + # req_method, req_trigger, req_target)) + return method(request, *args, **kwargs) + + +class ICUpdateView(edit_views.UpdateView): + def form_valid(self, form): + if self.ic_data.request: pass + else: + try: + # super of Python 2.7 + return super(ICUpdateView, self).form_valid(form) + except exceptions.ImproperlyConfigured as err: + message = err.args[0].replace( + 'a url', + 'a url or define a get_success_url method in view class') + raise exceptions.ImproperlyConfigured(message) + self.object = form.save() + # Although the form is valid, as user cannot refresh ic post + # request, we reuse form_invalid logic to render neccessary + # template. + # super of Python 2.7 + return super(ICUpdateView, self).form_invalid(form) diff --git a/setup.cfg b/setup.cfg index 8c1706a..d5e52f2 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,4 +1,5 @@ -[pytest] +# paths.py:44: RemovedInPytest4Warning: [pytest] section in setup.cfg files is deprecated, use [tool:pytest] instead +[tool:pytest] norecursedirs=.* *.egg .svn _build src bin lib local include testpaths=intercooler_helpers/tests python_files=test_*.py diff --git a/setup.py b/setup.py index b500540..b4176cb 100644 --- a/setup.py +++ b/setup.py @@ -63,13 +63,11 @@ def make_readme(root_path): install_requires=[ "Django>=1.8", "django-intercoolerjs>=1.1.0.0", + "lxml>=4.4.0", ], tests_require=[ - "pytest>=2.6", - "pytest-django>=2.8.0,<3.0.0", "pytest-cov>=1.8", - "pytest-remove-stale-bytecode>=1.0", - "pytest-catchlog>=1.2", + # pytest-catchlog plugin has been merged into the core, please remove it from your requirements. ], cmdclass={"test": PyTest}, zip_safe=False, @@ -84,12 +82,11 @@ def make_readme(root_path): "Programming Language :: Python :: 2", "Programming Language :: Python :: 2.7", "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.3", - "Programming Language :: Python :: 3.4", - "Programming Language :: Python :: 3.5", + "Programming Language :: Python :: 3.6", + "Programming Language :: Python :: 3.7", "Framework :: Django", - "Framework :: Django :: 1.10", - "Framework :: Django :: 1.8", - "Framework :: Django :: 1.9", + "Framework :: Django :: 1.11", + "Framework :: Django :: 2.1", + "Framework :: Django :: 2.2", ], ) diff --git a/test_settings.py b/test_settings.py index 9b4cb5a..d0dc0a6 100644 --- a/test_settings.py +++ b/test_settings.py @@ -59,7 +59,7 @@ }, ] -MIDDLEWARE_CLASSES = ( +MIDDLEWARE = MIDDLEWARE_CLASSES = ( 'django.contrib.sessions.middleware.SessionMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', diff --git a/test_templates/demo_project.html b/test_templates/demo_project.html index a417415..fa825db 100644 --- a/test_templates/demo_project.html +++ b/test_templates/demo_project.html @@ -56,6 +56,53 @@

Explanation


+
+

Using ICTemplateResponse

+

This example demos using ICTemplateResponse to extract html part from template file.

{% if show_details %} +

Explanation

+
    +
  • + Single template file contains full page design, with some elements contain id to identify themself. +
  • +
  • + The extract_html_part method will use an id to extract the element which has that id. +
  • +
  • + Then the extracted part can be used to render html response. +
  • +
+
Hide details{% else %} + Show details + {% endif %}{# if show_details #} +
+
+ +
+

Using ICTemplateResponseMixin and ICDispatchMixin class

+

This example demos using class based view mixins to process different combinations of request's method, trigger.

+

Explanation

+ + Get + + Post + {{ message }} + {% for message in messages %} + {{ message }} + {{ message }} + {% endfor message %} +
+
+

Implementing Infinite Scrolling

This example demos an infinite scroll UI.

diff --git a/test_urls.py b/test_urls.py index 6f87a4d..03a5aa4 100644 --- a/test_urls.py +++ b/test_urls.py @@ -14,6 +14,8 @@ except ImportError: # Django <1.10 from django.core.urlresolvers import reverse +from intercooler_helpers import views as ic_views + def _page_data(): return tuple(str(uuid4()) for x in range(0, 10)) @@ -51,6 +53,8 @@ class TestForm(Form): field = CharField() number = IntegerField(max_value=10, min_value=5) + def save(self): pass + def form(request): template = "form.html" @@ -61,7 +65,6 @@ def form(request): return TemplateResponse(request, template=template, context=context) - def polling_stop(request): resp = HttpResponse("Cancelled") resp['X-IC-CancelPolling'] = "true" @@ -94,6 +97,70 @@ def infinite_scrolling(request): return TemplateResponse(request, template=template, context=context) +def html_part(request, show_details=False): + template = "demo_project.html" + context = { + 'show_details': show_details, + } + return ic_views.ICTemplateResponse( + request, template=template, context=context) + + +class ICView(ic_views.ICTemplateResponseMixin, ic_views.ICDispatchMixin): + template_name = "demo_project.html" + ic_tuples = [ + ('get', None, 'test_class', 'get_html_part'), + ('post', 'post-btn', 'test_class', 'post_message'), + ('post', 'post-btn', 'target_*', 'post_message'), + ('get', 'post-btn', None, 'test_full_template'), + ] + target_map = {'target_*': 'target_{{ forloop.count }}'} + + def get(self, request, *args, **kwargs): + context = {'message': 'In GET'} + return self.render_to_response(context) + + def post(self, request, *args, **kwargs): + return HttpResponse('In POST') + + def get_html_part(self, request, *args, **kwargs): + context = { + 'message': 'Dispatched to get', + } + return self.render_to_response(context) + + def post_message(self, request, *args, **kwargs): + context = { + 'message': 'Dispatched to post: ' + request.POST['message'], + } + return self.render_to_response(context) + + def test_full_template(self, request, *args, **kwargs): + context = {'message': 'In Full Template'} + return self.render_to_response(context) + + +class ICTRNewMap(ic_views.ICTemplateResponse): + target_map = {'target_id': 'mapped_id'} + + +class ICUpdate(ic_views.ICTemplateResponseMixin, ic_views.ICDispatchMixin, + ic_views.ICUpdateView): + response_class = ICTRNewMap + template_name = "form.html" + + def check_form(self, request, *args, **kwargs): + _form = TestForm(request.POST or None) + return self.form_valid(_form) + # if self.form_valid(_form): + # return redirect(reverse('redirected')) + # context = {'form': _form} + # return self.render_to_response(context) + + def post(self, request, *args, **kwargs): + return self.check_form(request, *args, **kwargs) + + def root(request): template = "demo_project.html" context = { @@ -112,6 +179,18 @@ def root(request): url('^polling/start/$', polling_start, name='polling_start'), url('^polling/$', polling, name='polling'), url('^infinite/scrolling/$', infinite_scrolling, name='infinite_scrolling'), + url('^html_part/show/$', html_part, kwargs={'show_details': True}, + name='html_part_show'), + url('^html_part/$', html_part, kwargs={'show_details': False}, + name='html_part_hide'), + url('^ic_dispatch/$', ICView.as_view(), name='ic_dispatch'), + url('^ic_update/$', ICUpdate.as_view( + ic_tuples=[ + ('get', 'trigger_id', 'target_id', 'action'), + ('post', 'trigger_id', 'target_id', 'check_form'), + ], + target_map={'target_id': 'take_over'}, + ), name='ic_update'), url('^$', root, name='root'), ] diff --git a/tox.ini b/tox.ini index 34eda6b..d19cdb1 100644 --- a/tox.ini +++ b/tox.ini @@ -1,20 +1,25 @@ [tox] minversion=2.2 -envlist = py27-dj{18,19,110}, - py33-dj18, - py34-dj{18,19,110}, - py35-dj{18,19,110}, +envlist = + # Please update package classifiers in setup.py as well + py27-dj111, + py3{6,7}-dj{111,21,22}, + [testenv] commands = python -B -R -tt -W ignore setup.py test basepython = py27: python2.7 - py33: python3.3 - py34: python3.4 - py35: python3.5 + py36: python3.6 + py37: python3.7 deps = - dj18: Django>=1.8,<1.9 - dj19: Django>=1.9,<1.10 - dj110: Django>=1.10,<1.11 + py27: more-itertools<6.0.0 + py27: pytest<3.10 + py27: pytest-django<3.0.0 + py3{6,7}: pytest>=3.10 + py3{6,7}: pytest-django>=3.0.0 + dj111: Django>=1.11,<2.0 + dj21: Django>=2.1,<2.2 + dj22: Django>=2.2,<2.3