diff --git a/.travis.yml b/.travis.yml
index c304cdef19..fffad52095 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -91,9 +91,7 @@ install:
&& mkdir geckodriver \
&& tar -xzf geckodriver-v0.19.1-linux64.tar.gz -C geckodriver \
&& export PATH=$PATH:$PWD/geckodriver \
- && export DISPLAY=:99.0 \
- && sh -e /etc/init.d/xvfb start \
- && sleep 3 # give xvfb some time to start
+ && export DISPLAY=:99.0
fi
- |
@@ -163,11 +161,11 @@ script:
# selenium tests
if [[ "$ZDS_TEST_JOB" == *"selenium"* ]]; then
yarn build
- python manage.py \
+ xvfb-run --server-args="-screen 0 1280x720x8" python manage.py \
test \
- --keepdb \
--settings zds.settings.ci_test \
- --tag=front
+ --tag=front \
+ --keepdb
fi
- |
diff --git a/assets/js/content-publication-readiness.js b/assets/js/content-publication-readiness.js
index d6702f6995..6efa1eca36 100644
--- a/assets/js/content-publication-readiness.js
+++ b/assets/js/content-publication-readiness.js
@@ -1,19 +1,18 @@
-(function ($, undefined) {
- $("li").on("click", ".readiness", function (e) {
+(function ($) {
+ $(".readiness").on("click", function (e) {
var url = $(e.target).data("url");
- var readiness = $(e.target).data("is-ready") === "true";
+ var readiness = $(e.target).data("is-ready").toString() === "true";
var csrf = $("input[name=csrfmiddlewaretoken]").val();
-
+ var toggledReadiness = !readiness;
$.ajax(url, {method: "PUT", data: {
- "ready_to_publish": readiness,
+ "ready_to_publish": toggledReadiness,
"container_slug": $(e.target).data("container-slug"),
"parent_container_slug": $(e.target).data("parent-container-slug") || ""
}, success: function () {
- $(e.target).data("is-ready", readiness?"false":"true")
- .children("span")
- .removeClass("glyphicon-remove-sign")
- .removeClass("glyphicon-ok-sign")
- .addClass(readiness?"glyphicon-remove-sign":"glyphicon-ok-sign");
+ var readinessAsString = String(toggledReadiness);
+ var newDisplayedText = $(e.target).data("is-ready-" + readinessAsString);
+ $(e.target).attr("data-is-ready", readinessAsString)
+ .text(newDisplayedText);
}, headers: {
"X-CSRFToken": csrf
}});
diff --git a/doc/source/back-end/contents_manifest.rst b/doc/source/back-end/contents_manifest.rst
index 09f72f3d20..c640c64026 100644
--- a/doc/source/back-end/contents_manifest.rst
+++ b/doc/source/back-end/contents_manifest.rst
@@ -38,10 +38,10 @@ Les 0 non significatifs sont optionnels ainsi ``{version: "1"}`` est strictement
Version 2.1
-----------
-La version 2.1 est la version actuelement utilisée.
-Le manifest voit l'arrivée d'un nouvel élément non obligatoire ``ready_to_publish`` qui sera utilisé sur tous les éléments de type `Container`.
+La version 2.1 est la version actuellement utilisée.
+Le manifest voit l'arrivée d'un nouvel élément non obligatoire ``ready_to_publish`` qui sera utilisé sur tous les éléments de type ``Container``.
Cet élément permet de marquer qu'une partie ou un chapitre est prêt à être publié. Lorsque la valeur est à ``False``, la partie ou le chapitre
-sont simplement ignoré du processus de publication.
+sont simplement ignorés du processus de publication.
Lorsque l'attribut n'est pas renseigné, il est supposé *truthy*.
diff --git a/requirements-dev.txt b/requirements-dev.txt
index 8d9d6bc019..9cca6eed7e 100644
--- a/requirements-dev.txt
+++ b/requirements-dev.txt
@@ -7,7 +7,7 @@ flake8==3.4.1
flake8_quotes==0.11.0
autopep8==1.3.2
sphinx==1.6.3
-selenium==3.6.0
+selenium==3.8.1
sphinx_rtd_theme==0.2.4
faker==0.8.3
mock==2.0.0
diff --git a/templates/tutorialv2/view/container.html b/templates/tutorialv2/view/container.html
index cb3b56da6a..cdf7d07c55 100644
--- a/templates/tutorialv2/view/container.html
+++ b/templates/tutorialv2/view/container.html
@@ -243,17 +243,17 @@
{% endif %}
- {% if container.ready_to_publish %}
-
- {% trans "Marquer comme à ne pas valider." %}
+
+ {% if container.ready_to_publish %}
+ {% trans "Marquer comme à ne pas valider." %}
+ {% else %}
+ {% trans "Marquer comme prêt à valider." %}
+ {% endif %}
- {% else %}
-
- {% trans "Marquer comme prêt à valider." %}
- {% endif %}
{% endif %}
{% endblock %}
diff --git a/zds/member/api/permissions.py b/zds/member/api/permissions.py
index 85f6958f61..920dc1ff68 100644
--- a/zds/member/api/permissions.py
+++ b/zds/member/api/permissions.py
@@ -25,16 +25,25 @@ def has_object_permission(self, request, view, obj):
class IsOwner(permissions.BasePermission):
+
+ def has_permission(self, request, view):
+ return request.user and self.has_object_permission(request, view, view.get_object())
+
def has_object_permission(self, request, view, obj):
- # Write permissions are not allowed to the owner of the snippet
if hasattr(obj, 'user'):
- owners = [obj.user]
+ owners = [obj.user.pk]
elif hasattr(obj, 'author'):
- owners = [obj.author]
+ owners = [obj.author.pk]
elif hasattr(obj, 'authors'):
- owners = list(obj.authors)
+ owners = list(obj.authors.values_list('pk', flat=True))
- return request.user in owners
+ return request.user.pk in owners
+
+
+class IsAuthorOrStaff(permissions.BasePermission):
+ def has_permission(self, request, view):
+ return IsStaffUser().has_permission(request, view) or IsOwner().has_object_permission(request, view,
+ view.get_object())
class IsNotOwnerOrReadOnly(permissions.BasePermission):
diff --git a/zds/settings/abstract_base/django.py b/zds/settings/abstract_base/django.py
index 967f2fcebe..9b339ee9ce 100644
--- a/zds/settings/abstract_base/django.py
+++ b/zds/settings/abstract_base/django.py
@@ -145,8 +145,8 @@
CRISPY_TEMPLATE_PACK = 'bootstrap'
INSTALLED_APPS = (
- 'django.contrib.auth',
'django.contrib.contenttypes',
+ 'django.contrib.auth',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
diff --git a/zds/tutorialv2/api/views.py b/zds/tutorialv2/api/views.py
index 40450df80d..97b068c1e0 100644
--- a/zds/tutorialv2/api/views.py
+++ b/zds/tutorialv2/api/views.py
@@ -4,14 +4,14 @@
from rest_framework.generics import UpdateAPIView
from rest_framework.serializers import Serializer, CharField, BooleanField
from rest_framework.permissions import IsAuthenticatedOrReadOnly
-from zds.member.api.permissions import CanReadAndWriteNowOrReadOnly, IsNotOwnerOrReadOnly, IsStaffUser, IsOwner
+from zds.member.api.permissions import CanReadAndWriteNowOrReadOnly, IsNotOwnerOrReadOnly, IsAuthorOrStaff
from zds.tutorialv2.utils import search_container_or_404
from zds.utils.api.views import KarmaView
from zds.tutorialv2.models.database import ContentReaction, PublishableContent
class ContainerReadinessSerializer(Serializer):
- parent_container_slug = CharField()
+ parent_container_slug = CharField(allow_blank=True, allow_null=True, required=False)
container_slug = CharField(required=True)
ready_to_publish = BooleanField(required=True)
@@ -45,7 +45,7 @@ class ContentReactionKarmaView(KarmaView):
class ContainerPublicationReadinessView(UpdateAPIView):
- permission_classes = (IsStaffUser, IsOwner)
+ permission_classes = (IsAuthorOrStaff, )
serializer_class = ContainerReadinessSerializer
def get_object(self):
@@ -54,4 +54,5 @@ def get_object(self):
.first()
if not content:
raise Http404()
+ self.check_object_permissions(self.request, object)
return content
diff --git a/zds/tutorialv2/factories.py b/zds/tutorialv2/factories.py
index 062e97ca56..3dce25263b 100644
--- a/zds/tutorialv2/factories.py
+++ b/zds/tutorialv2/factories.py
@@ -41,7 +41,7 @@ class Meta:
pubdate = datetime.now()
@classmethod
- def _prepare(cls, create, **kwargs):
+ def _prepare(cls, create, *, light=True, **kwargs):
auths = []
if 'author_list' in kwargs:
auths = kwargs.pop('author_list')
@@ -52,9 +52,6 @@ def _prepare(cls, create, **kwargs):
given_licence = Licence.objects.filter(title=given_licence).first() or Licence.objects.first()
licence = given_licence or LicenceFactory()
- light = True
- if 'light' in kwargs:
- light = kwargs.pop('light')
text = text_content
if not light:
text = tricky_text_content
@@ -82,13 +79,9 @@ class Meta:
title = factory.Sequence(lambda n: 'Mon container No{0}'.format(n + 1))
@classmethod
- def _prepare(cls, create, **kwargs):
- db_object = kwargs.pop('db_object', None)
+ def _prepare(cls, create, *, db_object=None, light=True, **kwargs):
parent = kwargs.pop('parent', None)
- light = True
- if 'light' in kwargs:
- light = kwargs.pop('light')
text = text_content
if not light:
text = tricky_text_content
@@ -109,13 +102,9 @@ class Meta:
title = factory.Sequence(lambda n: 'Mon extrait No{0}'.format(n + 1))
@classmethod
- def _prepare(cls, create, **kwargs):
+ def _prepare(cls, create, *, light=True, container=None, **kwargs):
db_object = kwargs.pop('db_object', None)
- parent = kwargs.pop('container', None)
-
- light = True
- if 'light' in kwargs:
- light = kwargs.pop('light')
+ parent = container
text = text_content
if not light:
text = tricky_text_content
diff --git a/zds/tutorialv2/models/versioned.py b/zds/tutorialv2/models/versioned.py
index ff2ebf7b30..b3ceee1820 100644
--- a/zds/tutorialv2/models/versioned.py
+++ b/zds/tutorialv2/models/versioned.py
@@ -794,6 +794,14 @@ def requires_validation(self):
"""
return self.type in CONTENT_TYPES_REQUIRING_VALIDATION
+ def remove_children(self, children_slugs):
+ for slug in children_slugs:
+ if slug not in self.children_dict:
+ continue
+ to_be_remove = self.children_dict[slug]
+ self.children.remove(to_be_remove)
+ del self.children_dict[slug]
+
def _dump_html(self, file_path, content, db_object):
try:
with file_path.open('w', encoding='utf-8') as f:
diff --git a/zds/tutorialv2/publication_utils.py b/zds/tutorialv2/publication_utils.py
index f5e5fbbcc2..56b3bd82b7 100644
--- a/zds/tutorialv2/publication_utils.py
+++ b/zds/tutorialv2/publication_utils.py
@@ -490,7 +490,6 @@ def publish(self, md_file_path, base_name, silently_pass=True, **kwargs):
class FailureDuringPublication(Exception):
"""Exception raised if something goes wrong during publication process
"""
-
def __init__(self, *args, **kwargs):
super(FailureDuringPublication, self).__init__(*args, **kwargs)
diff --git a/zds/tutorialv2/publish_container.py b/zds/tutorialv2/publish_container.py
index 295fbc1968..74ab1d45b7 100644
--- a/zds/tutorialv2/publish_container.py
+++ b/zds/tutorialv2/publish_container.py
@@ -69,13 +69,16 @@ def publish_container(db_object, base_dir, container, template='tutorialv2/expor
parsed = emarkdown(container.get_introduction(), db_object.js_support)
container.introduction = str(part_path)
write_chapter_file(base_dir, container, part_path, parsed, path_to_title_dict, image_callback)
-
- for i, child in enumerate(copy.copy(container.children)):
+ children = copy.copy(container.children)
+ container.children = []
+ container.children_dict = {}
+ for child in filter(lambda c: c.ready_to_publish, children):
altered_version = copy.copy(child)
- container.children[i] = altered_version
+ container.children.append(altered_version)
container.children_dict[altered_version.slug] = altered_version
- path_to_title_dict.update(publish_container(db_object, base_dir, altered_version, file_ext=file_ext,
- image_callback=image_callback))
+ result = publish_container(db_object, base_dir, altered_version, file_ext=file_ext,
+ image_callback=image_callback)
+ path_to_title_dict.update(result)
if container.conclusion and container.get_conclusion():
part_path = Path(container.get_prod_path(relative=True), 'conclusion.' + file_ext)
parsed = emarkdown(container.get_conclusion(), db_object.js_support)
diff --git a/zds/tutorialv2/tests/__init__.py b/zds/tutorialv2/tests/__init__.py
index 19ef2e9462..cd17ac450a 100644
--- a/zds/tutorialv2/tests/__init__.py
+++ b/zds/tutorialv2/tests/__init__.py
@@ -1,6 +1,11 @@
from django.conf import settings
import shutil
+from django.urls import reverse
+from selenium.webdriver.support.wait import WebDriverWait
+
+from zds.tutorialv2.models.database import Validation
+
class TutorialTestMixin:
def clean_media_dir(self):
@@ -11,3 +16,73 @@ def clean_media_dir(self):
def tearDown(self):
self.clean_media_dir()
+
+
+class TutorialFrontMixin:
+ def login(self, profile):
+ """
+ TODO: This is definitely way too slow. Fasten this.
+ """
+ selenium = self.selenium
+ find_element = selenium.find_element_by_css_selector
+
+ selenium.get(self.live_server_url + reverse('member-login'))
+
+ username = find_element('.content-container input#id_username')
+ password = find_element('.content-container input#id_password')
+ username.send_keys(profile.user.username)
+ password.send_keys('hostel77')
+
+ find_element('.content-container button[type=submit]').click()
+
+ # Wait until the user is logged in (this raises if the element
+ # is not found).
+
+ find_element('.header-container .logbox .my-account .username')
+
+ def login_author(self):
+ self.login(self.user_author.profile)
+
+ def login_staff(self):
+ self.login(self.user_staff.profile)
+
+ def logout(self):
+ find_element = self.selenium.find_element_by_css_selector
+ find_element('#my-account').click()
+ find_element('form[action="/membres/deconnexion/"] button').click()
+
+ def ask_validation(self):
+ find_element = self.selenium.find_element_by_css_selector
+ self.selenium.get(self.live_server_url + self.content.get_absolute_url())
+ find_element('a[href="#ask-validation"]').click()
+ find_element('#id_text').send_keys('Coucou.')
+ find_element('#ask-validation button[type="submit"]').click()
+
+ def take_reservation(self):
+ find_element = self.selenium.find_element_by_css_selector
+ self.selenium.get(self.live_server_url + self.content.get_absolute_url())
+ validation = Validation.objects.filter(content=self.content).first()
+ find_element('form[action="/validations/reserver/{}/"] button'.format(validation.pk)).click()
+
+ def validate(self):
+ find_element = self.selenium.find_element_by_css_selector
+ self.selenium.get(self.live_server_url + self.content.get_absolute_url())
+ validation = Validation.objects.filter(content=self.content).first()
+ find_element('a[href="#valid-publish"]').click()
+ find_element('form#valid-publish #id_text').send_keys('Coucou.')
+ find_element('form[action="/validations/accepter/{}/"] button'.format(validation.pk)).click()
+
+ def wait_element_attribute_change(self, locator, attribute, initial_value, time):
+ return WebDriverWait(self.selenium, time) \
+ .until(AttributeHasChanged(locator, attribute, initial_value))
+
+
+class AttributeHasChanged:
+ def __init__(self, locator, attribute_name, initial_value):
+ self.locator = locator
+ self.attribute_name = attribute_name
+ self.initial_value = initial_value
+
+ def __call__(self, driver):
+ element = driver.find_element(*self.locator)
+ return element.get_attribute(self.attribute_name) != self.initial_value
diff --git a/zds/tutorialv2/tests/tests_front.py b/zds/tutorialv2/tests/tests_front.py
new file mode 100644
index 0000000000..dda39d8337
--- /dev/null
+++ b/zds/tutorialv2/tests/tests_front.py
@@ -0,0 +1,98 @@
+from django.contrib.staticfiles.testing import StaticLiveServerTestCase
+from django.test import override_settings
+from django.urls import reverse
+from selenium.common.exceptions import WebDriverException
+from selenium.webdriver.common.by import By
+from selenium.webdriver.firefox.webdriver import WebDriver
+from django.test import tag
+from selenium.webdriver.support import expected_conditions
+from selenium.webdriver.support.wait import WebDriverWait
+
+from zds.member.factories import StaffProfileFactory, ProfileFactory
+from zds.tutorialv2.factories import LicenceFactory, SubCategoryFactory, PublishableContentFactory, ContainerFactory, \
+ ExtractFactory
+from zds.tutorialv2.models.database import PublishedContent, PublishableContent
+from zds.tutorialv2.tests import TutorialTestMixin, TutorialFrontMixin
+from copy import deepcopy
+from django.conf import settings
+import os
+
+overridden_zds_app = deepcopy(settings.ZDS_APP)
+overridden_zds_app['content']['repo_private_path'] = os.path.join(settings.BASE_DIR, 'contents-private-test')
+overridden_zds_app['content']['repo_public_path'] = os.path.join(settings.BASE_DIR, 'contents-public-test')
+
+
+@override_settings(MEDIA_ROOT=os.path.join(settings.BASE_DIR, 'media-test'))
+@override_settings(ZDS_APP=overridden_zds_app)
+@override_settings(ES_ENABLED=False)
+@tag('front')
+class PublicationFronttest(StaticLiveServerTestCase, TutorialTestMixin, TutorialFrontMixin):
+ @classmethod
+ def setUpClass(cls):
+ super(PublicationFronttest, cls).setUpClass()
+ cls.selenium = WebDriver()
+ cls.selenium.implicitly_wait(10)
+
+ @classmethod
+ def tearDownClass(cls):
+ cls.selenium.quit()
+ super(PublicationFronttest, cls).tearDownClass()
+
+ def tearDown(self):
+ super().tearDown()
+ self.clean_media_dir()
+
+ def setUp(self):
+ self.overridden_zds_app = overridden_zds_app
+ # don't build PDF to speed up the tests
+ overridden_zds_app['content']['build_pdf_when_published'] = False
+
+ self.staff = StaffProfileFactory().user
+
+ settings.EMAIL_BACKEND = 'django.core.mail.backends.locmem.EmailBackend'
+ self.mas = ProfileFactory().user
+ overridden_zds_app['member']['bot_account'] = self.mas.username
+
+ self.licence = LicenceFactory()
+ self.subcategory = SubCategoryFactory()
+
+ self.user_author = ProfileFactory().user
+ self.user_staff = StaffProfileFactory().user
+ self.user_guest = ProfileFactory().user
+ self.content = PublishableContentFactory(author_list=[self.user_author], light=False)
+ self.part_published = ContainerFactory(db_object=self.content, light=False, parent=self.content.load_version())
+ self.ignored_part = ContainerFactory(db_object=self.content, light=False, parent=self.content.load_version())
+ ExtractFactory(db_object=self.content, container=self.part_published, light=False)
+ ExtractFactory(db_object=self.content, container=self.ignored_part, light=False)
+
+ def test_partial_publication(self):
+ self.login_author()
+ self.selenium.get(self.live_server_url + self.ignored_part.get_absolute_url())
+ find_element = self.selenium.find_element_by_css_selector
+ button = WebDriverWait(self.selenium, 20)\
+ .until(expected_conditions.element_to_be_clickable((By.CSS_SELECTOR, '.readiness')))
+ readiness = button.get_attribute('data-is-ready')
+ button.click()
+ self.wait_element_attribute_change((By.CSS_SELECTOR, '.readiness'), 'data-is-ready', readiness, 20)
+ self.content = PublishableContent.objects.get(pk=self.content.pk)
+ self.ignored_part = self.content.load_version().children[1]
+ self.assertFalse(self.ignored_part.ready_to_publish, 'part should be marked as not ready to publish')
+ self.selenium.get(self.live_server_url + self.content.get_absolute_url())
+ self.selenium.get(self.live_server_url + self.ignored_part.get_absolute_url())
+ button = find_element('.readiness')
+ self.assertNotEqual(readiness, button.get_attribute('data-is-ready'),
+ 'part should be marked as not ready to publish')
+ self.selenium.get(self.live_server_url + self.content.get_absolute_url())
+ self.ask_validation()
+ self.logout()
+ self.login_staff()
+ self.take_reservation()
+ self.validate()
+ url = PublishedContent.objects.get(content__pk=self.content.pk).get_absolute_url_online()
+ self.selenium.get(self.live_server_url + url)
+ self.assertRaises(WebDriverException, find_element, 'a[href="{}"]'.format(
+ reverse('tutorial:view-container', kwargs={
+ 'slug': self.content.slug,
+ 'pk': self.content.pk,
+ 'container_slug': self.ignored_part.slug
+ })))
diff --git a/zds/tutorialv2/tests/tests_utils.py b/zds/tutorialv2/tests/tests_utils.py
index c7b9d41ea7..0d811aa449 100644
--- a/zds/tutorialv2/tests/tests_utils.py
+++ b/zds/tutorialv2/tests/tests_utils.py
@@ -7,7 +7,6 @@
from django.test import TestCase
from django.test.utils import override_settings
from django.core.urlresolvers import reverse
-from pathlib import Path
from zds.member.factories import ProfileFactory, StaffProfileFactory
from zds.tutorialv2.factories import PublishableContentFactory, ContainerFactory, LicenceFactory, ExtractFactory, \
diff --git a/zds/tutorialv2/utils.py b/zds/tutorialv2/utils.py
index 42613bdae6..95a17a06f3 100644
--- a/zds/tutorialv2/utils.py
+++ b/zds/tutorialv2/utils.py
@@ -789,7 +789,7 @@ def __init__(self, reason):
class FailureDuringPublication(Exception):
- """Exception raised if something goes wrong during publication process
+ """Exception raised if something goes wrong during the publication process
"""
def __init__(self, *args, **kwargs):