diff --git a/.travis.yml b/.travis.yml index a974ee8..8c93d1d 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,18 +1,26 @@ dist: xenial # for Python3.7+ language: python +env: + - MOZ_HEADLESS=1 +addons: + firefox: latest + matrix: include: - python: 2.7 env: TOXENV=py27-django18,py27-django19,py27-django10,py27-django11 - python: 3.5 - env: TOXENV=py35-django20 + env: TOXENV=py35-django20-drf30 - python: 3.6 - env: TOXENV=py36-django20,py36-django21,py36-django22 + env: TOXENV=py36-django20-drf30,py36-django21-drf30,py36-django22-drf30 - python: 3.7 - env: TOXENV=py37-django20,py37-django21,py37-django22 + env: TOXENV=py37-django20-drf30,py37-django21-drf30,py37-django22-drf30 install: - - pip install codecov tox + - pip install codecov tox selenium + - wget https://github.com/mozilla/geckodriver/releases/download/v0.24.0/geckodriver-v0.24.0-linux64.tar.gz + - tar -xvzf geckodriver* + - chmod +x geckodriver && sudo mv geckodriver /usr/local/bin/ script: tox && codecov diff --git a/example/blog/urls.py b/example/blog/urls.py index 4c1f701..b3f774b 100644 --- a/example/blog/urls.py +++ b/example/blog/urls.py @@ -1,10 +1,15 @@ # coding: utf-8 -from django.conf.urls import url +from django.conf.urls import url, include +from rest_framework import routers -from .views import BlogPostListView, BlogPostDetailView +from blog.views.views import BlogPostListView, BlogPostDetailView +from blog.views import api +router = routers.DefaultRouter() +router.register('blog-posts', api.BlogPostViewSet) urlpatterns = [ url(r'^$', BlogPostListView.as_view(), name='posts_list'), url(r'^(?P\d+)$', BlogPostDetailView.as_view(), name='blog_post_detail'), + url(r'^api-auth/', include('rest_framework.urls')) ] diff --git a/example/blog/views/__init__.py b/example/blog/views/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/example/blog/views/api.py b/example/blog/views/api.py new file mode 100644 index 0000000..27da57a --- /dev/null +++ b/example/blog/views/api.py @@ -0,0 +1,40 @@ +# coding: utf-8 +from __future__ import unicode_literals + +from rest_framework import routers, serializers, viewsets +from blog.models import BlogPost, Category, Tag, TextBlock + +from light_draft.views import DraftAPIViewMixin + + +class CategorySerializer(serializers.ModelSerializer): + class Meta: + model = Category + fields = ['title'] + + +class TagSerializer(serializers.ModelSerializer): + class Meta: + model = Tag + fields = ['title', 'colour_class'] + + +class TextBlockSerializer(serializers.ModelSerializer): + class Meta: + model = TextBlock + fields = ['title', 'body'] + + +class BlogPostSerializer(serializers.ModelSerializer): + tags = TagSerializer(many=True) + blocks = TextBlockSerializer(many=True) + category = CategorySerializer() + + class Meta: + model = BlogPost + fields = ['pk', 'title', 'lead', 'body', 'category', 'tags', 'blocks'] + + +class BlogPostViewSet(DraftAPIViewMixin, viewsets.ModelViewSet): + queryset = BlogPost.objects.all() + serializer_class = BlogPostSerializer diff --git a/example/blog/views.py b/example/blog/views/views.py similarity index 92% rename from example/blog/views.py rename to example/blog/views/views.py index 1960831..2783e64 100644 --- a/example/blog/views.py +++ b/example/blog/views/views.py @@ -6,7 +6,7 @@ from django.views.generic.list import ListView from django.views.generic.detail import DetailView -from .models import BlogPost +from blog.models import BlogPost class BlogPostListView(ListView): diff --git a/example/settings.py b/example/settings.py index 15e1eb7..6dd373d 100644 --- a/example/settings.py +++ b/example/settings.py @@ -39,6 +39,7 @@ 'django.contrib.staticfiles', 'light_draft', + 'rest_framework', 'blog', @@ -109,3 +110,10 @@ 'LOCATION': 'just-an-example', } } + + +REST_FRAMEWORK = { + 'DEFAULT_PERMISSION_CLASSES': [ + 'rest_framework.permissions.DjangoModelPermissionsOrAnonReadOnly' + ] +} diff --git a/example/tests/test_integrational.py b/example/tests/test_integrational.py new file mode 100644 index 0000000..7f1ea8c --- /dev/null +++ b/example/tests/test_integrational.py @@ -0,0 +1,75 @@ +import time + +from django.test import LiveServerTestCase +from django.contrib.staticfiles.testing import StaticLiveServerTestCase +from selenium.webdriver.support.wait import WebDriverWait +from selenium import webdriver +from selenium.webdriver.common.keys import Keys + +try: + from django.urls import reverse +except ImportError: + from django.core.urlresolvers import reverse + +from tests import factories as f + + +class DraftTestCase(StaticLiveServerTestCase): + + @classmethod + def setUpClass(cls): + super(DraftTestCase, cls).setUpClass() + cls.post = f.BlogPostFactory(tags_count=2, blocks_count=2) + cls.admin_change_url = reverse('admin:blog_blogpost_change', args=(cls.post.pk,)) + cls.admin_preview_url = reverse('admin:blog_blogpost_preview', args=(cls.post.pk,)) + cls.admin_user = f.UserFactory(username='admin') + cls.admin_user.set_password('admin') + cls.admin_user.save() + + def setUp(self): + super(DraftTestCase, self).setUp() + self.selenium = webdriver.Firefox() + self.selenium.get('{}/admin/'.format(self.live_server_url)) + self.assertEqual('{}/admin/login/?next=/admin/'.format(self.live_server_url), self.selenium.current_url) + self.selenium.find_element_by_name('username').send_keys('admin') + self.selenium.find_element_by_name('password').send_keys('admin') + self.selenium.find_element_by_xpath('//input[@type="submit"]').click() + + WebDriverWait(self.selenium, 2).until( + lambda driver: driver.find_element_by_tag_name('body')) + + self.assertEqual('{}/admin/'.format(self.live_server_url), self.selenium.current_url) + + def tearDown(self): + self.selenium.quit() + super(DraftTestCase, self).tearDown() + + def test_ok(self): + url = '{}{}'.format(self.live_server_url, self.admin_change_url) + self._get_url(url) + + self.selenium.find_element_by_id('id_title').send_keys('Exiting new title') + + draft_element = self.selenium.find_elements_by_css_selector('#content-main .object-tools a.previewlink') + self.assertEqual(len(draft_element), 1) + + draft_element = draft_element[0] + draft_element.is_displayed() + self.assertEqual(draft_element.text, 'DRAFT PREVIEW') + + draft_element.click() + self.selenium.switch_to_window(self.selenium.window_handles[1]) + time.sleep(1) # need some delay after switching tabs... + + expected_part_url = '{}{}?hash=blog:blogpost:{}'.format( + self.live_server_url, + self.post.get_absolute_url(), + self.post.pk + ) + + self.assertTrue(self.selenium.current_url.startswith(expected_part_url)) + + def _get_url(self, url): + """Helper to open an URL and check that we weren't redirected somewhere.""" + self.selenium.get(url) + self.assertEqual(url, self.selenium.current_url) diff --git a/example/urls.py b/example/urls.py index 54b01d0..390d7cc 100644 --- a/example/urls.py +++ b/example/urls.py @@ -2,9 +2,12 @@ from django.conf.urls import include, url from django.contrib import admin +from blog.urls import router + admin.autodiscover() urlpatterns = [ url(r'^', include('blog.urls')), url(r'^admin/', admin.site.urls), + url(r'^api/v1/', include(router.urls)), ] diff --git a/light_draft/static/admin/light.draft.js b/light_draft/static/admin/light.draft.js index 5b463e1..aa26741 100644 --- a/light_draft/static/admin/light.draft.js +++ b/light_draft/static/admin/light.draft.js @@ -1,7 +1,21 @@ (function ($) { $(function () { var href = location.href.replace(/(\/change\/?$|\/$)/, '') + '/preview/', // TODO: generate an URL - button = $('
  • Draft preview
  • '); + button = $('
  • Draft preview
  • '), + csrftoken = $("[name=csrfmiddlewaretoken]").val(); + + function csrfSafeMethod(method) { + // these HTTP methods do not require CSRF protection + return (/^(GET|HEAD|OPTIONS|TRACE)$/.test(method)); + } + + $.ajaxSetup({ + beforeSend: function(xhr, settings) { + if (!csrfSafeMethod(settings.type) && !this.crossDomain) { + xhr.setRequestHeader("X-CSRFToken", csrftoken); + } + } + }); $('.object-tools > li > a.viewsitelink') .closest('ul') @@ -9,7 +23,7 @@ .before(button); button.click(function () { - var form = $('form[method="post"][enctype="multipart/form-data"]'), + var form = $('form[method="post"]'), m2m = form.find('select[multiple="multiple"][id$="_to"]'), link; diff --git a/light_draft/views.py b/light_draft/views.py index 193da90..f0f97bd 100644 --- a/light_draft/views.py +++ b/light_draft/views.py @@ -32,7 +32,19 @@ def get_object(self, *args, **kwargs): return super(BaseDraftView, self).get_object(*args, **kwargs) - def get_context_data(self, *args, **kwargs): - context = super(BaseDraftView, self).get_context_data(*args, **kwargs) - context['is_draft_preview'] = True - return context + +class DraftAPIViewMixin: + + def get_object(self, *args, **kwargs): + if getattr(self, '__object', None): + return self.__object + + if 'hash' in self.request.GET: + try: + self.__object = load_from_shapshot( + self.serializer_class.Meta.model, self.request.GET.get('hash')) + except DraftError: + raise Http404('Snapshot does not exist') + return self.__object + + return super().get_object(*args, **kwargs) diff --git a/requirements.txt b/requirements.txt index 3a2fc65..3827cca 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,28 +1,60 @@ appnope==0.1.0 +astroid==2.2.5 +atomicwrites==1.3.0 +attrs==19.1.0 backports.shutil-get-terminal-size==1.0.0 +blessings==1.7 +bpython==0.17.1 +certifi==2019.3.9 +chardet==3.0.4 +coverage==4.5.1 +curtsies==0.3.0 decorator==4.1.2 -Django==1.9 +Django==2.2.3 +djangorestframework==3.10.1 enum34==1.1.6 factory-boy==2.10.0 Faker==0.8.12 +greenlet==0.4.15 +idna==2.8 +importlib-metadata==0.18 ipaddress==1.0.19 ipython==5.5.0 ipython-genutils==0.2.0 +isort==4.3.21 +lazy-object-proxy==1.4.1 +mccabe==0.6.1 +more-itertools==7.2.0 +packaging==19.0 +parameterized==0.6.1 pathlib2==2.3.0 pexpect==4.3.1 pickleshare==0.7.4 +pluggy==0.12.0 prompt-toolkit==1.0.15 ptyprocess==0.5.2 +py==1.8.0 Pygments==2.2.0 +pylint==2.3.1 +pyparsing==2.4.0 +pytest==5.0.1 +pytest-django==3.5.1 python-dateutil==2.7.2 pytz==2017.3 +requests==2.21.0 scandir==1.6 +selenium==3.141.0 simplegeneric==0.8.1 six==1.11.0 South==1.0 +sqlparse==0.3.0 text-unidecode==1.2 +tox==3.0.0 traitlets==4.3.2 +typed-ast==1.4.0 +typing==3.6.6 +urllib3==1.24.1 +virtualenv==16.6.2 wcwidth==0.1.7 -coverage==4.5.1 -tox==3.0.0 -parameterized==0.6.1 +wrapt==1.11.2 +zipp==0.5.2 diff --git a/tox.ini b/tox.ini index 641f42b..fb07910 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py27-django{18,19,10,11},py{35,36,37}-django{20,21,22} +envlist = py27-django{18,19,10,11},py{35,36,37}-django{20,21,22}-drf{30} [testenv] deps = @@ -13,6 +13,8 @@ deps = factory-boy~=2.12.0 coverage~=4.5.0 parameterized~=0.7.0 + drf30: djangorestframework~=3.10.0 + selenium~=3.141.0 changedir = example commands =