From 7127cdd7da8d70c81f9b9864d21826532bad7e29 Mon Sep 17 00:00:00 2001 From: Matus Moravcik Date: Tue, 24 Jul 2018 00:17:13 +0100 Subject: [PATCH 001/147] Added ModerationCollection and new ModerationRequest (#32) --- djangocms_moderation/admin.py | 65 +---- djangocms_moderation/apps.py | 1 - djangocms_moderation/cms_toolbars.py | 232 ------------------ djangocms_moderation/emails.py | 10 +- djangocms_moderation/forms.py | 19 +- djangocms_moderation/helpers.py | 73 ++---- .../migrations/0001_initial.py | 136 ++++++---- .../migrations/0002_auto_20180420_0811.py | 32 --- .../migrations/0003_auto_20180614_1516.py | 22 -- ...pagemoderationrequestaction_is_archived.py | 20 -- .../migrations/0005_auto_20180618_2102.py | 73 ------ .../migrations/0006_auto_20180705_1034.py | 25 -- djangocms_moderation/models.py | 89 +++---- djangocms_moderation/monkeypatches.py | 60 ----- djangocms_moderation/views.py | 58 +---- tests/test_cms_toolbars.py | 157 ------------ tests/test_forms.py | 19 +- tests/test_helpers.py | 56 ----- tests/test_models.py | 57 ++--- tests/test_moderation_flows.py | 38 +-- tests/test_views.py | 50 +--- tests/utils.py | 35 ++- 22 files changed, 241 insertions(+), 1086 deletions(-) delete mode 100644 djangocms_moderation/cms_toolbars.py delete mode 100644 djangocms_moderation/migrations/0002_auto_20180420_0811.py delete mode 100644 djangocms_moderation/migrations/0003_auto_20180614_1516.py delete mode 100644 djangocms_moderation/migrations/0004_pagemoderationrequestaction_is_archived.py delete mode 100644 djangocms_moderation/migrations/0005_auto_20180618_2102.py delete mode 100644 djangocms_moderation/migrations/0006_auto_20180705_1034.py delete mode 100644 djangocms_moderation/monkeypatches.py delete mode 100644 tests/test_cms_toolbars.py diff --git a/djangocms_moderation/admin.py b/djangocms_moderation/admin.py index 775fe6cc..b0e4f490 100644 --- a/djangocms_moderation/admin.py +++ b/djangocms_moderation/admin.py @@ -1,14 +1,12 @@ from __future__ import unicode_literals from django.conf.urls import url -from django.contrib import admin, messages +from django.contrib import admin from django.core.urlresolvers import reverse -from django.http import HttpResponseRedirect from django.utils.html import format_html, format_html_join from django.utils.translation import ugettext, ugettext_lazy as _ from cms.admin.placeholderadmin import PlaceholderAdminMixin -from cms.extensions import PageExtensionAdmin from cms.models import Page from adminsortable2.admin import SortableInlineAdminMixin @@ -20,18 +18,12 @@ ACTION_RESUBMITTED, ) from .forms import WorkflowStepInlineFormSet -from .helpers import ( - get_active_moderation_request, - get_form_submission_for_step, - get_page_or_404, - is_moderation_enabled, -) +from .helpers import get_form_submission_for_step from .models import ( ConfirmationFormSubmission, ConfirmationPage, - PageModeration, - PageModerationRequest, - PageModerationRequestAction, + ModerationRequest, + ModerationRequestAction, Role, Workflow, WorkflowStep, @@ -47,13 +39,8 @@ from cms.admin.pageadmin import PageAdmin -class PageModerationAdmin(PageExtensionAdmin): - list_display = ['workflow', 'grant_on', 'enabled'] - fields = ['workflow', 'grant_on', 'enabled'] - - -class PageModerationRequestActionInline(admin.TabularInline): - model = PageModerationRequestAction +class ModerationRequestActionInline(admin.TabularInline): + model = ModerationRequestAction fields = ['show_user', 'message', 'date_taken', 'form_submission'] readonly_fields = fields verbose_name = _('Action') @@ -89,11 +76,11 @@ def form_submission(self, obj): form_submission.short_description = _('Form Submission') -class PageModerationRequestAdmin(admin.ModelAdmin): - inlines = [PageModerationRequestActionInline] - list_display = ['id', 'page', 'language', 'workflow', 'show_status', 'date_sent'] - list_filter = ['language', 'workflow', 'id', 'compliance_number'] - fields = ['id', 'workflow', 'page', 'language', 'is_active', 'show_status', 'compliance_number'] +class ModerationRequestAdmin(admin.ModelAdmin): + inlines = [ModerationRequestActionInline] + list_display = ['id', 'language', 'collection', 'show_status', 'date_sent'] + list_filter = ['language', 'collection', 'id', 'compliance_number'] + fields = ['id', 'collection', 'workflow', 'language', 'is_active', 'show_status', 'compliance_number'] readonly_fields = fields def has_add_permission(self, request): @@ -176,11 +163,6 @@ def _url(regex, fn, name, **kwargs): 'approve_request', action=ACTION_APPROVED, ), - _url( - r'^([0-9]+)/([a-z\-]+)/moderation/select-workflow/$', - views.select_new_moderation_request, - 'select_new_moderation', - ), _url( r'^([0-9]+)/([a-z\-]+)/moderation/comments/$', views.moderation_comments, @@ -189,28 +171,6 @@ def _url(regex, fn, name, **kwargs): ] return url_patterns + super(ExtendedPageAdmin, self).get_urls() - def publish_page(self, request, page_id, language): - page = get_page_or_404(page_id, language) - - if not is_moderation_enabled(page): - return super(ExtendedPageAdmin, self).publish_page(request, page_id, language) - - active_request = get_active_moderation_request(page, language) - - if active_request and active_request.is_approved(): - # The moderation request has been approved. - # Let the user publish the page. - return super(ExtendedPageAdmin, self).publish_page(request, page_id, language) - elif active_request: - message = ugettext('This page is currently undergoing moderation ' - 'and can\'t be published until all parties have approved it.') - else: - message = ugettext('You need to submit this page for moderation before publishing.') - - messages.warning(request, message) - path = page.get_absolute_url(language, fallback=True) - return HttpResponseRedirect(path) - class ConfirmationPageAdmin(PlaceholderAdminMixin, admin.ModelAdmin): view_on_site = True @@ -267,8 +227,7 @@ def form_data(self, obj): admin.site._registry[Page] = ExtendedPageAdmin(Page, admin.site) -admin.site.register(PageModeration, PageModerationAdmin) -admin.site.register(PageModerationRequest, PageModerationRequestAdmin) +admin.site.register(ModerationRequest, ModerationRequestAdmin) admin.site.register(Role, RoleAdmin) admin.site.register(Workflow, WorkflowAdmin) diff --git a/djangocms_moderation/apps.py b/djangocms_moderation/apps.py index f34a5099..1e6fedd2 100644 --- a/djangocms_moderation/apps.py +++ b/djangocms_moderation/apps.py @@ -9,6 +9,5 @@ class ModerationConfig(AppConfig): verbose_name = _('django CMS Moderation') def ready(self): - import djangocms_moderation.monkeypatches import djangocms_moderation.handlers import djangocms_moderation.signals # noqa: F401 diff --git a/djangocms_moderation/cms_toolbars.py b/djangocms_moderation/cms_toolbars.py deleted file mode 100644 index e66606a6..00000000 --- a/djangocms_moderation/cms_toolbars.py +++ /dev/null @@ -1,232 +0,0 @@ -# -*- coding: utf-8 -*- -from __future__ import unicode_literals - -from django.utils.functional import cached_property -from django.utils.translation import ugettext_lazy as _ - -from cms.api import get_page_draft -from cms.toolbar.items import ( - Button, - Dropdown, - DropdownToggleButton, - ModalButton, -) -from cms.toolbar_base import CMSToolbar -from cms.toolbar_pool import toolbar_pool -from cms.utils import page_permissions - -from .helpers import get_active_moderation_request, is_moderation_enabled -from .models import PageModeration -from .monkeypatches import set_current_language -from .utils import get_admin_url - - -from . import conf # isort:skip - - -try: - PageToolbar = toolbar_pool.toolbars['cms.cms_toolbars.PageToolbar'] -except: # noqa: F722 - from cms.cms_toolbars import PageToolbar - - -class ExtendedPageToolbar(PageToolbar): - class Media: - js = ('djangocms_moderation/js/dist/bundle.moderation.min.js',) - css = { - 'all': ('djangocms_moderation/css/moderation.css',) - } - - def __init__(self, *args, **kwargs): - super(ExtendedPageToolbar, self).__init__(*args, **kwargs) - set_current_language(self.current_lang) - - @cached_property - def moderation_request(self): - if not self.page: - return None - return get_active_moderation_request(self.page, self.current_lang) - - @cached_property - def moderation_workflow(self): - if not self.page: - return None - if self.moderation_request: - return self.moderation_request.workflow - return None - - @cached_property - def is_moderation_enabled(self): - if not self.page: - return False - return is_moderation_enabled(self.page) - - def get_cancel_moderation_button(self): - cancel_request_url = get_admin_url( - name='cms_moderation_cancel_request', - language=self.current_lang, - args=(self.page.pk, self.current_lang), - ) - return ModalButton(name=_('Cancel request'), url=cancel_request_url) - - def user_can_publish(self): - moderation_request = self.moderation_request - user = self.request.user - - if moderation_request and moderation_request.user_can_take_moderation_action(user): - return True - return super(ExtendedPageToolbar, self).user_can_publish() - - def add_publish_button(self, classes=('cms-btn-action', 'cms-btn-publish', 'cms-btn-publish-active',)): - """ - Lets work out what button should we display to the user. - We need to consider the moderation, e.g. if it is enabled, - we need to display moderation buttons instead of publish ones - """ - page = self.page - - if not self.user_can_publish() or not self.is_moderation_enabled: - # Page has no pending changes - # OR user has no permission to publish - # OR page has disabled moderation - return super(ExtendedPageToolbar, self).add_publish_button(classes) - - moderation_request = self.moderation_request - - if moderation_request and moderation_request.is_approved(): - return super(ExtendedPageToolbar, self).add_publish_button(classes) - elif moderation_request: - # We have an active moderation request ongoing. - user = self.request.user - container = Dropdown(side=self.toolbar.RIGHT) - container.add_primary_button( - DropdownToggleButton(name=_('Moderation')) - ) - - if page.is_published(self.current_lang): - container.buttons.append( - Button(name=_('View differences'), url='#', extra_classes=('js-cms-moderation-view-diff',)) - ) - - if moderation_request.user_can_resubmit(user): - # This is a content author, able to edit and resubmit the - # changes for another moderation cycle - resubmit_request_url = get_admin_url( - name='cms_moderation_resubmit_request', - language=self.current_lang, - args=(page.pk, self.current_lang), - ) - container.buttons.append( - ModalButton(name=_('Resubmit changes for moderation'), url=resubmit_request_url) - ) - elif moderation_request.user_can_take_moderation_action(user): - # Now we have a moderator, able to Approve or Reject changes - approve_request_url = get_admin_url( - name='cms_moderation_approve_request', - language=self.current_lang, - args=(page.pk, self.current_lang), - ) - container.buttons.append( - ModalButton(name=_('Approve changes'), url=approve_request_url) - ) - - reject_request_url = get_admin_url( - name='cms_moderation_reject_request', - language=self.current_lang, - args=(page.pk, self.current_lang), - ) - container.buttons.append( - ModalButton(name=_('Send for rework'), url=reject_request_url) - ) - - # Anyone should be able to cancel the moderation request - container.buttons.append(self.get_cancel_moderation_button()) - - if moderation_request.user_can_view_comments(user): - comment_url = get_admin_url( - name='cms_moderation_comments', - language=self.current_lang, - args=(page.pk, self.current_lang), - ) - container.buttons.append( - ModalButton(name=_('View comments'), url=comment_url) - ) - - self.toolbar.add_item(container) - else: - if conf.ENABLE_WORKFLOW_OVERRIDE: - view_name = 'cms_moderation_select_new_moderation' - else: - view_name = 'cms_moderation_new_request' - - new_request_url = get_admin_url( - name=view_name, - language=self.current_lang, - args=(page.pk, self.current_lang), - ) - self.toolbar.add_modal_button( - name=_('Submit for moderation'), - url=new_request_url, - side=self.toolbar.RIGHT, - ) - - def get_publish_button(self, classes=None): - if not self.is_moderation_enabled: - return super(ExtendedPageToolbar, self).get_publish_button(classes) - - button = super(ExtendedPageToolbar, self).get_publish_button(['cms-btn-publish']) - container = Dropdown(side=self.toolbar.RIGHT) - container.add_primary_button( - DropdownToggleButton(name=_('Moderation')) - ) - container.buttons.extend(button.buttons) - container.buttons.append(self.get_cancel_moderation_button()) - return container - - -class PageModerationToolbar(CMSToolbar): - - def populate(self): - """ Adds Moderation link to Page Toolbar Menu - """ - # always use draft if we have a page - page = get_page_draft(self.request.current_page) - - if page: - can_change = page_permissions.user_can_change_page(self.request.user, page) - else: - can_change = False - - if not can_change: - return - - page_menu = self.toolbar.get_menu('page') - - if not page_menu or page_menu.disabled: - return - - try: - extension = PageModeration.objects.get(extended_object_id=page.pk) - except PageModeration.DoesNotExist: - extension = None - - opts = PageModeration._meta - - url_args = [] - - if extension: - url_name = '{}_{}_{}'.format(opts.app_label, opts.model_name, 'change') - url_args.append(extension.pk) - else: - url_name = '{}_{}_{}'.format(opts.app_label, opts.model_name, 'add') - - url = get_admin_url(url_name, self.current_lang, args=url_args) - - if not extension: - url += '?extended_object=%s' % page.pk - not_edit_mode = not self.toolbar.edit_mode_active - page_menu.add_modal_item(_('Moderation'), url=url, disabled=not_edit_mode) - - -toolbar_pool.toolbars['cms.cms_toolbars.PageToolbar'] = ExtendedPageToolbar -toolbar_pool.register(PageModerationToolbar) diff --git a/djangocms_moderation/emails.py b/djangocms_moderation/emails.py index 4bdbf9bf..9c88cb55 100644 --- a/djangocms_moderation/emails.py +++ b/djangocms_moderation/emails.py @@ -31,9 +31,9 @@ def _send_email(request, action, recipients, subject, template): - page = request.page + obj = request.content_object edit_on = get_cms_setting('CMS_TOOLBAR_URL__EDIT_ON') - page_url = page.get_absolute_url(request.language) + '?' + edit_on + page_url = obj.get_absolute_url(request.language) + '?' + edit_on author_name = request.get_first_action().get_by_user_name() if action.to_user_id: @@ -43,10 +43,10 @@ def _send_email(request, action, recipients, subject, template): else: moderator_name = '' - site = page.node.site - admin_url = reverse('admin:djangocms_moderation_pagemoderationrequest_change', args=(request.pk,)) + site = obj.node.site + admin_url = reverse('admin:djangocms_moderation_moderationrequest_change', args=(request.pk,)) context = { - 'page': page, + 'page': obj, 'page_url': get_absolute_url(page_url, site), 'author_name': author_name, 'by_user_name': action.get_by_user_name(), diff --git a/djangocms_moderation/forms.py b/djangocms_moderation/forms.py index 35450311..f20f9068 100644 --- a/djangocms_moderation/forms.py +++ b/djangocms_moderation/forms.py @@ -8,8 +8,6 @@ from adminsortable2.admin import CustomInlineFormSet from .constants import ACTION_CANCELLED, ACTION_REJECTED, ACTION_RESUBMITTED -from .helpers import get_page_moderation_workflow -from .models import Workflow class WorkflowStepInlineFormSet(CustomInlineFormSet): @@ -75,7 +73,7 @@ def configure_moderator_field(self): def save(self): self.workflow.submit_new_request( - page=self.page, + obj=self.page, by_user=self.user, to_user=self.cleaned_data.get('moderator'), language=self.language, @@ -118,18 +116,3 @@ def save(self): to_user=self.cleaned_data.get('moderator'), message=self.cleaned_data['message'], ) - - -class SelectModerationForm(forms.Form): - required_css_class = 'required' - - workflow = forms.ModelChoiceField( - label=_('workflow to trigger'), - queryset=Workflow.objects.all(), - required=True, - ) - - def __init__(self, *args, **kwargs): - page = kwargs.pop('page') - super(SelectModerationForm, self).__init__(*args, **kwargs) - self.fields['workflow'].initial = get_page_moderation_workflow(page) diff --git a/djangocms_moderation/helpers.py b/djangocms_moderation/helpers.py index 7808d0d7..ecaf6855 100644 --- a/djangocms_moderation/helpers.py +++ b/djangocms_moderation/helpers.py @@ -1,16 +1,6 @@ -from django.shortcuts import get_object_or_404 +from django.contrib.contenttypes.models import ContentType -from cms.models import Page - -from .models import ( - ConfirmationFormSubmission, - PageModeration, - PageModerationRequest, - Workflow, -) - - -from . import conf # isort:skip +from .models import ConfirmationFormSubmission, ModerationRequest, Workflow def get_default_workflow(): @@ -21,62 +11,39 @@ def get_default_workflow(): return workflow -def get_page_moderation_settings(page): - moderation = PageModeration.objects.for_page(page) - return moderation - - -def get_page_moderation_workflow(page): - moderation = get_page_moderation_settings(page) - - if moderation: - workflow = moderation.workflow - else: - workflow = get_default_workflow() - return workflow - - -def get_workflow_or_none(pk): - try: - return Workflow.objects.get(pk=pk) - except Workflow.DoesNotExist: - return None +def get_moderation_workflow(): + # TODO for now just return default, this would need to depend on the collection + # Might be as well not needed in 4.x, leaving it here for now + return get_default_workflow() -def get_active_moderation_request(page, language): +def get_active_moderation_request(obj, language): + content_type = ContentType.objects.get_for_model(obj) try: - return PageModerationRequest.objects.get( - page=page, + return ModerationRequest.objects.get( + content_type=content_type, + object_id=obj.pk, language=language, is_active=True, ) - except PageModerationRequest.DoesNotExist: + except ModerationRequest.DoesNotExist: return None -def get_page_or_404(page_id, language): - return get_object_or_404( - Page, - pk=page_id, +def get_page_or_404(obj_id, language): + """ + TODO is this needed in 4.x? + """ + content_type = ContentType.objects.get(app_label="cms", model="page") # how do we get this + + return content_type.get_object_for_this_type( + pk=obj_id, is_page_type=False, publisher_is_draft=True, title_set__language=language, ) -def is_moderation_enabled(page): - page_moderation_extension = get_page_moderation_settings(page) - - try: - is_enabled = page_moderation_extension.enabled - except AttributeError: - is_enabled = True - - if conf.ENABLE_WORKFLOW_OVERRIDE: - return is_enabled and Workflow.objects.exists() - return is_enabled and bool(get_page_moderation_workflow(page)) - - def get_form_submission_for_step(active_request, current_step): lookup = ( ConfirmationFormSubmission diff --git a/djangocms_moderation/migrations/0001_initial.py b/djangocms_moderation/migrations/0001_initial.py index ac5a6225..d8cde7ef 100644 --- a/djangocms_moderation/migrations/0001_initial.py +++ b/djangocms_moderation/migrations/0001_initial.py @@ -1,39 +1,74 @@ # -*- coding: utf-8 -*- +# Generated by Django 1.11.13 on 2018-07-17 15:49 from __future__ import unicode_literals -from django.db import migrations, models +import cms.models.fields from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + +from djangocms_moderation import conf +from djangocms_moderation.constants import ACTION_CHOICES class Migration(migrations.Migration): + initial = True + dependencies = [ - ('cms', '0016_auto_20160608_1535'), - ('auth', '0006_require_contenttypes_0002'), + ('auth', '0008_alter_user_username_max_length'), + ('cms', '0020_old_tree_cleanup'), migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('contenttypes', '0002_remove_content_type_name'), ] operations = [ migrations.CreateModel( - name='PageModeration', + name='ConfirmationFormSubmission', fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('grant_on', models.IntegerField(default=5, verbose_name='grant on', choices=[(1, 'Current page'), (2, 'Page children (immediate)'), (3, 'Page and children (immediate)'), (4, 'Page descendants'), (5, 'Page and descendants')])), - ('extended_object', models.OneToOneField(editable=False, to='cms.Page')), - ('public_extension', models.OneToOneField(related_name='draft_extension', null=True, editable=False, to='djangocms_moderation.PageModeration')), + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('data', models.TextField(blank=True, editable=False)), + ('submitted_at', models.DateTimeField(auto_now_add=True)), + ('by_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='by user')), ], options={ - 'abstract': False, + 'verbose_name': 'Confirmation Form Submission', + 'verbose_name_plural': 'Confirmation Form Submissions', }, ), migrations.CreateModel( - name='PageModerationRequest', + name='ConfirmationPage', fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('language', models.CharField(max_length=5, verbose_name='language', choices=settings.LANGUAGES)), - ('is_active', models.BooleanField(default=False, db_index=True)), + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50, verbose_name='name')), + ('content_type', models.CharField(choices=[('plain', 'Plain'), ('form', 'Form')], default='form', max_length=50, verbose_name='Content Type')), + ('template', models.CharField(choices=conf.CONFIRMATION_PAGE_TEMPLATES, default=conf.DEFAULT_CONFIRMATION_PAGE_TEMPLATE, max_length=100, verbose_name='Template')), + ('content', cms.models.fields.PlaceholderField(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, slotname='confirmation_content', to='cms.Placeholder')), + ], + options={ + 'verbose_name': 'Confirmation Page', + 'verbose_name_plural': 'Confirmation Pages', + }, + ), + migrations.CreateModel( + name='ModerationCollection', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=128, verbose_name='name')), + ('is_locked', models.BooleanField(default=False, verbose_name='is locked')), + ], + ), + migrations.CreateModel( + name='ModerationRequest', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('object_id', models.PositiveIntegerField()), + ('language', models.CharField(choices=settings.LANGUAGES, max_length=5, verbose_name='language')), + ('is_active', models.BooleanField(db_index=True, default=False)), ('date_sent', models.DateTimeField(auto_now_add=True, verbose_name='date sent')), - ('page', models.ForeignKey(verbose_name='page', to='cms.Page')), + ('compliance_number', models.CharField(blank=True, editable=False, max_length=32, null=True, unique=True, verbose_name='compliance number')), + ('collection', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='moderation_requests', to='djangocms_moderation.ModerationCollection')), + ('content_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType')), ], options={ 'verbose_name': 'Request', @@ -41,28 +76,30 @@ class Migration(migrations.Migration): }, ), migrations.CreateModel( - name='PageModerationRequestAction', + name='ModerationRequestAction', fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('action', models.CharField(max_length=30, verbose_name='status', choices=[('start', 'Started'), ('rejected', 'Rejected'), ('approved', 'Approved'), ('cancelled', 'Cancelled'), ('finished', 'Finished')])), - ('message', models.TextField(verbose_name='message', blank=True)), + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('action', models.CharField(choices=ACTION_CHOICES, max_length=30, verbose_name='status')), + ('message', models.TextField(blank=True, verbose_name='message')), ('date_taken', models.DateTimeField(auto_now_add=True, verbose_name='date taken')), - ('by_user', models.ForeignKey(related_name='+', verbose_name='by user', to=settings.AUTH_USER_MODEL)), - ('request', models.ForeignKey(related_name='actions', verbose_name='request', to='djangocms_moderation.PageModerationRequest')), + ('is_archived', models.BooleanField(default=False)), + ('by_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='by user')), + ('request', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='actions', to='djangocms_moderation.ModerationRequest', verbose_name='request')), ], options={ - 'ordering': ('date_taken',), 'verbose_name': 'Action', 'verbose_name_plural': 'Actions', + 'ordering': ('date_taken',), }, ), migrations.CreateModel( name='Role', fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('name', models.CharField(max_length=120, verbose_name='name')), - ('group', models.ForeignKey(verbose_name='group', blank=True, to='auth.Group', null=True)), - ('user', models.ForeignKey(verbose_name='user', blank=True, to=settings.AUTH_USER_MODEL, null=True)), + ('confirmation_page', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='djangocms_moderation.ConfirmationPage', verbose_name='confirmation page')), + ('group', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='auth.Group', verbose_name='group')), + ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='user')), ], options={ 'verbose_name': 'Role', @@ -72,9 +109,12 @@ class Migration(migrations.Migration): migrations.CreateModel( name='Workflow', fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), - ('name', models.CharField(unique=True, max_length=120, verbose_name='name')), + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=120, unique=True, verbose_name='name')), ('is_default', models.BooleanField(default=False, verbose_name='is default')), + ('identifier', models.CharField(blank=True, default='', help_text="Identifier is a 'free' field you could use for internal purposes. For example, it could be used as a workflow specific prefix of a compliance number", max_length=128, verbose_name='identifier')), + ('requires_compliance_number', models.BooleanField(default=False, help_text='Does the Compliance number need to be generated before the moderation request is approved? Please select the compliance number backend below', verbose_name='requires compliance number?')), + ('compliance_number_backend', models.CharField(choices=conf.COMPLIANCE_NUMBER_BACKENDS, default=conf.DEFAULT_COMPLIANCE_NUMBER_BACKEND, max_length=255, verbose_name='compliance number backend')), ], options={ 'verbose_name': 'Workflow', @@ -84,45 +124,59 @@ class Migration(migrations.Migration): migrations.CreateModel( name='WorkflowStep', fields=[ - ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('is_required', models.BooleanField(default=True, verbose_name='is mandatory')), ('order', models.PositiveIntegerField()), - ('role', models.ForeignKey(related_name='+', verbose_name='role', to='djangocms_moderation.Role')), - ('workflow', models.ForeignKey(related_name='steps', verbose_name='workflow', to='djangocms_moderation.Workflow')), + ('role', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='djangocms_moderation.Role', verbose_name='role')), + ('workflow', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='steps', to='djangocms_moderation.Workflow', verbose_name='workflow')), ], options={ - 'ordering': ('order',), 'verbose_name': 'Step', 'verbose_name_plural': 'Steps', + 'ordering': ('order',), }, ), migrations.AddField( - model_name='pagemoderationrequestaction', + model_name='moderationrequestaction', name='step_approved', - field=models.ForeignKey(verbose_name='step approved', blank=True, to='djangocms_moderation.WorkflowStep', null=True), + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='djangocms_moderation.WorkflowStep', verbose_name='step approved'), ), migrations.AddField( - model_name='pagemoderationrequestaction', + model_name='moderationrequestaction', name='to_role', - field=models.ForeignKey(related_name='+', verbose_name='to role', blank=True, to='djangocms_moderation.Role', null=True), + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to='djangocms_moderation.Role', verbose_name='to role'), ), migrations.AddField( - model_name='pagemoderationrequestaction', + model_name='moderationrequestaction', name='to_user', - field=models.ForeignKey(related_name='+', verbose_name='to user', blank=True, to=settings.AUTH_USER_MODEL, null=True), + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='to user'), ), migrations.AddField( - model_name='pagemoderationrequest', + model_name='moderationcollection', name='workflow', - field=models.ForeignKey(related_name='requests', verbose_name='workflow', to='djangocms_moderation.Workflow'), + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='moderation_collections', to='djangocms_moderation.Workflow', verbose_name='workflow'), ), migrations.AddField( - model_name='pagemoderation', - name='workflow', - field=models.ForeignKey(related_name='+', verbose_name='workflow', to='djangocms_moderation.Workflow'), + model_name='confirmationformsubmission', + name='confirmation_page', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='djangocms_moderation.ConfirmationPage', verbose_name='confirmation page'), + ), + migrations.AddField( + model_name='confirmationformsubmission', + name='for_step', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='djangocms_moderation.WorkflowStep', verbose_name='for step'), + ), + migrations.AddField( + model_name='confirmationformsubmission', + name='request', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='form_submissions', to='djangocms_moderation.ModerationRequest', verbose_name='request'), ), migrations.AlterUniqueTogether( name='workflowstep', unique_together=set([('role', 'workflow')]), ), + migrations.AlterUniqueTogether( + name='confirmationformsubmission', + unique_together=set([('request', 'for_step')]), + ), ] diff --git a/djangocms_moderation/migrations/0002_auto_20180420_0811.py b/djangocms_moderation/migrations/0002_auto_20180420_0811.py deleted file mode 100644 index 2f56e79f..00000000 --- a/djangocms_moderation/migrations/0002_auto_20180420_0811.py +++ /dev/null @@ -1,32 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11.11 on 2018-04-20 07:11 -from __future__ import unicode_literals - -from django.db import migrations, models - -from djangocms_moderation import conf - - -class Migration(migrations.Migration): - - dependencies = [ - ('djangocms_moderation', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='pagemoderation', - name='enabled', - field=models.BooleanField(default=True, verbose_name='enable moderation for page'), - ), - migrations.AddField( - model_name='pagemoderationrequest', - name='compliance_number', - field=models.CharField(blank=True, max_length=32, null=True, unique=True, editable=False), - ), - migrations.AddField( - model_name='workflow', - name='compliance_number_backend', - field=models.CharField(choices=conf.COMPLIANCE_NUMBER_BACKENDS, default=conf.DEFAULT_COMPLIANCE_NUMBER_BACKEND, max_length=255), - ), - ] diff --git a/djangocms_moderation/migrations/0003_auto_20180614_1516.py b/djangocms_moderation/migrations/0003_auto_20180614_1516.py deleted file mode 100644 index 4872493c..00000000 --- a/djangocms_moderation/migrations/0003_auto_20180614_1516.py +++ /dev/null @@ -1,22 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11.13 on 2018-06-14 14:16 -from __future__ import unicode_literals - -from django.db import migrations, models - -from djangocms_moderation.constants import ACTION_CHOICES - - -class Migration(migrations.Migration): - - dependencies = [ - ('djangocms_moderation', '0002_auto_20180420_0811'), - ] - - operations = [ - migrations.AlterField( - model_name='pagemoderationrequestaction', - name='action', - field=models.CharField(choices=ACTION_CHOICES, max_length=30, verbose_name='status'), - ), - ] diff --git a/djangocms_moderation/migrations/0004_pagemoderationrequestaction_is_archived.py b/djangocms_moderation/migrations/0004_pagemoderationrequestaction_is_archived.py deleted file mode 100644 index ee3560c1..00000000 --- a/djangocms_moderation/migrations/0004_pagemoderationrequestaction_is_archived.py +++ /dev/null @@ -1,20 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11.13 on 2018-06-14 14:19 -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('djangocms_moderation', '0003_auto_20180614_1516'), - ] - - operations = [ - migrations.AddField( - model_name='pagemoderationrequestaction', - name='is_archived', - field=models.BooleanField(default=False), - ), - ] diff --git a/djangocms_moderation/migrations/0005_auto_20180618_2102.py b/djangocms_moderation/migrations/0005_auto_20180618_2102.py deleted file mode 100644 index 4abd7c7b..00000000 --- a/djangocms_moderation/migrations/0005_auto_20180618_2102.py +++ /dev/null @@ -1,73 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11.12 on 2018-06-18 20:02 -from __future__ import unicode_literals - -import cms.models.fields -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - -from djangocms_moderation import conf - - -class Migration(migrations.Migration): - - dependencies = [ - ('cms', '0020_old_tree_cleanup'), - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('djangocms_moderation', '0004_pagemoderationrequestaction_is_archived'), - ] - - operations = [ - migrations.CreateModel( - name='ConfirmationFormSubmission', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('data', models.TextField(blank=True, editable=False)), - ('submitted_at', models.DateTimeField(auto_now_add=True)), - ('by_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='by user')), - ], - options={ - 'verbose_name': 'Confirmation Form Submission', - 'verbose_name_plural': 'Confirmation Form Submissions', - }, - ), - migrations.CreateModel( - name='ConfirmationPage', - fields=[ - ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=50, verbose_name='name')), - ('content_type', models.CharField(choices=[('plain', 'Plain'), ('form', 'Form')], default='form', max_length=50, verbose_name='Content Type')), - ('template', models.CharField(choices=conf.CONFIRMATION_PAGE_TEMPLATES, default=conf.DEFAULT_CONFIRMATION_PAGE_TEMPLATE, max_length=100, verbose_name='Template')), - ('content', cms.models.fields.PlaceholderField(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, slotname='confirmation_content', to='cms.Placeholder')), - ], - options={ - 'verbose_name': 'Confirmation Page', - 'verbose_name_plural': 'Confirmation Pages', - }, - ), - migrations.AddField( - model_name='confirmationformsubmission', - name='confirmation_page', - field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='+', to='djangocms_moderation.ConfirmationPage', verbose_name='confirmation page'), - ), - migrations.AddField( - model_name='confirmationformsubmission', - name='for_step', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to='djangocms_moderation.WorkflowStep', verbose_name='for step'), - ), - migrations.AddField( - model_name='confirmationformsubmission', - name='request', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='form_submissions', to='djangocms_moderation.PageModerationRequest', verbose_name='request'), - ), - migrations.AddField( - model_name='role', - name='confirmation_page', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='djangocms_moderation.ConfirmationPage', verbose_name='confirmation page'), - ), - migrations.AlterUniqueTogether( - name='confirmationformsubmission', - unique_together=set([('request', 'for_step')]), - ), - ] diff --git a/djangocms_moderation/migrations/0006_auto_20180705_1034.py b/djangocms_moderation/migrations/0006_auto_20180705_1034.py deleted file mode 100644 index ae8fb816..00000000 --- a/djangocms_moderation/migrations/0006_auto_20180705_1034.py +++ /dev/null @@ -1,25 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11.13 on 2018-07-05 09:34 -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('djangocms_moderation', '0005_auto_20180618_2102'), - ] - - operations = [ - migrations.AddField( - model_name='workflow', - name='identifier', - field=models.CharField(blank=True, default='', help_text="Identifier is a 'free' field you could use for internal purposes. For example, it could be used as a workflow specific prefix of a compliance number", max_length=128), - ), - migrations.AddField( - model_name='workflow', - name='requires_compliance_number', - field=models.BooleanField(default=False, help_text='Does the Compliance number need to be generated before the moderation request is approved? Please select the compliance number backend below'), - ), - ] diff --git a/djangocms_moderation/models.py b/djangocms_moderation/models.py index 82f3e2db..5731a619 100644 --- a/djangocms_moderation/models.py +++ b/djangocms_moderation/models.py @@ -5,6 +5,8 @@ from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.auth.models import Group +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.core.urlresolvers import reverse from django.db import models, transaction @@ -12,12 +14,9 @@ from django.utils.functional import cached_property from django.utils.translation import ugettext, ugettext_lazy as _ -from cms.extensions import PageExtension -from cms.extensions.extension_pool import extension_pool from cms.models.fields import PlaceholderField from .emails import notify_request_author, notify_requested_moderator -from .managers import PageModerationManager from .utils import generate_compliance_number @@ -192,7 +191,7 @@ def get_active_request(self, page, language): try: active_request = lookup.get() - except PageModerationRequest.DoesNotExist: + except ModerationRequest.DoesNotExist: active_request = None return active_request @@ -201,9 +200,9 @@ def has_active_request(self, page, language): return lookup.exists() @transaction.atomic - def submit_new_request(self, by_user, page, language, message='', to_user=None): + def submit_new_request(self, by_user, obj, language, message='', to_user=None): request = self.requests.create( - page=page, + content_object=obj, language=language, is_active=True, workflow=self, @@ -266,64 +265,35 @@ def get_next_required(self): return self.get_next(cache=False, is_required=True) -@python_2_unicode_compatible -class PageModeration(PageExtension): - ACCESS_CHOICES = ( - (constants.ACCESS_PAGE, _('Current page')), - (constants.ACCESS_CHILDREN, _('Page children (immediate)')), - (constants.ACCESS_PAGE_AND_CHILDREN, _('Page and children (immediate)')), - (constants.ACCESS_DESCENDANTS, _('Page descendants')), - (constants.ACCESS_PAGE_AND_DESCENDANTS, _('Page and descendants')), - ) - +class ModerationCollection(models.Model): + name = models.CharField(verbose_name=_('name'), max_length=128) workflow = models.ForeignKey( to=Workflow, verbose_name=_('workflow'), - related_name='+', - ) - grant_on = models.IntegerField( - verbose_name=_('grant on'), - choices=ACCESS_CHOICES, - default=constants.ACCESS_PAGE_AND_DESCENDANTS, + related_name='moderation_collections', ) - enabled = models.BooleanField( - verbose_name=_('enable moderation for page'), - default=True, - ) - - objects = PageModerationManager() - - def __str__(self): - return self.extended_object.get_page_title() - - @cached_property - def page(self): - return self.get_page() - - def copy_relations(self, oldinstance, language): - self.workflow_id = oldinstance.workflow_id + # TODO: proper implementations and handlers coming later for is_locked + is_locked = models.BooleanField(verbose_name=_('is locked'), default=False) @python_2_unicode_compatible -class PageModerationRequest(models.Model): - page = models.ForeignKey( - to='cms.Page', - verbose_name=_('page'), - limit_choices_to={ - 'is_page_type': False, - 'publisher_is_draft': True, - }, +class ModerationRequest(models.Model): + collection = models.ForeignKey( + to=ModerationCollection, + related_name='moderation_requests', + on_delete=models.CASCADE + ) + content_type = models.ForeignKey( + ContentType, + on_delete=models.CASCADE, ) + object_id = models.PositiveIntegerField() + content_object = GenericForeignKey('content_type', 'object_id') language = models.CharField( verbose_name=_('language'), max_length=5, choices=settings.LANGUAGES, ) - workflow = models.ForeignKey( - to=Workflow, - verbose_name=_('workflow'), - related_name='requests', - ) is_active = models.BooleanField( default=False, db_index=True, @@ -348,7 +318,7 @@ class Meta: def __str__(self): return "{} {}".format( self.pk, - self.page.get_page_title(self.language) + self.content_object.pk ) @cached_property @@ -358,6 +328,10 @@ def author(self): """ return self.get_first_action().by_user + @cached_property + def workflow(self): + return self.collection.workflow + def has_pending_step(self): return self.get_pending_steps().exists() @@ -505,7 +479,7 @@ def set_compliance_number(self): @python_2_unicode_compatible -class PageModerationRequestAction(models.Model): +class ModerationRequestAction(models.Model): action = models.CharField( verbose_name=_('status'), max_length=30, @@ -549,7 +523,7 @@ class PageModerationRequestAction(models.Model): blank=True, ) request = models.ForeignKey( - to=PageModerationRequest, + to=ModerationRequest, verbose_name=_('request'), related_name='actions', ) @@ -603,12 +577,12 @@ def save(self, **kwargs): if next_step: self.to_role_id = next_step.role_id - super(PageModerationRequestAction, self).save(**kwargs) + super(ModerationRequestAction, self).save(**kwargs) class ConfirmationFormSubmission(models.Model): request = models.ForeignKey( - to=PageModerationRequest, + to=ModerationRequest, verbose_name=_('request'), related_name='form_submissions', on_delete=models.CASCADE, @@ -651,6 +625,3 @@ def get_by_user_name(self): def get_form_data(self): return json.loads(self.data) - - -extension_pool.register(PageModeration) diff --git a/djangocms_moderation/monkeypatches.py b/djangocms_moderation/monkeypatches.py deleted file mode 100644 index 4f03f6b7..00000000 --- a/djangocms_moderation/monkeypatches.py +++ /dev/null @@ -1,60 +0,0 @@ -from functools import wraps -from threading import local - -from django.utils.decorators import available_attrs -from django.utils.translation import get_language - -from cms.utils import page_permissions - -from .helpers import get_active_moderation_request - - -# thread local support -_thread_locals = local() - - -def set_current_language(user): - _thread_locals.request_language = user - - -def get_request_language(): - return getattr(_thread_locals, 'request_language', get_language()) - - -def user_can_change_page(func): - @wraps(func, assigned=available_attrs(func)) - def wrapper(user, page, site=None): - can_change = func(user, page, site=site) - - if can_change: - active_request = get_active_moderation_request(page, get_request_language()) - - # If there is an active moderation request, the only user - # who can edit the content is the original content author after - # his moderation request has been rejected by any of the moderators. - # The reason for this that content author should be able to - # resubmit the changes for another review as a part of the same - # moderation request. - if active_request and not active_request.user_can_resubmit(user): - return False - return can_change - return wrapper - - -def user_can_view_page_draft(func): - @wraps(func, assigned=available_attrs(func)) - def wrapper(user, page, site=None): - can_view_page_draft = func(user, page, site=site) - - # get active request for page - active_request = get_active_moderation_request(page, get_request_language()) - - # check if user is part of the active request, if yes, return True - if active_request and active_request.user_can_moderate(user): - return True - return can_view_page_draft - return wrapper - - -page_permissions.user_can_change_page = user_can_change_page(page_permissions.user_can_change_page) -page_permissions.user_can_view_page_draft = user_can_view_page_draft(page_permissions.user_can_view_page_draft) diff --git a/djangocms_moderation/views.py b/djangocms_moderation/views.py index 967ed1ed..92568a9a 100644 --- a/djangocms_moderation/views.py +++ b/djangocms_moderation/views.py @@ -13,21 +13,16 @@ from cms.utils.urlutils import add_url_parameters -from .forms import ( - ModerationRequestForm, - SelectModerationForm, - UpdateModerationRequestForm, -) +from .forms import ModerationRequestForm, UpdateModerationRequestForm from .helpers import ( get_active_moderation_request, - get_page_moderation_workflow, + get_moderation_workflow, get_page_or_404, - get_workflow_or_none, ) from .models import ( ConfirmationFormSubmission, ConfirmationPage, - PageModerationRequest, + ModerationRequest, ) from .utils import get_admin_url @@ -93,10 +88,8 @@ def dispatch(self, request, *args, **kwargs): elif self.action != constants.ACTION_STARTED: # All except for the new request endpoint require an active moderation request return HttpResponseBadRequest('Page does not have an active moderation request.') - elif request.GET.get('workflow'): - self.workflow = get_workflow_or_none(request.GET.get('workflow')) else: - self.workflow = get_page_moderation_workflow(self.page) + self.workflow = get_moderation_workflow() if not self.workflow: return HttpResponseBadRequest('No moderation workflow exists for page.') @@ -119,7 +112,7 @@ def get_form_kwargs(self): return kwargs def get_context_data(self, **kwargs): - opts = PageModerationRequest._meta + opts = ModerationRequest._meta form_submission_opts = ConfirmationFormSubmission._meta if self.active_request: @@ -178,47 +171,6 @@ def get_context_data(self, **kwargs): ) -class SelectModerationView(FormView): - - form_class = SelectModerationForm - template_name = 'djangocms_moderation/select_workflow_form.html' - - def dispatch(self, request, *args, **kwargs): - self.page_id = args[0] - self.current_lang = args[1] - return super(SelectModerationView, self).dispatch(request, *args, **kwargs) - - def get_context_data(self, **kwargs): - context = super(SelectModerationView, self).get_context_data(**kwargs) - context.update({ - 'has_change_permission': True, - 'root_path': reverse('admin:index'), - 'adminform': context['form'], - 'is_popup': True, - }) - return context - - def get_form_kwargs(self): - kwargs = super(SelectModerationView, self).get_form_kwargs() - kwargs['page'] = get_page_or_404(self.page_id, self.current_lang) - return kwargs - - def form_valid(self, form): - selected_workflow = form.cleaned_data['workflow'] - redirect_url = add_url_parameters( - get_admin_url( - name='cms_moderation_new_request', - language=self.current_lang, - args=(self.page_id, self.current_lang), - ), - workflow=selected_workflow.pk - ) - return HttpResponseRedirect(redirect_url) - - -select_new_moderation_request = SelectModerationView.as_view() - - class ModerationCommentsView(ListView): template_name = 'djangocms_moderation/comment_list.html' diff --git a/tests/test_cms_toolbars.py b/tests/test_cms_toolbars.py deleted file mode 100644 index 3ba13db8..00000000 --- a/tests/test_cms_toolbars.py +++ /dev/null @@ -1,157 +0,0 @@ -from mock import patch - -from django.test.client import RequestFactory -from django.utils.encoding import force_text - -from cms.api import create_page, publish_page -from cms.constants import PUBLISHER_STATE_DIRTY -from cms.middleware.toolbar import ToolbarMiddleware -from cms.toolbar.items import ButtonList, Dropdown, ModalItem -from cms.utils.conf import get_cms_setting - -from djangocms_moderation.models import PageModeration -from djangocms_moderation.utils import get_admin_url - -from .utils import BaseViewTestCase - - -class BaseToolbarTest(BaseViewTestCase): - - def setup_toolbar(self, page, user, is_edit_mode=True): - page.set_publisher_state('en', state=PUBLISHER_STATE_DIRTY) # make page dirty - - if is_edit_mode: - edit_mode = get_cms_setting('CMS_TOOLBAR_URL__EDIT_ON') - else: - edit_mode = get_cms_setting('CMS_TOOLBAR_URL__EDIT_OFF') - - request = RequestFactory().get('{}?{}'.format(page.get_absolute_url('en'), edit_mode)) - request.current_page = page - request.user = user - request.session = self.client.session - ToolbarMiddleware().process_request(request) - self.toolbar = request.toolbar - self.toolbar.populate() - self.toolbar.post_template_populate() - self.toolbar_left_items = self.toolbar.get_left_items() - self.toolbar_right_items = self.toolbar.get_right_items() - - -class ExtendedPageToolbarTest(BaseToolbarTest): - - def test_show_publish_button_if_moderation_disabled_for_page(self): - self.wf1.is_default = False - self.wf1.save() # making all workflows not default, therefore by design moderation is disabled - new_page = create_page(title='New Page', template='page.html', language='en', published=True,) - self.setup_toolbar(new_page, self.user) - buttons = sum([item.buttons for item in self.toolbar_right_items if isinstance(item, ButtonList)], []) - self.assertTrue([button for button in buttons if force_text(button.name) == 'Publish page changes']) - - def test_show_resubmit_button_if_moderation_request_is_rejected(self): - self.setup_toolbar(self.pg5, self.user) - buttons = sum([item.buttons for item in self.toolbar_right_items if isinstance(item, Dropdown)], []) - self.assertEqual(len(buttons), 4) - self.assertEqual(force_text(buttons[0].name), 'View differences') - self.assertEqual(force_text(buttons[1].name), 'Resubmit changes for moderation') - self.assertEqual(force_text(buttons[2].name), 'Cancel request') - self.assertEqual(force_text(buttons[3].name), 'View comments') - - def test_show_moderation_dropdown_if_moderation_request_non_published_page(self): - self.setup_toolbar(self.pg1, self.user) - buttons = sum([item.buttons for item in self.toolbar_right_items if isinstance(item, Dropdown)], []) - self.assertEqual(len(buttons), 4) - # View difference button is not present - self.assertEqual(force_text(buttons[0].name), 'Approve changes') - self.assertEqual(force_text(buttons[1].name), 'Send for rework') - self.assertEqual(force_text(buttons[2].name), 'Cancel request') - self.assertEqual(force_text(buttons[3].name), 'View comments') - - def test_show_moderation_dropdown_if_moderation_request_previously_published_page(self): - self.setup_toolbar(self.pg5, self.user) - buttons = sum([item.buttons for item in self.toolbar_right_items if isinstance(item, Dropdown)], []) - self.assertEqual(len(buttons), 4) - # View difference button is present - self.assertEqual(force_text(buttons[0].name), 'View differences') - self.assertEqual(force_text(buttons[1].name), 'Resubmit changes for moderation') - self.assertEqual(force_text(buttons[2].name), 'Cancel request') - self.assertEqual(force_text(buttons[3].name), 'View comments') - - def test_show_moderation_dropdown_with_no_actions_for_non_role_user(self): - self.setup_toolbar(self.pg1, self.user3) - buttons = sum([item.buttons for item in self.toolbar_right_items if isinstance(item, Dropdown)], []) - self.assertEqual(len(buttons), 2) - # `self.pg1` has never been published so we don't show View diff button - self.assertEqual(force_text(buttons[0].name), 'Cancel request') - self.assertEqual(force_text(buttons[1].name), 'View comments') - - def test_show_submit_for_moderation_button_if_page_is_dirty(self): - new_page = create_page(title='New Page', template='page.html', language='en', published=True,) - self.setup_toolbar(new_page, self.user) - buttons = sum([item.buttons for item in self.toolbar_right_items if isinstance(item, ButtonList)], []) - self.assertTrue([button for button in buttons if force_text(button.name) == 'Submit for moderation']) - - def test_submit_for_moderation_button_with_default_settings(self): - new_page = create_page(title='New Page', template='page.html', language='en', published=True,) - self.setup_toolbar(new_page, self.user) - buttons = sum([item.buttons for item in self.toolbar_right_items if isinstance(item, ButtonList)], []) - submit_for_moderation_button = [ - button for button in buttons if force_text(button.name) == 'Submit for moderation' - ][0] - url = get_admin_url( - name='cms_moderation_new_request', - language='en', - args=(new_page.pk, 'en'), - ) - self.assertEqual(submit_for_moderation_button.url, url) - - @patch('djangocms_moderation.conf.ENABLE_WORKFLOW_OVERRIDE', True) - def test_submit_for_moderation_button_with_override_settings(self): - new_page = create_page(title='New Page', template='page.html', language='en', published=True,) - self.setup_toolbar(new_page, self.user) - buttons = sum([item.buttons for item in self.toolbar_right_items if isinstance(item, ButtonList)], []) - submit_for_moderation_button = [ - button for button in buttons if force_text(button.name) == 'Submit for moderation' - ][0] - url = get_admin_url( - name='cms_moderation_select_new_moderation', - language='en', - args=(new_page.pk, 'en'), - ) - self.assertEqual(submit_for_moderation_button.url, url) - - def test_publish_button_after_moderation_request_approved(self): - self.setup_toolbar(self.pg3, self.user) # pg3 => moderation request is approved - publish_page(page=self.pg3, user=self.user, language="en") - buttons = sum([item.buttons for item in self.toolbar_right_items if isinstance(item, Dropdown)], []) - self.assertEqual(len(buttons), 2) - self.assertEqual(force_text(buttons[0].name), 'Publish page changes') - self.assertEqual(force_text(buttons[1].name), 'Cancel request') - - -class PageModerationToolbarTest(BaseToolbarTest): - - def test_moderation_menu_add_rendered(self): - new_page = create_page(title='New Page', template='page.html', language='en',) - self.setup_toolbar(new_page, self.user) - page_menu = self.toolbar.menus['page'] - opts = PageModeration._meta - url = get_admin_url( - name='{}_{}_{}'.format(opts.app_label, opts.model_name, 'add'), - language='en', - args=[], - ) - url += '?extended_object=%s' % new_page.pk - self.assertEqual(len(page_menu.find_items(ModalItem, url=url)), 1) - - def test_moderation_menu_change_rendered(self): - new_page = create_page(title='New Page', template='page.html', language='en',) - extension = PageModeration.objects.create(extended_object=new_page, enabled=True, workflow=self.wf1,) - self.setup_toolbar(new_page, self.user) - page_menu = self.toolbar.menus['page'] - opts = PageModeration._meta - url = get_admin_url( - name='{}_{}_{}'.format(opts.app_label, opts.model_name, 'change'), - language='en', - args=[extension.pk], - ) - self.assertEqual(len(page_menu.find_items(ModalItem, url=url)), 1) diff --git a/tests/test_forms.py b/tests/test_forms.py index 5be2d86e..90f9ecdd 100644 --- a/tests/test_forms.py +++ b/tests/test_forms.py @@ -6,10 +6,8 @@ from djangocms_moderation import constants from djangocms_moderation.forms import ( ModerationRequestForm, - SelectModerationForm, UpdateModerationRequestForm, ) -from djangocms_moderation.models import Workflow from .utils import BaseTestCase @@ -48,7 +46,7 @@ def test_form_save(self): self.assertTrue(form.is_valid()) form.save() form.workflow.submit_new_request.assert_called_once_with( - page=self.pg2, + obj=self.pg2, by_user=self.user, to_user=None, language='en', @@ -126,18 +124,3 @@ def test_form_save(self): to_user=None, message='Approved message', ) - - -class SelectModerationFormTest(BaseTestCase): - - def test_form_init(self): - form = SelectModerationForm(page=self.pg1,) - self.assertIn('workflow', form.fields) - field_workflow = form.fields['workflow'] - self.assertQuerysetEqual( - field_workflow.queryset, - Workflow.objects.all(), - transform=lambda x: x, - ordered=False, - ) - self.assertEqual(field_workflow.initial, self.wf1) diff --git a/tests/test_helpers.py b/tests/test_helpers.py index a3b35f59..d78534f3 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -1,37 +1,18 @@ import json -from mock import patch - -from django.test import override_settings from djangocms_moderation.helpers import ( get_active_moderation_request, get_form_submission_for_step, get_page_or_404, - get_workflow_or_none, - is_moderation_enabled, ) from djangocms_moderation.models import ( ConfirmationFormSubmission, ConfirmationPage, - PageModeration, - Workflow, ) from .utils import BaseTestCase -class GetWorkflowOrNoneTest(BaseTestCase): - - def test_existing_workflow(self): - workflow = Workflow.objects.get(pk=1) - self.assertEqual(get_workflow_or_none(1), workflow) - workflow = Workflow.objects.get(pk=2) - self.assertEqual(get_workflow_or_none(2), workflow) - - def test_non_existing_workflow(self): - self.assertIsNone(get_workflow_or_none(10)) - - class GetCurrentModerationRequestTest(BaseTestCase): def test_existing_moderation_request(self): @@ -49,43 +30,6 @@ def test_returns_page(self): self.assertEqual(get_page_or_404(self.pg1.pk, 'en'), self.pg1) -class IsModerationEnabledTest(BaseTestCase): - - @override_settings(CMS_MODERATION_ENABLE_WORKFLOW_OVERRIDE=True) - def test_returns_true_with_override_no_moderation_object(self): - self.assertTrue(is_moderation_enabled(self.pg1)) - - @override_settings(CMS_MODERATION_ENABLE_WORKFLOW_OVERRIDE=True) - def test_returns_true_with_override_moderation_object_enabled(self): - PageModeration.objects.create(extended_object=self.pg1, enabled=True, workflow=self.wf1,) - self.assertTrue(is_moderation_enabled(self.pg1)) - - @override_settings(CMS_MODERATION_ENABLE_WORKFLOW_OVERRIDE=True) - def test_returns_false_with_override_moderation_object_disabled(self): - PageModeration.objects.create(extended_object=self.pg1, enabled=False, workflow=self.wf1,) - self.assertFalse(is_moderation_enabled(self.pg1)) - - @override_settings(CMS_MODERATION_ENABLE_WORKFLOW_OVERRIDE=True) - def test_returns_false_with_override_no_workflows(self): - Workflow.objects.all().delete() - self.assertFalse(is_moderation_enabled(self.pg1)) - - def test_returns_true_default_settings_has_default_workflow(self): - self.assertTrue(is_moderation_enabled(self.pg1)) - - def test_returns_true_default_settings_moderation_object_enabled(self): - PageModeration.objects.create(extended_object=self.pg1, enabled=True, workflow=self.wf1,) - self.assertTrue(is_moderation_enabled(self.pg1)) - - def test_returns_false_default_settings_moderation_object_disabled(self): - PageModeration.objects.create(extended_object=self.pg1, enabled=False, workflow=self.wf1,) - self.assertFalse(is_moderation_enabled(self.pg1)) - - @patch('djangocms_moderation.helpers.get_page_moderation_workflow', return_value=None) - def test_returns_false_default_settings_no_workflow(self, mock_gpmw): - self.assertFalse(is_moderation_enabled(self.pg1)) - - class GetFormSubmissions(BaseTestCase): def test_returns_form_submission_for_step(self): diff --git a/tests/test_models.py b/tests/test_models.py index 593bc1a6..1103042f 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,18 +1,17 @@ import json from mock import patch +from unittest import skip from django.contrib.auth.models import User from django.core.exceptions import ValidationError from django.core.urlresolvers import reverse -from cms.api import create_page - from djangocms_moderation import constants from djangocms_moderation.models import ( ConfirmationFormSubmission, ConfirmationPage, - PageModerationRequest, - PageModerationRequestAction, + ModerationRequest, + ModerationRequestAction, Role, Workflow, WorkflowStep, @@ -64,17 +63,18 @@ def test_multiple_defaults_validation_error(self): def test_first_step(self): self.assertEqual(self.wf1.first_step, self.wf1st1) + @skip('4.0 rework TBC') @patch('djangocms_moderation.models.notify_requested_moderator') def test_submit_new_request(self, mock_nrm): request = self.wf1.submit_new_request( by_user=self.user, - page=self.pg3, + obj=self.pg3, language='en', message='Some message', ) self.assertQuerysetEqual( request.actions.all(), - PageModerationRequestAction.objects.filter(request=request), + ModerationRequestAction.objects.filter(request=request), transform=lambda x: x, ordered=False, ) @@ -94,7 +94,7 @@ def test_get_next_required(self): self.assertIsNone(self.wf1st3.get_next_required()) -class PageModerationRequestTest(BaseTestCase): +class ModerationRequestTest(BaseTestCase): def test_has_pending_step(self): self.assertTrue(self.moderation_request1.has_pending_step()) @@ -244,20 +244,11 @@ def test_user_can_moderate(self): self.assertFalse(self.moderation_request3.user_can_moderate(temp_user)) # check that it doesn't allow access to users that aren't part of this moderation request - self.pg5 = create_page(title='Page 5', template='page.html', language='en',) - self.user4 = User.objects.create_superuser(username='test4', email='test4@test.com', password='test4',) - self.role4 = Role.objects.create(name='Role 4', user=self.user4,) - self.wf4 = Workflow.objects.create(pk=4, name='Workflow 4',) - self.wf4st1 = self.wf4.steps.create(role=self.role4, is_required=True, order=1,) - self.wf4st2 = self.wf4.steps.create(role=self.role1, is_required=False, order=2,) - self.moderation_request4 = PageModerationRequest.objects.create( - page=self.pg5, language='en', workflow=self.wf4, is_active=True,) - self.moderation_request4.actions.create(by_user=self.user, action=constants.ACTION_STARTED,) - + user4 = User.objects.create_superuser(username='test4', email='test4@test.com', password='test4',) self.assertTrue(self.moderation_request4.user_can_moderate(self.user)) - self.assertFalse(self.moderation_request4.user_can_moderate(self.user2)) - self.assertFalse(self.moderation_request4.user_can_moderate(self.user3)) - self.assertTrue(self.moderation_request4.user_can_moderate(self.user4)) + self.assertTrue(self.moderation_request4.user_can_moderate(self.user2)) + self.assertTrue(self.moderation_request4.user_can_moderate(self.user3)) + self.assertFalse(self.moderation_request4.user_can_moderate(user4)) @patch('djangocms_moderation.models.notify_request_author') @patch('djangocms_moderation.models.notify_requested_moderator') @@ -372,11 +363,11 @@ def test_rejection_makes_the_previous_actions_archived(self): def test_compliance_number(self, mock_uuid): mock_uuid.return_value = 'abc123' - request = PageModerationRequest.objects.create( - page=self.pg1, + request = ModerationRequest.objects.create( + content_object=self.pg1, language='en', is_active=True, - workflow=self.wf1, + collection=self.collection1, ) self.assertEqual(mock_uuid.call_count, 0) @@ -386,10 +377,10 @@ def test_compliance_number(self, mock_uuid): def test_compliance_number_sequential_number_backend(self): self.wf2.compliance_number_backend = 'djangocms_moderation.backends.sequential_number_backend' - request = PageModerationRequest.objects.create( - page=self.pg1, + request = ModerationRequest.objects.create( + content_object=self.pg1, language='en', - workflow=self.wf2, + collection=self.collection2, ) request.refresh_from_db() self.assertIsNone(request.compliance_number) @@ -405,10 +396,10 @@ def test_compliance_number_sequential_number_with_identifier_prefix_backend(self ) self.wf2.identifier = 'SSO' - request = PageModerationRequest.objects.create( - page=self.pg1, + request = ModerationRequest.objects.create( + content_object=self.pg1, language='en', - workflow=self.wf2, + collection=self.collection2, ) request.refresh_from_db() self.assertIsNone(request.compliance_number) @@ -419,7 +410,7 @@ def test_compliance_number_sequential_number_with_identifier_prefix_backend(self self.assertEqual(request.compliance_number, expected) -class PageModerationRequestActionTest(BaseTestCase): +class ModerationRequestActionTest(BaseTestCase): def test_get_by_user_name(self): action = self.moderation_request3.actions.last() @@ -439,10 +430,10 @@ def test_save_when_to_user_passed(self): self.assertEqual(new_action.to_role, self.role2) def test_save_when_to_user_not_passed_and_action_started(self): - new_request = PageModerationRequest.objects.create( - page=self.pg2, + new_request = ModerationRequest.objects.create( + content_object=self.pg2, language='en', - workflow=self.wf1, + collection=self.collection1, is_active=True, ) new_action = new_request.actions.create(by_user=self.user, action=constants.ACTION_STARTED,) diff --git a/tests/test_moderation_flows.py b/tests/test_moderation_flows.py index 967fce46..ee5bf221 100644 --- a/tests/test_moderation_flows.py +++ b/tests/test_moderation_flows.py @@ -1,3 +1,5 @@ +from unittest import skip + from django.contrib.auth.models import User from django.test import TestCase @@ -6,8 +8,8 @@ from djangocms_moderation import constants from djangocms_moderation.models import ( - PageModerationRequest, - PageModerationRequestAction, + ModerationRequest, + ModerationRequestAction, Role, Workflow, ) @@ -67,6 +69,7 @@ def _resubmit_moderation_request(self, user, message='Test message - resubmit'): def _cancel_moderation_request(self, user, message='Test message - cancel'): return self._process_moderation_request(user, 'cancel', message) + @skip('4.0 rework TBC') def test_approve_moderation_workflow(self): """ This case tests the following workflow: @@ -77,20 +80,20 @@ def test_approve_moderation_workflow(self): 5. compliance number is generated 6. author publishes the page and workflow is done """ - self.assertFalse(PageModerationRequest.objects.exists()) - self.assertFalse(PageModerationRequestAction.objects.exists()) + self.assertFalse(ModerationRequest.objects.exists()) + self.assertFalse(ModerationRequestAction.objects.exists()) # Lets create a new moderation request self._new_moderation_request(self.author) - moderation_request = PageModerationRequest.objects.get() # It exists - action = PageModerationRequestAction.objects.get() + moderation_request = ModerationRequest.objects.get() # It exists + action = ModerationRequestAction.objects.get() self.assertEqual(action.action, constants.ACTION_STARTED) response = self._approve_moderation_request(self.moderator_1) self.assertEqual(response.status_code, 200) - second_action = PageModerationRequestAction.objects.last() + second_action = ModerationRequestAction.objects.last() self.assertTrue(second_action.action, constants.ACTION_APPROVED) self.assertTrue(second_action.message, 'Test message - approved') # Compliance number is not generated yet @@ -110,7 +113,7 @@ def test_approve_moderation_workflow(self): compliance_number = moderation_request.compliance_number self.assertIsNotNone(compliance_number) - third_action = PageModerationRequestAction.objects.last() + third_action = ModerationRequestAction.objects.last() self.assertTrue(third_action.action, constants.ACTION_APPROVED) self.assertTrue(second_action.message, 'message #2') @@ -122,10 +125,11 @@ def test_approve_moderation_workflow(self): moderation_request.refresh_from_db() # Moderation request is finished and last action is recorded self.assertFalse(moderation_request.is_active) - last_action = PageModerationRequestAction.objects.last() + last_action = ModerationRequestAction.objects.last() self.assertTrue(last_action.action, constants.ACTION_FINISHED) self.assertEqual(moderation_request.compliance_number, compliance_number) + @skip('4.0 rework TBC') def test_reject_moderation_workflow(self): """ This case tests the following workflow: @@ -136,15 +140,15 @@ def test_reject_moderation_workflow(self): 5. all is approved by moderator_1 and moderator_2 6. author cancels the request """ - self.assertFalse(PageModerationRequest.objects.exists()) - self.assertFalse(PageModerationRequestAction.objects.exists()) + self.assertFalse(ModerationRequest.objects.exists()) + self.assertFalse(ModerationRequestAction.objects.exists()) # Lets create a new moderation request self._new_moderation_request(self.author) - moderation_request = PageModerationRequest.objects.get() # It exists + moderation_request = ModerationRequest.objects.get() # It exists - action = PageModerationRequestAction.objects.get() + action = ModerationRequestAction.objects.get() self.assertEqual(action.action, constants.ACTION_STARTED) # moderator_1 will approve it now @@ -159,12 +163,12 @@ def test_reject_moderation_workflow(self): # Make sure that after the rejection, this moderation request is still active self.assertTrue(moderation_request.is_active) - third_action = PageModerationRequestAction.objects.last() + third_action = ModerationRequestAction.objects.last() self.assertTrue(third_action.action, constants.ACTION_REJECTED) self.assertTrue(third_action.message, 'Please, less swearing') # Lets check that we now have 2 archived actions. First and second one - self.assertEqual(2, PageModerationRequestAction.objects.filter(is_archived=True).count()) + self.assertEqual(2, ModerationRequestAction.objects.filter(is_archived=True).count()) # Now the original author can make amends and resubmit self._resubmit_moderation_request(self.author) @@ -173,7 +177,7 @@ def test_reject_moderation_workflow(self): moderation_request.refresh_from_db() self.assertTrue(moderation_request.is_active) - last_action = PageModerationRequestAction.objects.last() + last_action = ModerationRequestAction.objects.last() self.assertTrue(last_action.action, constants.ACTION_RESUBMITTED) self._approve_moderation_request(self.moderator_1) @@ -185,5 +189,5 @@ def test_reject_moderation_workflow(self): moderation_request.refresh_from_db() self.assertFalse(moderation_request.is_active) - last_action = PageModerationRequestAction.objects.last() + last_action = ModerationRequestAction.objects.last() self.assertTrue(last_action.action, constants.ACTION_CANCELLED) diff --git a/tests/test_views.py b/tests/test_views.py index 852da6f7..04b222da 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -1,5 +1,6 @@ import json from mock import patch +from unittest import skip from django.contrib.auth.models import User from django.utils.translation import ugettext_lazy as _ @@ -9,7 +10,6 @@ from djangocms_moderation import constants from djangocms_moderation.forms import ( ModerationRequestForm, - SelectModerationForm, UpdateModerationRequestForm, ) from djangocms_moderation.models import ( @@ -54,6 +54,7 @@ def test_new_request_view_with_form(self): title=_('Submit for moderation') ) + @skip('4.0 rework TBC') def test_new_request_view_with_form_workflow_passed_param(self): response = self.client.get( '{}?{}'.format( @@ -154,6 +155,7 @@ def test_get_form_kwargs(self): self.assertEqual(kwargs.get('workflow'), view.workflow) self.assertEqual(kwargs.get('active_request'), view.active_request) + @skip('4.0 rework TBC') def test_form_valid(self): response = self.client.post(get_admin_url( name='cms_moderation_new_request', @@ -175,6 +177,7 @@ def test_throws_error_moderation_already_exists(self): self.assertEqual(response.status_code, 400) self.assertEqual(response.content, b'Page already has an active moderation request.') + @skip('4.0 rework TBC') def test_throws_error_invalid_workflow_passed(self): response = self.client.get('{}?{}'.format( get_admin_url( @@ -217,7 +220,7 @@ def test_throws_error_forbidden_user(self): self.assertEqual(response.status_code, 403) self.assertEqual(response.content, b'User is not allowed to update request.') - @patch('djangocms_moderation.views.get_page_moderation_workflow', return_value=None) + @patch('djangocms_moderation.views.get_moderation_workflow', return_value=None) def test_throws_error_if_workflow_has_not_been_resolved(self, mock_gpmw): response = self.client.get(get_admin_url( name='cms_moderation_new_request', @@ -303,49 +306,6 @@ def test_renders_all_form_submissions(self): self.assertQuerysetEqual(form_submissions, results, transform=lambda x: x, ordered=False) -class SelectModerationViewTest(BaseViewTestCase): - - def test_renders_view_with_form(self): - response = self.client.get(get_admin_url( - name='cms_moderation_select_new_moderation', - language='en', - args=(self.pg1.pk, 'en') - )) - view = response.context_data['view'] - form = response.context_data['adminform'] - self.assertEqual(response.status_code, 200) - self.assertEqual(response.template_name[0], 'djangocms_moderation/select_workflow_form.html') - self.assertEqual(view.page_id, str(self.pg1.pk)) - self.assertEqual(view.current_lang, 'en') - self.assertIsInstance(form, SelectModerationForm) - - def test_get_form_kwargs(self): - response = self.client.get(get_admin_url( - name='cms_moderation_select_new_moderation', - language='en', - args=(self.pg1.pk, 'en') - )) - view = response.context_data['view'] - kwargs = view.get_form_kwargs() - self.assertEqual(kwargs.get('page'), self.pg1) - - def test_form_valid(self): - response = self.client.post(get_admin_url( - name='cms_moderation_select_new_moderation', - language='en', - args=(self.pg1.pk, 'en') - ), {'workflow': self.wf2.pk}) - form_valid_redirect_url = '{}?{}'.format( - get_admin_url( - name='cms_moderation_new_request', - language='en', - args=(self.pg1.pk, 'en') - ), - 'workflow={}'.format(self.wf2.pk) - ) - self.assertEqual(response.url, form_valid_redirect_url) - - class ModerationCommentsViewTest(BaseViewTestCase): def test_comment_list(self): diff --git a/tests/utils.py b/tests/utils.py index 5a250b3f..36dee07c 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -4,7 +4,12 @@ from cms.api import create_page from djangocms_moderation import constants -from djangocms_moderation.models import PageModerationRequest, Role, Workflow +from djangocms_moderation.models import ( + ModerationCollection, + ModerationRequest, + Role, + Workflow, +) class BaseTestCase(TestCase): @@ -51,17 +56,21 @@ def setUpTestData(cls): cls.wf3st2 = cls.wf3.steps.create(role=cls.role3, is_required=False, order=2,) # create page moderation requests and actions - cls.moderation_request1 = PageModerationRequest.objects.create( - page=cls.pg1, language='en', workflow=cls.wf1, is_active=True,) + cls.collection1 = ModerationCollection.objects.create(name='Collection 1', workflow=cls.wf1) + cls.collection2 = ModerationCollection.objects.create(name='Collection 2', workflow=cls.wf2) + cls.collection3 = ModerationCollection.objects.create(name='Collection 3', workflow=cls.wf3) + + cls.moderation_request1 = ModerationRequest.objects.create( + content_object=cls.pg1, language='en', collection=cls.collection1, is_active=True,) cls.moderation_request1.actions.create(by_user=cls.user, action=constants.ACTION_STARTED,) - PageModerationRequest.objects.create( - page=cls.pg1, language='en', workflow=cls.wf1, is_active=False,) - PageModerationRequest.objects.create( - page=cls.pg2, language='en', workflow=cls.wf2, is_active=False,) + ModerationRequest.objects.create( + content_object=cls.pg1, language='en', collection=cls.collection1, is_active=False,) + ModerationRequest.objects.create( + content_object=cls.pg2, language='en', collection=cls.collection2, is_active=False,) - cls.moderation_request2 = PageModerationRequest.objects.create( - page=cls.pg3, language='en', workflow=cls.wf2, is_active=True,) + cls.moderation_request2 = ModerationRequest.objects.create( + content_object=cls.pg3, language='en', collection=cls.collection2, is_active=True,) cls.moderation_request2.actions.create( by_user=cls.user, action=constants.ACTION_STARTED,) cls.moderation_request2.actions.create( @@ -69,8 +78,8 @@ def setUpTestData(cls): cls.moderation_request2.actions.create( by_user=cls.user, action=constants.ACTION_APPROVED, step_approved=cls.wf2st2,) - cls.moderation_request3 = PageModerationRequest.objects.create( - page=cls.pg4, language='en', workflow=cls.wf3, is_active=True,) + cls.moderation_request3 = ModerationRequest.objects.create( + content_object=cls.pg4, language='en', collection=cls.collection3, is_active=True,) cls.moderation_request3.actions.create(by_user=cls.user, action=constants.ACTION_STARTED,) cls.moderation_request3.actions.create( by_user=cls.user, @@ -79,8 +88,8 @@ def setUpTestData(cls): step_approved=cls.wf3st1, ) # This request will be rejected - cls.moderation_request4 = PageModerationRequest.objects.create( - page=cls.pg5, language='en', workflow=cls.wf3, is_active=True,) + cls.moderation_request4 = ModerationRequest.objects.create( + content_object=cls.pg5, language='en', collection=cls.collection3, is_active=True,) cls.moderation_request4.actions.create(by_user=cls.user, action=constants.ACTION_STARTED,) cls.moderation_request4.actions.create(by_user=cls.user2, action=constants.ACTION_REJECTED) From af3a37acaf9e40709b8f2cd0b816dc8911730fb6 Mon Sep 17 00:00:00 2001 From: Damilare Onajole Date: Fri, 27 Jul 2018 14:26:31 +0100 Subject: [PATCH 002/147] Integrated moderation with app registration system (#35) --- .circleci/config.yml | 67 ++++++------------------- djangocms_moderation/cms_config.py | 26 ++++++++++ tests/requirements.txt | 1 + tests/settings.py | 3 ++ tests/test_app_registration.py | 78 ++++++++++++++++++++++++++++++ tests/test_forms.py | 2 +- tests/test_handlers.py | 2 +- tests/test_helpers.py | 2 +- tests/test_models.py | 2 +- tests/test_views.py | 2 +- tests/utils/__init__.py | 0 tests/utils/app_1/__init__.py | 1 + tests/utils/app_1/apps.py | 7 +++ tests/utils/app_1/cms_config.py | 10 ++++ tests/utils/app_1/models.py | 15 ++++++ tests/utils/app_2/__init__.py | 1 + tests/utils/app_2/apps.py | 7 +++ tests/utils/app_2/cms_config.py | 10 ++++ tests/utils/app_2/models.py | 15 ++++++ tests/{utils.py => utils/base.py} | 0 tox.ini | 8 ++- 21 files changed, 196 insertions(+), 63 deletions(-) create mode 100644 djangocms_moderation/cms_config.py create mode 100644 tests/test_app_registration.py create mode 100644 tests/utils/__init__.py create mode 100644 tests/utils/app_1/__init__.py create mode 100644 tests/utils/app_1/apps.py create mode 100644 tests/utils/app_1/cms_config.py create mode 100644 tests/utils/app_1/models.py create mode 100644 tests/utils/app_2/__init__.py create mode 100644 tests/utils/app_2/apps.py create mode 100644 tests/utils/app_2/cms_config.py create mode 100644 tests/utils/app_2/models.py rename tests/{utils.py => utils/base.py} (100%) diff --git a/.circleci/config.yml b/.circleci/config.yml index e2a1e571..a489597f 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,17 +1,5 @@ version: 2.0 -py27default: &py27default - docker: - - image: circleci/python:2.7 - steps: - - setup_remote_docker: - docker_layer_caching: true - - checkout - - restore_cache: - keys: py27- - - run: docker load -i caches/py27.tar || true - - run: docker run py27 tox -e $CIRCLE_STAGE - py34default: &py34default docker: - image: circleci/python:3.4 @@ -49,10 +37,6 @@ py36default: &py36default - run: docker load -i caches/py36.tar || true - run: docker run py36 tox -e $CIRCLE_STAGE -py27_requires: &py27_requires - requires: - - py27_base - py34_requires: &py34_requires requires: - py34_base @@ -66,20 +50,6 @@ py36_requires: &py36_requires - py36_base jobs: - py27_base: - docker: - - image: circleci/python:2.7 - steps: - - checkout - - setup_remote_docker: - docker_layer_caching: true - - run: docker build -f .circleci/Dockerfile --build-arg PYTHON_VERSION=2.7 -t py27 . - - run: mkdir caches - - run: docker save -o caches/py27.tar py27 - - save_cache: - key: py27-{{ .Environment.CIRCLE_SHA1 }} - paths: - - "caches/" py34_base: docker: - image: circleci/python:3.4 @@ -139,21 +109,17 @@ jobs: keys: py35- - run: docker load -i caches/py35.tar || true - run: docker run py35 gulp lint - py27-dj111-sqlite-cms35: - <<: *py27default - py34-dj111-sqlite-cms35: + py34-dj111-sqlite-cms4: <<: *py34default - py35-dj111-sqlite-cms35: + py35-dj111-sqlite-cms4: <<: *py35default - py36-dj111-sqlite-cms35: + py36-dj111-sqlite-cms4: <<: *py36default - py36-dj111-sqlite-cms36: - <<: *py36default - py34-dj20-sqlite-cms36: + py34-dj20-sqlite-cms4: <<: *py34default - py35-dj20-sqlite-cms36: + py35-dj20-sqlite-cms4: <<: *py35default - py36-dj20-sqlite-cms36: + py36-dj20-sqlite-cms4: <<: *py36default ####################### @@ -162,7 +128,6 @@ workflows: version: 2 build: jobs: - - py27_base - py34_base - py35_base - py36_base @@ -175,27 +140,23 @@ workflows: - eslint: requires: - py35_base - - py27-dj111-sqlite-cms35: - requires: - - py27_base - - py34-dj111-sqlite-cms35: + - py34-dj111-sqlite-cms4: requires: - py34_base - - py35-dj111-sqlite-cms35: + - py35-dj111-sqlite-cms4: requires: - py35_base - - py36-dj111-sqlite-cms35: - requires: - - py36_base - - py36-dj111-sqlite-cms36: + - py36-dj111-sqlite-cms4: requires: - py36_base - - py34-dj20-sqlite-cms36: + - py34-dj20-sqlite-cms4: requires: - py34_base - - py35-dj20-sqlite-cms36: + - py35-dj20-sqlite-cms4: requires: - py35_base - - py36-dj20-sqlite-cms36: + - py36-dj20-sqlite-cms4: requires: - py36_base + + diff --git a/djangocms_moderation/cms_config.py b/djangocms_moderation/cms_config.py new file mode 100644 index 00000000..0ff35c3c --- /dev/null +++ b/djangocms_moderation/cms_config.py @@ -0,0 +1,26 @@ +from django.core.exceptions import ImproperlyConfigured + +from cms.app_base import CMSAppExtension + + +class ModerationExtension(CMSAppExtension): + + def __init__(self): + self.moderated_models = [] + + def configure_app(self, cms_config): + versioning_enabled = getattr(cms_config, 'djangocms_versioning_enabled', False) + moderated_models = getattr(cms_config, 'moderated_models', []) + versioning_models = getattr(cms_config, 'versioning_models', []) + + if not versioning_enabled: + raise ImproperlyConfigured('Versioning needs to be enabled for Moderation') + + for moderated_model in moderated_models: + if moderated_model not in versioning_models: + raise ImproperlyConfigured( + 'Moderated models need to be Versionable, please include every ' + 'model that needs to be moderated in versioning_models entry' + ) + + self.moderated_models.extend(moderated_models) diff --git a/tests/requirements.txt b/tests/requirements.txt index 990b53c4..9ea1f073 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -5,3 +5,4 @@ flake8 isort aldryn-forms>=3.0.3 mock +-e git+git://github.com/divio/djangocms-versioning.git@master#egg=djangocms-versioning diff --git a/tests/settings.py b/tests/settings.py index 75ae77df..770a6acd 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -1,5 +1,8 @@ HELPER_SETTINGS = { 'INSTALLED_APPS': [ + 'tests.utils.app_1', + 'tests.utils.app_2', + 'djangocms_versioning', 'filer', 'easy_thumbnails', 'absolute', diff --git a/tests/test_app_registration.py b/tests/test_app_registration.py new file mode 100644 index 00000000..c44d50f3 --- /dev/null +++ b/tests/test_app_registration.py @@ -0,0 +1,78 @@ +try: + from unittest.mock import Mock +except ImportError: + from mock import Mock + +from unittest import TestCase + +from django.apps import apps +from django.core.exceptions import ImproperlyConfigured + +from cms import app_registration +from cms.test_utils.testcases import CMSTestCase +from cms.utils.setup import setup_cms_apps + +from djangocms_moderation.cms_config import ModerationExtension + +from .utils.app_1.models import TestModel3, TestModel4 +from .utils.app_2.models import TestModel1, TestModel2 + + +class CMSConfigTest(CMSTestCase, TestCase): + + def setUp(self): + app_registration.get_cms_extension_apps.cache_clear() + app_registration.get_cms_config_apps.cache_clear() + + def test_missing_versioning_enabled(self): + extension = ModerationExtension() + cms_config = Mock( + moderated_models=[TestModel1, TestModel2, TestModel3, TestModel4], + djangocms_moderation_enabled=True, + djangocms_versioning_enabled=False, + app_config=Mock(label='blah_cms_config') + ) + + with self.assertRaises(ImproperlyConfigured): + extension.configure_app(cms_config) + + def test_moderated_model_not_in_versioning_models(self): + extension = ModerationExtension() + cms_config = Mock( + djangocms_moderation_enabled=True, + moderated_models=[TestModel1, TestModel2, TestModel3, TestModel4], + versioning_models=[TestModel3, TestModel4], + app_config=Mock(label='blah_cms_config') + ) + + with self.assertRaises(ImproperlyConfigured): + extension.configure_app(cms_config) + + def test_valid_cms_config(self): + extension = ModerationExtension() + cms_config = Mock( + djangocms_moderation_enabled=True, + moderated_models=[TestModel1, TestModel2, TestModel3, TestModel4], + versioning_models=[TestModel1, TestModel2, TestModel3, TestModel4], + app_config=Mock(label='blah_cms_config') + ) + + extension.configure_app(cms_config) + self.assertTrue(TestModel1 in extension.moderated_models) + self.assertTrue(TestModel2 in extension.moderated_models) + self.assertTrue(TestModel3 in extension.moderated_models) + self.assertTrue(TestModel4 in extension.moderated_models) + + +class CMSConfigIntegrationTest(CMSTestCase): + + def setUp(self): + app_registration.get_cms_extension_apps.cache_clear() + app_registration.get_cms_config_apps.cache_clear() + + def test_config_with_two_apps(self): + setup_cms_apps() + moderation_config = apps.get_app_config('djangocms_moderation') + registered_model = moderation_config.cms_extension.moderated_models + + self.assertEqual(len(registered_model), 4) diff --git a/tests/test_forms.py b/tests/test_forms.py index 90f9ecdd..c622e4a8 100644 --- a/tests/test_forms.py +++ b/tests/test_forms.py @@ -9,7 +9,7 @@ UpdateModerationRequestForm, ) -from .utils import BaseTestCase +from .utils.base import BaseTestCase class ModerationRequestFormTest(BaseTestCase): diff --git a/tests/test_handlers.py b/tests/test_handlers.py index ea51631f..cb240cb4 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -9,7 +9,7 @@ ConfirmationPage, ) -from .utils import BaseTestCase +from .utils.base import BaseTestCase class ModerationConfirmationFormSubmissionTest(BaseTestCase): diff --git a/tests/test_helpers.py b/tests/test_helpers.py index d78534f3..f7e7210e 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -10,7 +10,7 @@ ConfirmationPage, ) -from .utils import BaseTestCase +from .utils.base import BaseTestCase class GetCurrentModerationRequestTest(BaseTestCase): diff --git a/tests/test_models.py b/tests/test_models.py index 1103042f..d69c6b4a 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -17,7 +17,7 @@ WorkflowStep, ) -from .utils import BaseTestCase +from .utils.base import BaseTestCase class RoleTest(BaseTestCase): diff --git a/tests/test_views.py b/tests/test_views.py index 04b222da..c3ae1b71 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -18,7 +18,7 @@ ) from djangocms_moderation.utils import get_admin_url -from .utils import BaseViewTestCase +from .utils.base import BaseViewTestCase class ModerationRequestViewTest(BaseViewTestCase): diff --git a/tests/utils/__init__.py b/tests/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/utils/app_1/__init__.py b/tests/utils/app_1/__init__.py new file mode 100644 index 00000000..03f81427 --- /dev/null +++ b/tests/utils/app_1/__init__.py @@ -0,0 +1 @@ +default_app_config = "tests.utils.app_1.apps.App1Config" diff --git a/tests/utils/app_1/apps.py b/tests/utils/app_1/apps.py new file mode 100644 index 00000000..d7a19c05 --- /dev/null +++ b/tests/utils/app_1/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class App1Config(AppConfig): + name = 'tests.utils.app_1' + label = 'app_1' + verbose_name = "django app with cms_config for integration test" diff --git a/tests/utils/app_1/cms_config.py b/tests/utils/app_1/cms_config.py new file mode 100644 index 00000000..bb1b63fd --- /dev/null +++ b/tests/utils/app_1/cms_config.py @@ -0,0 +1,10 @@ +from cms.app_base import CMSAppConfig + +from .models import TestModel3, TestModel4 + + +class CMSApp1Config(CMSAppConfig): + djangocms_versioning_enabled = True + djangocms_moderation_enabled = True + versioning_models = [TestModel3, TestModel4] + moderated_models = [TestModel3, TestModel4] diff --git a/tests/utils/app_1/models.py b/tests/utils/app_1/models.py new file mode 100644 index 00000000..d58a1a5e --- /dev/null +++ b/tests/utils/app_1/models.py @@ -0,0 +1,15 @@ +from django.db import models + +from djangocms_versioning.models import BaseVersion + + +class GrouperModel(models.Model): + content = models.CharField(max_length=255) + + +class TestModel3(BaseVersion): + content = models.ForeignKey(GrouperModel) + + +class TestModel4(BaseVersion): + content = models.ForeignKey(GrouperModel) diff --git a/tests/utils/app_2/__init__.py b/tests/utils/app_2/__init__.py new file mode 100644 index 00000000..ff4e78c0 --- /dev/null +++ b/tests/utils/app_2/__init__.py @@ -0,0 +1 @@ +default_app_config = "tests.utils.app_2.apps.App2Config" diff --git a/tests/utils/app_2/apps.py b/tests/utils/app_2/apps.py new file mode 100644 index 00000000..d42552ed --- /dev/null +++ b/tests/utils/app_2/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class App2Config(AppConfig): + name = 'tests.utils.app_2' + label = 'app_2' + verbose_name = "Another django app with cms_config for integration test" diff --git a/tests/utils/app_2/cms_config.py b/tests/utils/app_2/cms_config.py new file mode 100644 index 00000000..2b5827f0 --- /dev/null +++ b/tests/utils/app_2/cms_config.py @@ -0,0 +1,10 @@ +from cms.app_base import CMSAppConfig + +from .models import TestModel1, TestModel2 + + +class CMSApp2Config(CMSAppConfig): + djangocms_versioning_enabled = True + djangocms_moderation_enabled = True + versioning_models = [TestModel1, TestModel2] + moderated_models = [TestModel1, TestModel2] diff --git a/tests/utils/app_2/models.py b/tests/utils/app_2/models.py new file mode 100644 index 00000000..5e92c245 --- /dev/null +++ b/tests/utils/app_2/models.py @@ -0,0 +1,15 @@ +from django.db import models + +from djangocms_versioning.models import BaseVersion + + +class GrouperModel(models.Model): + content = models.CharField(max_length=255) + + +class TestModel1(BaseVersion): + content = models.ForeignKey(GrouperModel) + + +class TestModel2(BaseVersion): + content = models.ForeignKey(GrouperModel) diff --git a/tests/utils.py b/tests/utils/base.py similarity index 100% rename from tests/utils.py rename to tests/utils/base.py diff --git a/tox.ini b/tox.ini index 719d970d..bc6de0aa 100644 --- a/tox.ini +++ b/tox.ini @@ -2,8 +2,8 @@ envlist = flake8 isort - py{27,34,35,36}-dj111-sqlite-cms{35,36} - py{34,35,36}-dj20-sqlite-cms36 + py{34,35,36}-dj111-sqlite-cms4 + py{34,35,36}-dj20-sqlite-cms4 skip_missing_interpreters=True @@ -14,11 +14,9 @@ deps = dj111: Django>=1.11,<2.0 dj20: Django>=2.0,<2.1 - cms35: django-cms>=3.5,<3.6 - cms36: https://github.com/divio/django-cms/archive/develop.zip + cms4: https://github.com/divio/django-cms/archive/release/4.0.x.zip basepython = - py27: python2.7 py34: python3.4 py35: python3.5 py36: python3.6 From 3178ac2dfda8d3da29284ffc3a439189811bc187 Mon Sep 17 00:00:00 2001 From: Matus Moravcik Date: Tue, 31 Jul 2018 22:25:27 +0100 Subject: [PATCH 003/147] Collection methods 1.0.x (#34) --- djangocms_moderation/exceptions.py | 6 ++ .../0002_moderationcollection_author.py | 28 +++++++ djangocms_moderation/models.py | 37 +++++++++ tests/test_models.py | 76 ++++++++++++++++++- tests/utils/base.py | 14 +++- 5 files changed, 156 insertions(+), 5 deletions(-) create mode 100644 djangocms_moderation/exceptions.py create mode 100644 djangocms_moderation/migrations/0002_moderationcollection_author.py diff --git a/djangocms_moderation/exceptions.py b/djangocms_moderation/exceptions.py new file mode 100644 index 00000000..abf1e860 --- /dev/null +++ b/djangocms_moderation/exceptions.py @@ -0,0 +1,6 @@ +class ObjectAlreadyInCollection(Exception): + pass + + +class CollectionIsLocked(Exception): + pass diff --git a/djangocms_moderation/migrations/0002_moderationcollection_author.py b/djangocms_moderation/migrations/0002_moderationcollection_author.py new file mode 100644 index 00000000..152690fa --- /dev/null +++ b/djangocms_moderation/migrations/0002_moderationcollection_author.py @@ -0,0 +1,28 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.13 on 2018-07-18 14:01 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('djangocms_moderation', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='moderationcollection', + name='author', + field=models.ForeignKey(default=0, on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='author'), + preserve_default=False, + ), + migrations.AlterUniqueTogether( + name='moderationrequest', + unique_together=set([('collection', 'object_id', 'content_type')]), + ), + ] diff --git a/djangocms_moderation/models.py b/djangocms_moderation/models.py index 5731a619..453577dd 100644 --- a/djangocms_moderation/models.py +++ b/djangocms_moderation/models.py @@ -17,6 +17,7 @@ from cms.models.fields import PlaceholderField from .emails import notify_request_author, notify_requested_moderator +from .exceptions import CollectionIsLocked, ObjectAlreadyInCollection from .utils import generate_compliance_number @@ -267,6 +268,12 @@ def get_next_required(self): class ModerationCollection(models.Model): name = models.CharField(verbose_name=_('name'), max_length=128) + author = models.ForeignKey( + to=settings.AUTH_USER_MODEL, + verbose_name=_('author'), + related_name='+', + on_delete=models.CASCADE, + ) workflow = models.ForeignKey( to=Workflow, verbose_name=_('workflow'), @@ -275,6 +282,35 @@ class ModerationCollection(models.Model): # TODO: proper implementations and handlers coming later for is_locked is_locked = models.BooleanField(verbose_name=_('is locked'), default=False) + def add_object(self, content_object): + """ + Add object to the ModerationRequest in this collection. + :return: + """ + if self.is_locked: + raise CollectionIsLocked( + "Can't add the object to the collection, because it is locked" + ) + + content_type = ContentType.objects.get_for_model(content_object) + # Object can ever be part of only one collection + existing_request_exists = ModerationRequest.objects.filter( + content_type=content_type, + object_id=content_object.pk, + ).exists() + + if not existing_request_exists: + return self.moderation_requests.create( + content_type=content_type, + object_id=content_object.pk, + collection=self, + ) + else: + raise ObjectAlreadyInCollection( + "{} is already part of existing moderation request which is part " + "of another active collection".format(content_object) + ) + @python_2_unicode_compatible class ModerationRequest(models.Model): @@ -314,6 +350,7 @@ class ModerationRequest(models.Model): class Meta: verbose_name = _('Request') verbose_name_plural = _('Requests') + unique_together = ('collection', 'object_id', 'content_type') def __str__(self): return "{} {}".format( diff --git a/tests/test_models.py b/tests/test_models.py index d69c6b4a..c90d7af6 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -3,13 +3,21 @@ from unittest import skip from django.contrib.auth.models import User +from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.core.urlresolvers import reverse +from cms.api import create_page + from djangocms_moderation import constants +from djangocms_moderation.exceptions import ( + CollectionIsLocked, + ObjectAlreadyInCollection, +) from djangocms_moderation.models import ( ConfirmationFormSubmission, ConfirmationPage, + ModerationCollection, ModerationRequest, ModerationRequestAction, Role, @@ -364,7 +372,7 @@ def test_compliance_number(self, mock_uuid): mock_uuid.return_value = 'abc123' request = ModerationRequest.objects.create( - content_object=self.pg1, + content_object=self.pg4, language='en', is_active=True, collection=self.collection1, @@ -501,3 +509,69 @@ def test_get_by_user_name(self): confirmation_page=self.cp, ) self.assertEqual(cfs.get_by_user_name(), self.user.username) + + +class ModerationCollectionTest(BaseTestCase): + def setUp(self): + self.collection1 = ModerationCollection.objects.create( + author=self.user, name='My collection 1', workflow=self.wf1 + ) + self.collection2 = ModerationCollection.objects.create( + author=self.user, name='My collection 2', workflow=self.wf1 + ) + + self.page1 = create_page(title='My page 1', template='page.html', language='en',) + self.page2 = create_page(title='My page 2', template='page.html', language='en',) + + def _moderation_requests_count(self, obj, collection=None): + """ + How many moderation requests are there [for a given collection] + :return: + """ + content_type = ContentType.objects.get_for_model(obj) + queryset = ModerationRequest.objects.filter( + content_type=content_type, + object_id=obj.pk, + ) + if collection: + queryset = queryset.filter(collection=collection) + return queryset.count() + + def test_add_object(self): + self.assertEqual(0, self._moderation_requests_count(self.page1)) + # Add `page1` to `collection1` + self.collection1.add_object(self.page1) + self.assertEqual(1, self._moderation_requests_count(self.page1)) + self.assertEqual(1, self._moderation_requests_count(self.page1, self.collection1)) + + # Adding the same object to the same collection will raise an exception + with self.assertRaises(ObjectAlreadyInCollection): + self.collection1.add_object(self.page1) + + self.assertEqual(1, self._moderation_requests_count(self.page1, self.collection1)) + self.assertEqual(1, self._moderation_requests_count(self.page1)) + + # This should not work as `page1` is already part of `collection1` + with self.assertRaises(ObjectAlreadyInCollection): + self.collection2.add_object(self.page1) + + # We can add `page2` to the `collection1` as it is not there yet + self.assertEqual(0, self._moderation_requests_count(self.page2)) + self.collection1.add_object(self.page2) + self.assertEqual(1, self._moderation_requests_count(self.page2)) + self.assertEqual(1, self._moderation_requests_count(self.page2, self.collection1)) + self.assertEqual(1, self._moderation_requests_count(self.page1, self.collection1)) + + def test_create_moderation_request_from_content_object_locked_collection(self): + # This works, as the collection is not locked + self.collection2.add_object(self.page1) + self.assertEqual(1, self._moderation_requests_count(self.page1)) + + # Now, let's lock the collection, so we can't add to it anymore + self.collection2.is_locked = True + self.collection2.save() + + with self.assertRaises(CollectionIsLocked): + self.collection2.add_object(self.page1) + + self.assertEqual(1, self._moderation_requests_count(self.page1)) diff --git a/tests/utils/base.py b/tests/utils/base.py index 36dee07c..8c78bce8 100644 --- a/tests/utils/base.py +++ b/tests/utils/base.py @@ -56,16 +56,22 @@ def setUpTestData(cls): cls.wf3st2 = cls.wf3.steps.create(role=cls.role3, is_required=False, order=2,) # create page moderation requests and actions - cls.collection1 = ModerationCollection.objects.create(name='Collection 1', workflow=cls.wf1) - cls.collection2 = ModerationCollection.objects.create(name='Collection 2', workflow=cls.wf2) - cls.collection3 = ModerationCollection.objects.create(name='Collection 3', workflow=cls.wf3) + cls.collection1 = ModerationCollection.objects.create( + author=cls.user, name='Collection 1', workflow=cls.wf1 + ) + cls.collection2 = ModerationCollection.objects.create( + author=cls.user, name='Collection 2', workflow=cls.wf2 + ) + cls.collection3 = ModerationCollection.objects.create( + author=cls.user, name='Collection 3', workflow=cls.wf3 + ) cls.moderation_request1 = ModerationRequest.objects.create( content_object=cls.pg1, language='en', collection=cls.collection1, is_active=True,) cls.moderation_request1.actions.create(by_user=cls.user, action=constants.ACTION_STARTED,) ModerationRequest.objects.create( - content_object=cls.pg1, language='en', collection=cls.collection1, is_active=False,) + content_object=cls.pg3, language='en', collection=cls.collection1, is_active=False,) ModerationRequest.objects.create( content_object=cls.pg2, language='en', collection=cls.collection2, is_active=False,) From 83ff5284813a84fc63d9966cae3d402a3aa16d6b Mon Sep 17 00:00:00 2001 From: Matus Moravcik Date: Thu, 2 Aug 2018 11:22:33 +0100 Subject: [PATCH 004/147] Added collection admin views 1.0.x (#36) --- djangocms_moderation/admin.py | 82 +++++++++++++++++-- .../migrations/0003_auto_20180724_1521.py | 26 ++++++ djangocms_moderation/models.py | 5 ++ .../moderation_request_change_list.html | 11 +++ 4 files changed, 118 insertions(+), 6 deletions(-) create mode 100644 djangocms_moderation/migrations/0003_auto_20180724_1521.py create mode 100644 djangocms_moderation/templates/djangocms_moderation/moderation_request_change_list.html diff --git a/djangocms_moderation/admin.py b/djangocms_moderation/admin.py index b0e4f490..95618341 100644 --- a/djangocms_moderation/admin.py +++ b/djangocms_moderation/admin.py @@ -22,6 +22,7 @@ from .models import ( ConfirmationFormSubmission, ConfirmationPage, + ModerationCollection, ModerationRequest, ModerationRequestAction, Role, @@ -77,16 +78,40 @@ def form_submission(self, obj): class ModerationRequestAdmin(admin.ModelAdmin): + actions = None # remove `delete_selected` for now, it will be handled later inlines = [ModerationRequestActionInline] - list_display = ['id', 'language', 'collection', 'show_status', 'date_sent'] - list_filter = ['language', 'collection', 'id', 'compliance_number'] - fields = ['id', 'collection', 'workflow', 'language', 'is_active', 'show_status', 'compliance_number'] + list_display = ['id', 'content_type', 'get_title', 'collection', 'get_preview_link', 'get_status'] + list_filter = ['collection'] + fields = ['id', 'collection', 'workflow', 'is_active', 'get_status'] readonly_fields = fields + change_list_template = 'djangocms_moderation/moderation_request_change_list.html' + + def get_title(self, obj): + return obj.content_object + get_title.short_description = _('Title') + + def get_preview_link(self, obj): + # TODO this will return Version object preview link once implemented + return "Link placeholder" + get_preview_link.short_description = _('Preview') def has_add_permission(self, request): return False - def show_status(self, obj): + def changelist_view(self, request, extra_context=None): + # If we filter by a specific collection, we want to add this collection + # to the context + collection_id = request.GET.get('collection__id__exact') + if collection_id: + try: + collection = ModerationCollection.objects.get(pk=int(collection_id)) + except (ValueError, ModerationCollection.DoesNotExist): + pass + else: + extra_context = dict(collection=collection) + return super(ModerationRequestAdmin, self).changelist_view(request, extra_context) + + def get_status(self, obj): if obj.is_approved(): status = ugettext('Ready for publishing') elif obj.is_active and obj.has_pending_step(): @@ -102,7 +127,7 @@ def show_status(self, obj): } status = ugettext('%(action)s by %(name)s') % message_data return status - show_status.short_description = _('Status') + get_status.short_description = _('Status') class RoleAdmin(admin.ModelAdmin): @@ -123,7 +148,51 @@ def get_extra(self, request, obj=None, **kwargs): class WorkflowAdmin(admin.ModelAdmin): inlines = [WorkflowStepInline] list_display = ['name', 'is_default'] - fields = ['name', 'is_default', 'identifier', 'requires_compliance_number', 'compliance_number_backend'] + fields = [ + 'name', + 'is_default', + 'identifier', + 'requires_compliance_number', + 'compliance_number_backend', + ] + + +class ModerationCollectionAdmin(admin.ModelAdmin): + actions = None # remove `delete_selected` for now, it will be handled later + list_display = [ + 'id', + 'get_name_with_requests_link', + 'get_moderator', + 'workflow', + 'status', + 'is_locked', + 'date_created', + ] + + def get_name_with_requests_link(self, obj): + """ + Name of the collection should link to the list of associated + moderation requests + """ + return format_html( + '{}', + reverse('admin:djangocms_moderation_moderationrequest_changelist'), + obj.pk, + obj.name, + ) + get_name_with_requests_link.short_description = _('Name') + + def get_moderator(self, obj): + return obj.author + get_moderator.short_description = _('Moderator') + + def status(self, obj): + # TODO more statuses to come in the future, once implemented. + # It will very likely be a ModerationCollection.status field + if obj.is_locked: + return _("In review") + return _("Collection") + status.short_description = _('Status') class ExtendedPageAdmin(PageAdmin): @@ -228,6 +297,7 @@ def form_data(self, obj): admin.site._registry[Page] = ExtendedPageAdmin(Page, admin.site) admin.site.register(ModerationRequest, ModerationRequestAdmin) +admin.site.register(ModerationCollection, ModerationCollectionAdmin) admin.site.register(Role, RoleAdmin) admin.site.register(Workflow, WorkflowAdmin) diff --git a/djangocms_moderation/migrations/0003_auto_20180724_1521.py b/djangocms_moderation/migrations/0003_auto_20180724_1521.py new file mode 100644 index 00000000..63533099 --- /dev/null +++ b/djangocms_moderation/migrations/0003_auto_20180724_1521.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.13 on 2018-07-24 14:21 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('djangocms_moderation', '0002_moderationcollection_author'), + ] + + operations = [ + migrations.AddField( + model_name='moderationcollection', + name='date_created', + field=models.DateTimeField(auto_now_add=True), + preserve_default=False, + ), + migrations.AddField( + model_name='moderationcollection', + name='date_modified', + field=models.DateTimeField(auto_now=True), + ), + ] diff --git a/djangocms_moderation/models.py b/djangocms_moderation/models.py index 453577dd..16c9d795 100644 --- a/djangocms_moderation/models.py +++ b/djangocms_moderation/models.py @@ -281,6 +281,11 @@ class ModerationCollection(models.Model): ) # TODO: proper implementations and handlers coming later for is_locked is_locked = models.BooleanField(verbose_name=_('is locked'), default=False) + date_created = models.DateTimeField(auto_now_add=True) + date_modified = models.DateTimeField(auto_now=True) + + def __str__(self): + return self.name def add_object(self, content_object): """ diff --git a/djangocms_moderation/templates/djangocms_moderation/moderation_request_change_list.html b/djangocms_moderation/templates/djangocms_moderation/moderation_request_change_list.html new file mode 100644 index 00000000..ee4addab --- /dev/null +++ b/djangocms_moderation/templates/djangocms_moderation/moderation_request_change_list.html @@ -0,0 +1,11 @@ +{% extends "admin/change_list.html" %} + +{% block content %} +{{ block.super }} + +{% comment %} + TODO This template will be overridden to provide the required functionality. + For now, lets just output the selected collection +{% endcomment %} +Selected collection: {{ collection }} +{% endblock %} From 465cd74cb6560429f8f64c358bf5a5c901181441 Mon Sep 17 00:00:00 2001 From: Paulo Alvarado Date: Mon, 6 Aug 2018 19:16:40 +0100 Subject: [PATCH 005/147] Use master aldryn-forms branch --- tests/requirements.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/requirements.txt b/tests/requirements.txt index 9ea1f073..e31ee985 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -3,6 +3,6 @@ tox coverage flake8 isort -aldryn-forms>=3.0.3 +-e git+git://github.com/aldryn/aldryn-forms.git@master#egg=aldryn-forms mock -e git+git://github.com/divio/djangocms-versioning.git@master#egg=djangocms-versioning From 4f4570734ec761286f345917eb645b836b59bb9c Mon Sep 17 00:00:00 2001 From: Damilare Onajole Date: Mon, 6 Aug 2018 19:16:56 +0100 Subject: [PATCH 006/147] Rework app registration integration with versioning (#37) --- djangocms_moderation/cms_config.py | 13 +++++---- tests/test_app_registration.py | 42 ++++++++++++++---------------- tests/utils/app_1/cms_config.py | 20 +++++++++++--- tests/utils/app_1/models.py | 16 +++++++----- tests/utils/app_2/cms_config.py | 22 ++++++++++++---- tests/utils/app_2/models.py | 16 +++++++----- 6 files changed, 78 insertions(+), 51 deletions(-) diff --git a/djangocms_moderation/cms_config.py b/djangocms_moderation/cms_config.py index 0ff35c3c..f4e1e5db 100644 --- a/djangocms_moderation/cms_config.py +++ b/djangocms_moderation/cms_config.py @@ -1,3 +1,4 @@ +from django.apps import apps from django.core.exceptions import ImproperlyConfigured from cms.app_base import CMSAppExtension @@ -11,16 +12,18 @@ def __init__(self): def configure_app(self, cms_config): versioning_enabled = getattr(cms_config, 'djangocms_versioning_enabled', False) moderated_models = getattr(cms_config, 'moderated_models', []) - versioning_models = getattr(cms_config, 'versioning_models', []) if not versioning_enabled: raise ImproperlyConfigured('Versioning needs to be enabled for Moderation') - for moderated_model in moderated_models: - if moderated_model not in versioning_models: + versioning_extension = apps.get_app_config('djangocms_versioning').cms_extension + + for model in moderated_models: + # @todo replace this with a to be provided func from versioning_extensions + if model not in versioning_extension.versionables_by_content: raise ImproperlyConfigured( - 'Moderated models need to be Versionable, please include every ' - 'model that needs to be moderated in versioning_models entry' + 'Moderated model %s need to be Versionable, please include every model that ' + 'needs to be moderated in djangocms_versioning VersionableItem entry' % model ) self.moderated_models.extend(moderated_models) diff --git a/tests/test_app_registration.py b/tests/test_app_registration.py index c44d50f3..bca219b6 100644 --- a/tests/test_app_registration.py +++ b/tests/test_app_registration.py @@ -4,6 +4,7 @@ from mock import Mock from unittest import TestCase +from unittest.mock import patch from django.apps import apps from django.core.exceptions import ImproperlyConfigured @@ -14,8 +15,8 @@ from djangocms_moderation.cms_config import ModerationExtension -from .utils.app_1.models import TestModel3, TestModel4 -from .utils.app_2.models import TestModel1, TestModel2 +from .utils.app_1.models import App1PostContent, App1TitleContent +from .utils.app_2.models import App2PostContent, App2TitleContent class CMSConfigTest(CMSTestCase, TestCase): @@ -27,41 +28,31 @@ def setUp(self): def test_missing_versioning_enabled(self): extension = ModerationExtension() cms_config = Mock( - moderated_models=[TestModel1, TestModel2, TestModel3, TestModel4], + moderated_models=[App1PostContent, App1TitleContent, App2PostContent, App2TitleContent], djangocms_moderation_enabled=True, djangocms_versioning_enabled=False, app_config=Mock(label='blah_cms_config') ) - with self.assertRaises(ImproperlyConfigured): + err_msg = 'Versioning needs to be enabled for Moderation' + with self.assertRaisesMessage(ImproperlyConfigured, err_msg): extension.configure_app(cms_config) - def test_moderated_model_not_in_versioning_models(self): + @patch('django.apps.apps.get_app_config') + def test_model_not_in_versionables_by_content(self, get_app_config): extension = ModerationExtension() cms_config = Mock( + moderated_models=[App1PostContent], djangocms_moderation_enabled=True, - moderated_models=[TestModel1, TestModel2, TestModel3, TestModel4], - versioning_models=[TestModel3, TestModel4], + djangocms_versioning_enabled=True, app_config=Mock(label='blah_cms_config') ) - with self.assertRaises(ImproperlyConfigured): - extension.configure_app(cms_config) + err_msg = 'Moderated model %s need to be Versionable, please include every model that ' \ + 'needs to be moderated in djangocms_versioning VersionableItem entry' % App1PostContent - def test_valid_cms_config(self): - extension = ModerationExtension() - cms_config = Mock( - djangocms_moderation_enabled=True, - moderated_models=[TestModel1, TestModel2, TestModel3, TestModel4], - versioning_models=[TestModel1, TestModel2, TestModel3, TestModel4], - app_config=Mock(label='blah_cms_config') - ) - - extension.configure_app(cms_config) - self.assertTrue(TestModel1 in extension.moderated_models) - self.assertTrue(TestModel2 in extension.moderated_models) - self.assertTrue(TestModel3 in extension.moderated_models) - self.assertTrue(TestModel4 in extension.moderated_models) + with self.assertRaisesMessage(ImproperlyConfigured, err_msg): + extension.configure_app(cms_config) class CMSConfigIntegrationTest(CMSTestCase): @@ -69,10 +60,15 @@ class CMSConfigIntegrationTest(CMSTestCase): def setUp(self): app_registration.get_cms_extension_apps.cache_clear() app_registration.get_cms_config_apps.cache_clear() + self.moderated_models = (App2PostContent, App2TitleContent, + App1PostContent, App1TitleContent) def test_config_with_two_apps(self): setup_cms_apps() moderation_config = apps.get_app_config('djangocms_moderation') registered_model = moderation_config.cms_extension.moderated_models + for model in self.moderated_models: + self.assertIn(model, registered_model) + self.assertEqual(len(registered_model), 4) diff --git a/tests/utils/app_1/cms_config.py b/tests/utils/app_1/cms_config.py index bb1b63fd..83ec92c4 100644 --- a/tests/utils/app_1/cms_config.py +++ b/tests/utils/app_1/cms_config.py @@ -1,10 +1,22 @@ from cms.app_base import CMSAppConfig -from .models import TestModel3, TestModel4 +from djangocms_versioning.datastructures import VersionableItem + +from .models import App1PostContent, App1TitleContent class CMSApp1Config(CMSAppConfig): - djangocms_versioning_enabled = True djangocms_moderation_enabled = True - versioning_models = [TestModel3, TestModel4] - moderated_models = [TestModel3, TestModel4] + djangocms_versioning_enabled = True + moderated_models = (App1PostContent, App1TitleContent) + + versioning = [ + VersionableItem( + content_model=App1PostContent, + grouper_field_name='post' + ), + VersionableItem( + content_model=App1TitleContent, + grouper_field_name='title' + ) + ] diff --git a/tests/utils/app_1/models.py b/tests/utils/app_1/models.py index d58a1a5e..c0dd2ec2 100644 --- a/tests/utils/app_1/models.py +++ b/tests/utils/app_1/models.py @@ -1,15 +1,17 @@ from django.db import models -from djangocms_versioning.models import BaseVersion +class App1Post(models.Model): + pass -class GrouperModel(models.Model): - content = models.CharField(max_length=255) +class App1PostContent(models.Model): + post = models.ForeignKey(App1Post, on_delete=models.CASCADE) -class TestModel3(BaseVersion): - content = models.ForeignKey(GrouperModel) +class App1Title(models.Model): + pass -class TestModel4(BaseVersion): - content = models.ForeignKey(GrouperModel) + +class App1TitleContent(models.Model): + title = models.ForeignKey(App1Title, on_delete=models.CASCADE) diff --git a/tests/utils/app_2/cms_config.py b/tests/utils/app_2/cms_config.py index 2b5827f0..425d28a6 100644 --- a/tests/utils/app_2/cms_config.py +++ b/tests/utils/app_2/cms_config.py @@ -1,10 +1,22 @@ from cms.app_base import CMSAppConfig -from .models import TestModel1, TestModel2 +from djangocms_versioning.datastructures import VersionableItem +from .models import App2PostContent, App2TitleContent -class CMSApp2Config(CMSAppConfig): - djangocms_versioning_enabled = True + +class CMSApp1Config(CMSAppConfig): djangocms_moderation_enabled = True - versioning_models = [TestModel1, TestModel2] - moderated_models = [TestModel1, TestModel2] + djangocms_versioning_enabled = True + moderated_models = (App2PostContent, App2TitleContent) + + versioning = [ + VersionableItem( + content_model=App2PostContent, + grouper_field_name='post' + ), + VersionableItem( + content_model=App2TitleContent, + grouper_field_name='title' + ) + ] diff --git a/tests/utils/app_2/models.py b/tests/utils/app_2/models.py index 5e92c245..692ba5a5 100644 --- a/tests/utils/app_2/models.py +++ b/tests/utils/app_2/models.py @@ -1,15 +1,17 @@ from django.db import models -from djangocms_versioning.models import BaseVersion +class App2Post(models.Model): + pass -class GrouperModel(models.Model): - content = models.CharField(max_length=255) +class App2PostContent(models.Model): + post = models.ForeignKey(App2Post, on_delete=models.CASCADE) -class TestModel1(BaseVersion): - content = models.ForeignKey(GrouperModel) +class App2Title(models.Model): + pass -class TestModel2(BaseVersion): - content = models.ForeignKey(GrouperModel) + +class App2TitleContent(models.Model): + title = models.ForeignKey(App2Title, on_delete=models.CASCADE) From 51bbd9064c1f9ecf78b9c28129deab9b647d5115 Mon Sep 17 00:00:00 2001 From: Matus Moravcik Date: Tue, 7 Aug 2018 14:11:12 +0100 Subject: [PATCH 007/147] Added submit collection for moderation (#38) --- djangocms_moderation/admin.py | 32 +++- djangocms_moderation/emails.py | 36 ++-- djangocms_moderation/forms.py | 57 +++--- djangocms_moderation/models.py | 60 ++++--- .../emails/moderation-request/request.txt | 19 +- .../moderation_request_change_list.html | 10 ++ .../djangocms_moderation/request_form.html | 2 +- djangocms_moderation/views.py | 55 +++++- tests/test_forms.py | 74 +++----- tests/test_models.py | 88 +++++---- tests/test_views.py | 170 +++++++----------- 11 files changed, 322 insertions(+), 281 deletions(-) diff --git a/djangocms_moderation/admin.py b/djangocms_moderation/admin.py index 95618341..9e405ead 100644 --- a/djangocms_moderation/admin.py +++ b/djangocms_moderation/admin.py @@ -109,23 +109,33 @@ def changelist_view(self, request, extra_context=None): pass else: extra_context = dict(collection=collection) + if collection.allow_submit_for_moderation: + submit_for_moderation_url = reverse( + 'admin:cms_moderation_submit_collection_for_moderation', + args=(collection_id,) + ) + extra_context['submit_for_moderation_url'] = submit_for_moderation_url return super(ModerationRequestAdmin, self).changelist_view(request, extra_context) def get_status(self, obj): + last_action = obj.get_last_action() if obj.is_approved(): status = ugettext('Ready for publishing') elif obj.is_active and obj.has_pending_step(): next_step = obj.get_next_required() role = next_step.role.name status = ugettext('Pending %(role)s approval') % {'role': role} - else: - last_action = obj.get_last_action() + elif last_action: + # We can have moderation requests without any action (e.g. the + # ones not submitted for moderation yet) user_name = last_action.get_by_user_name() message_data = { 'action': last_action.get_action_display(), 'name': user_name, } status = ugettext('%(action)s by %(name)s') % message_data + else: + status = ugettext('Ready for submission') return status get_status.short_description = _('Status') @@ -194,6 +204,19 @@ def status(self, obj): return _("Collection") status.short_description = _('Status') + def get_urls(self): + def _url(regex, fn, name, **kwargs): + return url(regex, self.admin_site.admin_view(fn), kwargs=kwargs, name=name) + + url_patterns = [ + _url( + '^(?P\d+)/submit-for-review/$', + views.submit_collection_for_moderation, + name="cms_moderation_submit_collection_for_moderation", + ), + ] + return url_patterns + super(ModerationCollectionAdmin, self).get_urls() + class ExtendedPageAdmin(PageAdmin): @@ -203,11 +226,6 @@ def _url(regex, fn, name, **kwargs): return url(regex, self.admin_site.admin_view(fn), kwargs=kwargs, name=name) url_patterns = [ - _url( - r'^([0-9]+)/([a-z\-]+)/moderation/new/$', - views.new_moderation_request, - 'new_request', - ), _url( r'^([0-9]+)/([a-z\-]+)/moderation/resubmit/$', views.resubmit_moderation_request, diff --git a/djangocms_moderation/emails.py b/djangocms_moderation/emails.py index 9c88cb55..c68b210e 100644 --- a/djangocms_moderation/emails.py +++ b/djangocms_moderation/emails.py @@ -4,12 +4,7 @@ from django.core.mail import EmailMessage from django.template.loader import render_to_string from django.utils.encoding import force_text -from django.utils.translation import ( - override as force_language, - ugettext_lazy as _, -) - -from cms.utils.conf import get_cms_setting +from django.utils.translation import ugettext_lazy as _ from .utils import get_absolute_url @@ -30,12 +25,7 @@ } -def _send_email(request, action, recipients, subject, template): - obj = request.content_object - edit_on = get_cms_setting('CMS_TOOLBAR_URL__EDIT_ON') - page_url = obj.get_absolute_url(request.language) + '?' + edit_on - author_name = request.get_first_action().get_by_user_name() - +def _send_email(collection, action, recipients, subject, template): if action.to_user_id: moderator_name = action.get_to_user_name() elif action.to_role_id: @@ -43,23 +33,19 @@ def _send_email(request, action, recipients, subject, template): else: moderator_name = '' - site = obj.node.site - admin_url = reverse('admin:djangocms_moderation_moderationrequest_change', args=(request.pk,)) + admin_url = reverse('admin:djangocms_moderation_moderationcollection_change', args=(collection.pk,)) context = { - 'page': obj, - 'page_url': get_absolute_url(page_url, site), - 'author_name': author_name, + 'collection': collection, + 'author_name': collection.author_name, 'by_user_name': action.get_by_user_name(), 'moderator_name': moderator_name, - 'job_id': request.pk, - 'comment': request.get_last_action().message, - 'admin_url': get_absolute_url(admin_url, site), + 'admin_url': get_absolute_url(admin_url), } template = 'djangocms_moderation/emails/moderation-request/{}'.format(template) - with force_language(request.language): - subject = force_text(subject) - content = render_to_string(template, context) + # TODO What language should the email be sent in? e.g. `with force_language(lang):` + subject = force_text(subject) + content = render_to_string(template, context) message = EmailMessage( subject=subject, @@ -88,7 +74,7 @@ def notify_request_author(request, action): return status -def notify_requested_moderator(request, action): +def notify_collection_moderators(collection, action): if action.to_user_id and not action.to_user.email: return 0 @@ -102,7 +88,7 @@ def notify_requested_moderator(request, action): return 0 status = _send_email( - request=request, + collection=collection, action=action, recipients=recipients, subject=_('Review requested'), diff --git a/djangocms_moderation/forms.py b/djangocms_moderation/forms.py index f20f9068..62b27bd6 100644 --- a/djangocms_moderation/forms.py +++ b/djangocms_moderation/forms.py @@ -41,7 +41,7 @@ def validate_unique(self): selected_roles.append(selected_role.pk) -class ModerationRequestForm(forms.Form): +class UpdateModerationRequestForm(forms.Form): moderator = forms.ModelChoiceField( label=_('moderator'), queryset=get_user_model().objects.none(), @@ -60,28 +60,8 @@ def __init__(self, *args, **kwargs): self.user = kwargs.pop('user') self.workflow = kwargs.pop('workflow') self.active_request = kwargs.pop('active_request') - super(ModerationRequestForm, self).__init__(*args, **kwargs) - - if 'moderator' in self.fields: - self.configure_moderator_field() - - def configure_moderator_field(self): - next_role = self.workflow.first_step.role - users = next_role.get_users_queryset().exclude(pk=self.user.pk) - self.fields['moderator'].empty_label = ugettext('Any {role}').format(role=next_role.name) - self.fields['moderator'].queryset = users - - def save(self): - self.workflow.submit_new_request( - obj=self.page, - by_user=self.user, - to_user=self.cleaned_data.get('moderator'), - language=self.language, - message=self.cleaned_data['message'], - ) - - -class UpdateModerationRequestForm(ModerationRequestForm): + super(UpdateModerationRequestForm, self).__init__(*args, **kwargs) + self.configure_moderator_field() def configure_moderator_field(self): # For cancelling and rejecting, we don't need to display a moderator @@ -116,3 +96,34 @@ def save(self): to_user=self.cleaned_data.get('moderator'), message=self.cleaned_data['message'], ) + + +class SubmitCollectionForModerationForm(forms.Form): + moderator = forms.ModelChoiceField( + label=_('moderator'), + queryset=get_user_model().objects.none(), + required=False, + ) + + def __init__(self, *args, **kwargs): + self.collection = kwargs.pop('collection') + self.user = kwargs.pop('user') + super(SubmitCollectionForModerationForm, self).__init__(*args, **kwargs) + self.configure_moderator_field() + + def configure_moderator_field(self): + next_role = self.collection.workflow.first_step.role + users = next_role.get_users_queryset().exclude(pk=self.user.pk) + self.fields['moderator'].empty_label = ugettext('Any {role}').format(role=next_role.name) + self.fields['moderator'].queryset = users + + def clean(self): + if not self.collection.allow_submit_for_moderation: + self.add_error(None, _("This collection can't be submitted for a review")) + return super(SubmitCollectionForModerationForm, self).clean() + + def save(self): + self.collection.submit_for_moderation( + by_user=self.user, + to_user=self.cleaned_data.get('moderator'), + ) diff --git a/djangocms_moderation/models.py b/djangocms_moderation/models.py index 16c9d795..9140da53 100644 --- a/djangocms_moderation/models.py +++ b/djangocms_moderation/models.py @@ -16,7 +16,7 @@ from cms.models.fields import PlaceholderField -from .emails import notify_request_author, notify_requested_moderator +from .emails import notify_collection_moderators from .exceptions import CollectionIsLocked, ObjectAlreadyInCollection from .utils import generate_compliance_number @@ -200,23 +200,6 @@ def has_active_request(self, page, language): lookup = self._lookup_active_request(page, language) return lookup.exists() - @transaction.atomic - def submit_new_request(self, by_user, obj, language, message='', to_user=None): - request = self.requests.create( - content_object=obj, - language=language, - is_active=True, - workflow=self, - ) - new_action = request.actions.create( - by_user=by_user, - to_user=to_user, - action=constants.ACTION_STARTED, - message=message, - ) - notify_requested_moderator(request, new_action) - return request - @python_2_unicode_compatible class WorkflowStep(models.Model): @@ -287,6 +270,37 @@ class ModerationCollection(models.Model): def __str__(self): return self.name + @property + def author_name(self): + return self.author.get_full_name() or self.author.get_username() + + def submit_for_moderation(self, by_user, to_user=None): + """ + Submit all the moderation requests belonging to this collection for + moderation and mark the collection as locked + """ + for moderation_request in self.moderation_requests.all(): + action = moderation_request.actions.create( + by_user=by_user, + to_user=to_user, + action=constants.ACTION_STARTED, + ) + # Lock the collection as it has been now submitted for moderation + self.is_locked = True + self.save(update_fields=['is_locked']) + # It is fine to pass any `action` from any moderation_request.actions + # above as it will have the same moderators + notify_collection_moderators(collection=self, action=action) + + @property + def allow_submit_for_moderation(self): + """ + Can this collection submitted for moderation? + :return: + """ + # TODO limited check for now, consider makings this a model field + return not self.is_locked and self.moderation_requests.exists() + def add_object(self, content_object): """ Add object to the ModerationRequest in this collection. @@ -412,7 +426,7 @@ def update_status(self, action, by_user, message='', to_user=None): ) self.save(update_fields=['is_active']) - new_action = self.actions.create( + self.actions.create( by_user=by_user, to_user=to_user, action=action, @@ -420,10 +434,6 @@ def update_status(self, action, by_user, message='', to_user=None): step_approved=step_approved, ) - if new_action.to_user_id or new_action.to_role_id: - notify_requested_moderator(self, new_action) - notify_request_author(self, new_action) - if self.should_set_compliance_number(): self.set_compliance_number() @@ -507,7 +517,7 @@ def user_can_moderate(self, user): return False def user_is_author(self, user): - return user == self.get_first_action().by_user + return user == self.author def user_can_view_comments(self, user): return self.user_is_author(user) or self.user_can_moderate(user) @@ -597,7 +607,7 @@ def get_to_user_name(self): return self._get_user_name(self.to_user) def _get_user_name(self, user): - return user.get_full_name() or getattr(user, user.USERNAME_FIELD) + return user.get_full_name() or user.get_username() def save(self, **kwargs): """ diff --git a/djangocms_moderation/templates/djangocms_moderation/emails/moderation-request/request.txt b/djangocms_moderation/templates/djangocms_moderation/emails/moderation-request/request.txt index c4501a8c..01c003c3 100644 --- a/djangocms_moderation/templates/djangocms_moderation/emails/moderation-request/request.txt +++ b/djangocms_moderation/templates/djangocms_moderation/emails/moderation-request/request.txt @@ -1,14 +1,29 @@ {% load i18n %} -{% blocktrans %} -Hello {{ moderator_name }}, {{ by_user_name }} has requested your approval for changes in {{ page_url }}. +{% blocktrans with collection.name as collection_name %} +Hello {{ moderator_name }}, {{ by_user_name }} has requested your approval for +changes in the collection {{ collection_name }}. {% endblocktrans %} +

+{% trans 'Items included in this collection:' %} +

+
    +{% for moderation_request in collection.moderation_requests.all %} +
  • + {{ moderation_request.pk }} - {{ moderation_request.content_object }} ({{ moderation_request.content_type }}) +
  • +{% endfor %} +
+ + {% if comment %} {% trans 'Comment' %}:
{{ comment }}
{% endif %} +{% if job_id %} {% trans 'Job ID' %}: {{ job_id }}
+{% endif %} {% if admin_url %} {% trans 'Admin Url' %}:
diff --git a/djangocms_moderation/templates/djangocms_moderation/moderation_request_change_list.html b/djangocms_moderation/templates/djangocms_moderation/moderation_request_change_list.html index ee4addab..8516d60a 100644 --- a/djangocms_moderation/templates/djangocms_moderation/moderation_request_change_list.html +++ b/djangocms_moderation/templates/djangocms_moderation/moderation_request_change_list.html @@ -1,4 +1,14 @@ {% extends "admin/change_list.html" %} +{% load i18n %} + +{% block object-tools-items %} +{{ block.super }} +{% if submit_for_moderation_url %} +
  • + {% trans 'Submit for review' %} +
  • +{% endif %} +{% endblock %} {% block content %} {{ block.super }} diff --git a/djangocms_moderation/templates/djangocms_moderation/request_form.html b/djangocms_moderation/templates/djangocms_moderation/request_form.html index c7ded030..546d3902 100644 --- a/djangocms_moderation/templates/djangocms_moderation/request_form.html +++ b/djangocms_moderation/templates/djangocms_moderation/request_form.html @@ -2,7 +2,7 @@ {% load i18n admin_urls %} {% block content %} - {% if errors %} + {% if errors or adminform.non_field_errors %}

    {% blocktrans count errors|length as counter %}Please correct the error below.{% plural %}Please correct the errors below.{% endblocktrans %}

    diff --git a/djangocms_moderation/views.py b/djangocms_moderation/views.py index 92568a9a..135bb288 100644 --- a/djangocms_moderation/views.py +++ b/djangocms_moderation/views.py @@ -13,7 +13,10 @@ from cms.utils.urlutils import add_url_parameters -from .forms import ModerationRequestForm, UpdateModerationRequestForm +from .forms import ( + SubmitCollectionForModerationForm, + UpdateModerationRequestForm, +) from .helpers import ( get_active_moderation_request, get_moderation_workflow, @@ -22,6 +25,7 @@ from .models import ( ConfirmationFormSubmission, ConfirmationPage, + ModerationCollection, ModerationRequest, ) from .utils import get_admin_url @@ -135,13 +139,6 @@ def get_context_data(self, **kwargs): return context -new_moderation_request = ModerationRequestView.as_view( - action=constants.ACTION_STARTED, - page_title=_('Submit for moderation'), - form_class=ModerationRequestForm, - success_message=_('The page has been sent for moderation.'), -) - cancel_moderation_request = ModerationRequestView.as_view( action=constants.ACTION_CANCELLED, page_title=_('Cancel request'), @@ -240,3 +237,45 @@ def moderation_confirmation_page(request, confirmation_id): reviewed=True, ) return render(request, confirmation_page_instance.template, context) + + +class SubmitCollectionForModeration(FormView): + template_name = 'djangocms_moderation/request_form.html' + form_class = SubmitCollectionForModerationForm + collection = None # Populated in dispatch method + + def dispatch(self, request, *args, **kwargs): + self.collection = get_object_or_404( + ModerationCollection, + pk=self.kwargs['collection_id'], + ) + return super(SubmitCollectionForModeration, self).dispatch(request, *args, **kwargs) + + def get_form_kwargs(self): + kwargs = super(SubmitCollectionForModeration, self).get_form_kwargs() + kwargs['collection'] = self.collection + kwargs['user'] = self.request.user + return kwargs + + def form_valid(self, form): + form.save() + messages.success(self.request, _("Your collection has been submitted for a review")) + # Redirect back to the collection filtered moderation request change list + redirect_url = reverse('admin:djangocms_moderation_moderationrequest_changelist') + redirect_url = "{}?collection__id__exact={}".format( + redirect_url, + self.collection.id + ) + return HttpResponseRedirect(redirect_url) + + def get_context_data(self, **kwargs): + context = super(SubmitCollectionForModeration, self).get_context_data(**kwargs) + context.update({ + 'opts': ModerationCollection._meta, + 'title': _('Submit collection for review'), + 'adminform': context['form'], + }) + return context + + +submit_collection_for_moderation = SubmitCollectionForModeration.as_view() diff --git a/tests/test_forms.py b/tests/test_forms.py index c622e4a8..28cd3eb3 100644 --- a/tests/test_forms.py +++ b/tests/test_forms.py @@ -1,59 +1,18 @@ -from mock import MagicMock +import mock from django.contrib.auth.models import User from django.forms import HiddenInput from djangocms_moderation import constants from djangocms_moderation.forms import ( - ModerationRequestForm, + SubmitCollectionForModerationForm, UpdateModerationRequestForm, ) +from djangocms_moderation.models import ModerationCollection from .utils.base import BaseTestCase -class ModerationRequestFormTest(BaseTestCase): - - def test_form_init(self): - form = ModerationRequestForm( - action=constants.ACTION_STARTED, - language='en', - page=self.pg2, - user=self.user, - workflow=self.wf1, - active_request=None, - ) - self.assertIn('moderator', form.fields) - field_moderator = form.fields['moderator'] - self.assertEqual(field_moderator.empty_label, 'Any Role 1') - self.assertQuerysetEqual(field_moderator.queryset, User.objects.none()) - - def test_form_save(self): - data = { - 'moderator': None, - 'message': 'Some message' - } - form = ModerationRequestForm( - data, - action=constants.ACTION_STARTED, - language='en', - page=self.pg2, - user=self.user, - workflow=self.wf1, - active_request=None, - ) - form.workflow.submit_new_request = MagicMock() - self.assertTrue(form.is_valid()) - form.save() - form.workflow.submit_new_request.assert_called_once_with( - obj=self.pg2, - by_user=self.user, - to_user=None, - language='en', - message='Some message', - ) - - class UpdateModerationRequestFormTest(BaseTestCase): def test_form_init_approved_action(self): @@ -65,7 +24,6 @@ def test_form_init_approved_action(self): workflow=self.wf1, active_request=self.moderation_request1, ) - self.assertIsInstance(form, ModerationRequestForm) field_moderator = form.fields['moderator'] self.assertEqual(field_moderator.empty_label, 'Any Role 2') self.assertQuerysetEqual( @@ -115,7 +73,7 @@ def test_form_save(self): workflow=self.wf1, active_request=self.moderation_request1, ) - form.active_request.update_status = MagicMock() + form.active_request.update_status = mock.MagicMock() self.assertTrue(form.is_valid()) form.save() form.active_request.update_status.assert_called_once_with( @@ -124,3 +82,27 @@ def test_form_save(self): to_user=None, message='Approved message', ) + + +class SubmitCollectionForModerationFormTest(BaseTestCase): + @mock.patch.object(ModerationCollection, 'allow_submit_for_moderation') + def test_form_is_invalid_if_collection_cant_be_submitted_for_review(self, allow_submit_mock): + data = { + 'moderator': None, + } + + allow_submit_mock.__get__ = mock.Mock(return_value=False) + form = SubmitCollectionForModerationForm( + data, + collection=self.collection1, + user=self.user, + ) + self.assertFalse(form.is_valid()) + + allow_submit_mock.__get__ = mock.Mock(return_value=True) + form = SubmitCollectionForModerationForm( + data, + collection=self.collection1, + user=self.user, + ) + self.assertTrue(form.is_valid()) diff --git a/tests/test_models.py b/tests/test_models.py index c90d7af6..de6e086b 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,6 +1,5 @@ import json from mock import patch -from unittest import skip from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType @@ -71,23 +70,6 @@ def test_multiple_defaults_validation_error(self): def test_first_step(self): self.assertEqual(self.wf1.first_step, self.wf1st1) - @skip('4.0 rework TBC') - @patch('djangocms_moderation.models.notify_requested_moderator') - def test_submit_new_request(self, mock_nrm): - request = self.wf1.submit_new_request( - by_user=self.user, - obj=self.pg3, - language='en', - message='Some message', - ) - self.assertQuerysetEqual( - request.actions.all(), - ModerationRequestAction.objects.filter(request=request), - transform=lambda x: x, - ordered=False, - ) - self.assertEqual(mock_nrm.call_count, 1) - class WorkflowStepTest(BaseTestCase): @@ -258,9 +240,7 @@ def test_user_can_moderate(self): self.assertTrue(self.moderation_request4.user_can_moderate(self.user3)) self.assertFalse(self.moderation_request4.user_can_moderate(user4)) - @patch('djangocms_moderation.models.notify_request_author') - @patch('djangocms_moderation.models.notify_requested_moderator') - def test_update_status_action_approved(self, mock_nrm, mock_nra): + def test_update_status_action_approved(self): self.moderation_request1.update_status( action=constants.ACTION_APPROVED, by_user=self.user, @@ -268,12 +248,8 @@ def test_update_status_action_approved(self, mock_nrm, mock_nra): ) self.assertTrue(self.moderation_request1.is_active) self.assertEqual(len(self.moderation_request1.actions.filter(is_archived=False)), 2) - self.assertEqual(mock_nrm.call_count, 1) - self.assertEqual(mock_nra.call_count, 1) - @patch('djangocms_moderation.models.notify_request_author') - @patch('djangocms_moderation.models.notify_requested_moderator') - def test_update_status_action_rejected(self, mock_nrm, mock_nra): + def test_update_status_action_rejected(self): self.moderation_request1.update_status( action=constants.ACTION_REJECTED, by_user=self.user, @@ -282,14 +258,7 @@ def test_update_status_action_rejected(self, mock_nrm, mock_nra): self.assertTrue(self.moderation_request1.is_active) self.assertEqual(len(self.moderation_request1.actions.all()), 2) - self.assertEqual(mock_nra.call_count, 1) - # No need to notify the moderator, as this is assigned back to the - # content author - self.assertFalse(mock_nrm.called) - - @patch('djangocms_moderation.models.notify_request_author') - @patch('djangocms_moderation.models.notify_requested_moderator') - def test_update_status_action_resubmitted(self, mock_nrm, mock_nra): + def test_update_status_action_resubmitted(self): self.moderation_request1.update_status( action=constants.ACTION_RESUBMITTED, by_user=self.user, @@ -298,9 +267,6 @@ def test_update_status_action_resubmitted(self, mock_nrm, mock_nra): self.assertTrue(self.moderation_request1.is_active) self.assertEqual(len(self.moderation_request1.actions.all()), 2) - self.assertEqual(mock_nra.call_count, 1) - self.assertEqual(mock_nrm.call_count, 1) - def test_compliance_number_is_generated(self): self.wf1.requires_compliance_number = True self.assertTrue(self.moderation_request1.has_required_pending_steps()) @@ -523,6 +489,52 @@ def setUp(self): self.page1 = create_page(title='My page 1', template='page.html', language='en',) self.page2 = create_page(title='My page 2', template='page.html', language='en',) + def test_allow_submit_for_moderation(self): + self.collection1.is_locked = False + self.collection1.save() + # This is false, as we don't have any moderation requests in this collection + self.assertFalse(self.collection1.allow_submit_for_moderation) + + ModerationRequest.objects.create( + content_object=self.pg1, collection=self.collection1, is_active=True + ) + self.assertTrue(self.collection1.allow_submit_for_moderation) + + self.collection1.is_locked = True + self.collection1.save() + self.assertFalse(self.collection1.allow_submit_for_moderation) + + @patch('djangocms_moderation.models.notify_collection_moderators') + def test_submit_for_moderation(self, mock_ncm): + ModerationRequest.objects.create( + content_object=self.pg1, language='en', collection=self.collection1 + ) + ModerationRequest.objects.create( + content_object=self.pg3, language='en', collection=self.collection1 + ) + + self.assertFalse( + ModerationRequestAction.objects.filter( + request__collection=self.collection1 + ).exists() + ) + + self.collection1.is_locked = False + self.collection1.save() + + self.collection1.submit_for_moderation(self.user, None) + self.assertEqual(1, mock_ncm.call_count) + + self.collection1.refresh_from_db() + # Collection should lock itself + self.assertTrue(self.collection1.is_locked) + # We will now have 2 actions with status STARTED. + self.assertEqual( + 2, ModerationRequestAction.objects.filter( + request__collection=self.collection1, action=constants.ACTION_STARTED + ).count() + ) + def _moderation_requests_count(self, obj, collection=None): """ How many moderation requests are there [for a given collection] @@ -562,7 +574,7 @@ def test_add_object(self): self.assertEqual(1, self._moderation_requests_count(self.page2, self.collection1)) self.assertEqual(1, self._moderation_requests_count(self.page1, self.collection1)) - def test_create_moderation_request_from_content_object_locked_collection(self): + def test_add_object_locked_collection(self): # This works, as the collection is not locked self.collection2.add_object(self.page1) self.assertEqual(1, self._moderation_requests_count(self.page1)) diff --git a/tests/test_views.py b/tests/test_views.py index c3ae1b71..70ce9cd9 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -1,20 +1,18 @@ import json -from mock import patch -from unittest import skip +import mock from django.contrib.auth.models import User +from django.urls import reverse from django.utils.translation import ugettext_lazy as _ from cms.utils.urlutils import add_url_parameters from djangocms_moderation import constants -from djangocms_moderation.forms import ( - ModerationRequestForm, - UpdateModerationRequestForm, -) +from djangocms_moderation.forms import UpdateModerationRequestForm from djangocms_moderation.models import ( ConfirmationFormSubmission, ConfirmationPage, + ModerationCollection, ) from djangocms_moderation.utils import get_admin_url @@ -36,46 +34,6 @@ def _assert_render(self, response, page, action, workflow, active_request, form_ self.assertEqual(response.context_data['title'], title) self.assertIsInstance(form, form_cls) - def test_new_request_view_with_form(self): - response = self.client.get( - get_admin_url( - name='cms_moderation_new_request', - language='en', - args=(self.pg2.pk, 'en') - ) - ) - self._assert_render( - response=response, - page=self.pg2, - action=constants.ACTION_STARTED, - active_request=None, - workflow=self.wf1, - form_cls=ModerationRequestForm, - title=_('Submit for moderation') - ) - - @skip('4.0 rework TBC') - def test_new_request_view_with_form_workflow_passed_param(self): - response = self.client.get( - '{}?{}'.format( - get_admin_url( - name='cms_moderation_new_request', - language='en', - args=(self.pg2.pk, 'en') - ), - 'workflow={}'.format(self.wf2.pk) - ) - ) - self._assert_render( - response=response, - page=self.pg2, - action=constants.ACTION_STARTED, - active_request=None, - workflow=self.wf2, - form_cls=ModerationRequestForm, - title=_('Submit for moderation') - ) - def test_cancel_request_view_with_form(self): response = self.client.get(get_admin_url( name='cms_moderation_cancel_request', @@ -140,56 +98,6 @@ def test_approve_request_view_with_form(self): title=_('Approve changes') ) - def test_get_form_kwargs(self): - response = self.client.get(get_admin_url( - name='cms_moderation_new_request', - language='en', - args=(self.pg2.pk, 'en') - )) - view = response.context_data['view'] - kwargs = view.get_form_kwargs() - self.assertEqual(kwargs.get('action'), view.action) - self.assertEqual(kwargs.get('language'), view.language) - self.assertEqual(kwargs.get('page'), view.page) - self.assertEqual(kwargs.get('user'), view.request.user) - self.assertEqual(kwargs.get('workflow'), view.workflow) - self.assertEqual(kwargs.get('active_request'), view.active_request) - - @skip('4.0 rework TBC') - def test_form_valid(self): - response = self.client.post(get_admin_url( - name='cms_moderation_new_request', - language='en', - args=(self.pg2.pk, 'en') - ), {'moderator': '', 'message': 'Some review message'}) - self.assertEqual(response.status_code, 200) - self.assertContains(response, 'reloadBrowser') # check html part - - def test_throws_error_moderation_already_exists(self): - response = self.client.get('{}?{}'.format( - get_admin_url( - name='cms_moderation_new_request', - language='en', - args=(self.pg1.pk, 'en') - ), - 'workflow={}'.format(self.wf1.pk) # pg1 => active request - )) - self.assertEqual(response.status_code, 400) - self.assertEqual(response.content, b'Page already has an active moderation request.') - - @skip('4.0 rework TBC') - def test_throws_error_invalid_workflow_passed(self): - response = self.client.get('{}?{}'.format( - get_admin_url( - name='cms_moderation_new_request', - language='en', - args=(self.pg2.pk, 'en') - ), - 'workflow=10' # pg2 => no active requests, 10 => workflow does not exist - )) - self.assertEqual(response.status_code, 400) - self.assertEqual(response.content, b'No moderation workflow exists for page.') - def test_throws_no_active_moderation_request(self): response = self.client.get(get_admin_url( name='cms_moderation_cancel_request', @@ -220,16 +128,6 @@ def test_throws_error_forbidden_user(self): self.assertEqual(response.status_code, 403) self.assertEqual(response.content, b'User is not allowed to update request.') - @patch('djangocms_moderation.views.get_moderation_workflow', return_value=None) - def test_throws_error_if_workflow_has_not_been_resolved(self, mock_gpmw): - response = self.client.get(get_admin_url( - name='cms_moderation_new_request', - language='en', - args=(self.pg2.pk, 'en') - )) - self.assertEqual(response.status_code, 400) - self.assertEqual(response.content, b'No moderation workflow exists for page.') - def _create_confirmation_page(self, moderation_request): # First delete all the form submissions for the passed moderation_request # This will make sure there are no form submissions @@ -389,3 +287,63 @@ def test_renders_post_view(self): reviewed=True, ) self.assertEqual(response.context['redirect_url'], redirect_url) + + +class SubmitCollectionForModerationViewTest(BaseViewTestCase): + def setUp(self): + super(SubmitCollectionForModerationViewTest, self).setUp() + self.url = reverse( + 'admin:cms_moderation_submit_collection_for_moderation', + args=(self.collection2.pk,) + ) + request_change_list_url = reverse('admin:djangocms_moderation_moderationrequest_changelist') + self.request_change_list_url = "{}?collection__id__exact={}".format( + request_change_list_url, + self.collection2.pk + ) + + @mock.patch.object(ModerationCollection, 'submit_for_moderation') + def test_submit_collection_for_moderation(self, submit_mock): + response = self.client.get(self.url) + self.assertEqual(200, response.status_code) + + response = self.client.post(self.url) + assert submit_mock.called + self.assertEqual(302, response.status_code) + self.assertEqual(self.request_change_list_url, response.url) + + +class ModerationRequestChangeListView(BaseViewTestCase): + def setUp(self): + super(ModerationRequestChangeListView, self).setUp() + self.collection_submit_url = reverse( + 'admin:cms_moderation_submit_collection_for_moderation', + args=(self.collection2.pk,) + ) + self.url = reverse('admin:djangocms_moderation_moderationrequest_changelist') + self.url_with_filter = "{}?collection__id__exact={}".format( + self.url, self.collection2.pk + ) + + def test_change_list_view_should_contain_collection_object(self): + response = self.client.get(self.url) + self.assertEqual(200, response.status_code) + self.assertNotIn('collection', response.context) + + response = self.client.get(self.url_with_filter) + self.assertEqual(200, response.status_code) + self.assertEqual(response.context['collection'], self.collection2) + + @mock.patch.object(ModerationCollection, 'allow_submit_for_moderation') + def test_change_list_view_should_contain_submit_collection_url(self, allow_submit_mock): + response = self.client.get(self.url) + self.assertEqual(200, response.status_code) + self.assertNotIn('submit_for_moderation_url', response.context) + + allow_submit_mock.__get__ = mock.Mock(return_value=False) + response = self.client.get(self.url_with_filter) + self.assertNotIn('submit_for_moderation_url', response.context) + + allow_submit_mock.__get__ = mock.Mock(return_value=True) + response = self.client.get(self.url_with_filter) + self.assertIn('submit_for_moderation_url', response.context) From 9307979bfbf8e9ce4b94718412506bbb2b4b2a1a Mon Sep 17 00:00:00 2001 From: Damilare Onajole Date: Thu, 9 Aug 2018 11:50:54 +0100 Subject: [PATCH 008/147] Logic to add single item to collection (#39) --- djangocms_moderation/admin.py | 60 +-- djangocms_moderation/cms_config.py | 1 - djangocms_moderation/cms_toolbars.py | 46 +++ djangocms_moderation/forms.py | 69 ++++ djangocms_moderation/models.py | 13 + .../item_to_collection.html | 73 ++++ .../djangocms_moderation/request_form.html | 2 +- djangocms_moderation/views.py | 218 +++-------- tests/test_app_registration.py | 7 +- tests/test_views.py | 353 ++++++------------ 10 files changed, 380 insertions(+), 462 deletions(-) create mode 100644 djangocms_moderation/cms_toolbars.py create mode 100644 djangocms_moderation/templates/djangocms_moderation/item_to_collection.html diff --git a/djangocms_moderation/admin.py b/djangocms_moderation/admin.py index 9e405ead..0b6ce7ca 100644 --- a/djangocms_moderation/admin.py +++ b/djangocms_moderation/admin.py @@ -7,16 +7,9 @@ from django.utils.translation import ugettext, ugettext_lazy as _ from cms.admin.placeholderadmin import PlaceholderAdminMixin -from cms.models import Page from adminsortable2.admin import SortableInlineAdminMixin -from .constants import ( - ACTION_APPROVED, - ACTION_CANCELLED, - ACTION_REJECTED, - ACTION_RESUBMITTED, -) from .forms import WorkflowStepInlineFormSet from .helpers import get_form_submission_for_step from .models import ( @@ -34,12 +27,6 @@ from . import views # isort:skip -try: - PageAdmin = admin.site._registry[Page].__class__ -except KeyError: - from cms.admin.pageadmin import PageAdmin - - class ModerationRequestActionInline(admin.TabularInline): model = ModerationRequestAction fields = ['show_user', 'message', 'date_taken', 'form_submission'] @@ -214,49 +201,13 @@ def _url(regex, fn, name, **kwargs): views.submit_collection_for_moderation, name="cms_moderation_submit_collection_for_moderation", ), - ] - return url_patterns + super(ModerationCollectionAdmin, self).get_urls() - - -class ExtendedPageAdmin(PageAdmin): - - def get_urls(self): - def _url(regex, fn, name, **kwargs): - name = 'cms_moderation_{}'.format(name) - return url(regex, self.admin_site.admin_view(fn), kwargs=kwargs, name=name) - - url_patterns = [ - _url( - r'^([0-9]+)/([a-z\-]+)/moderation/resubmit/$', - views.resubmit_moderation_request, - 'resubmit_request', - action=ACTION_RESUBMITTED, - ), _url( - r'^([0-9]+)/([a-z\-]+)/moderation/cancel/$', - views.cancel_moderation_request, - 'cancel_request', - action=ACTION_CANCELLED, - ), - _url( - r'^([0-9]+)/([a-z\-]+)/moderation/reject/$', - views.reject_moderation_request, - 'reject_request', - action=ACTION_REJECTED, - ), - _url( - r'^([0-9]+)/([a-z\-]+)/moderation/approve/$', - views.approve_moderation_request, - 'approve_request', - action=ACTION_APPROVED, - ), - _url( - r'^([0-9]+)/([a-z\-]+)/moderation/comments/$', - views.moderation_comments, - 'comments', - ), + r'^item/add-item/$', + views.add_item_to_collection, + name='cms_moderation_item_to_collection', + ) ] - return url_patterns + super(ExtendedPageAdmin, self).get_urls() + return url_patterns + super(ModerationCollectionAdmin, self).get_urls() class ConfirmationPageAdmin(PlaceholderAdminMixin, admin.ModelAdmin): @@ -313,7 +264,6 @@ def form_data(self, obj): form_data.short_description = _('Form Data') -admin.site._registry[Page] = ExtendedPageAdmin(Page, admin.site) admin.site.register(ModerationRequest, ModerationRequestAdmin) admin.site.register(ModerationCollection, ModerationCollectionAdmin) admin.site.register(Role, RoleAdmin) diff --git a/djangocms_moderation/cms_config.py b/djangocms_moderation/cms_config.py index f4e1e5db..efbc01cc 100644 --- a/djangocms_moderation/cms_config.py +++ b/djangocms_moderation/cms_config.py @@ -17,7 +17,6 @@ def configure_app(self, cms_config): raise ImproperlyConfigured('Versioning needs to be enabled for Moderation') versioning_extension = apps.get_app_config('djangocms_versioning').cms_extension - for model in moderated_models: # @todo replace this with a to be provided func from versioning_extensions if model not in versioning_extension.versionables_by_content: diff --git a/djangocms_moderation/cms_toolbars.py b/djangocms_moderation/cms_toolbars.py new file mode 100644 index 00000000..cd935a1c --- /dev/null +++ b/djangocms_moderation/cms_toolbars.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +from django.utils.translation import ugettext_lazy as _ + +from cms.api import get_page_draft +from cms.toolbar_base import CMSToolbar +from cms.toolbar_pool import toolbar_pool +from cms.utils.urlutils import add_url_parameters + +from .utils import get_admin_url + + +class ModerationToolbar(CMSToolbar): + class Media: + js = ('djangocms_moderation/js/dist/bundle.moderation.min.js',) + css = { + 'all': ('djangocms_moderation/css/moderation.css',) + } + + def post_template_populate(self): + """ + @TODO replace page object with generic content object + :return: + """ + super(ModerationToolbar, self).post_template_populate() + page = get_page_draft(self.request.current_page) + + if not page: + return None + + url = add_url_parameters( + get_admin_url( + name='cms_moderation_item_to_collection', + language=self.current_lang, + args=() + ), + content_object_id=page.pk + ) + + self.toolbar.add_modal_button( + name=_('Submit for moderation'), + url=url, + side=self.toolbar.RIGHT, + ) + + +toolbar_pool.register(ModerationToolbar) diff --git a/djangocms_moderation/forms.py b/djangocms_moderation/forms.py index 62b27bd6..ab918efd 100644 --- a/djangocms_moderation/forms.py +++ b/djangocms_moderation/forms.py @@ -1,13 +1,17 @@ from __future__ import unicode_literals from django import forms +from django.contrib import admin +from django.contrib.admin.widgets import RelatedFieldWidgetWrapper from django.contrib.auth import get_user_model +from django.contrib.contenttypes.models import ContentType from django.forms.forms import NON_FIELD_ERRORS from django.utils.translation import ugettext, ugettext_lazy as _ from adminsortable2.admin import CustomInlineFormSet from .constants import ACTION_CANCELLED, ACTION_REJECTED, ACTION_RESUBMITTED +from .models import ModerationCollection, ModerationRequest class WorkflowStepInlineFormSet(CustomInlineFormSet): @@ -98,6 +102,71 @@ def save(self): ) +class CollectionItemForm(forms.Form): + + collection = forms.ModelChoiceField( + queryset=ModerationCollection.objects.filter(is_locked=False), + required=True + ) + content_type = forms.ModelChoiceField( + queryset=ContentType.objects.filter(app_label="cms", model="page"), + required=True, + widget=forms.HiddenInput(), + ) + content_object_id = forms.IntegerField() + + def set_collection_widget(self, request): + related_modeladmin = admin.site._registry.get(ModerationCollection) + dbfield = ModerationRequest._meta.get_field('collection') + formfield = self.fields['collection'] + formfield.widget = RelatedFieldWidgetWrapper( + formfield.widget, + dbfield.rel, + admin_site=admin.site, + can_add_related=related_modeladmin.has_add_permission(request), + can_change_related=related_modeladmin.has_change_permission(request), + can_delete_related=related_modeladmin.has_delete_permission(request), + ) + + def clean(self): + """ + Validates content_object_id: Checks that a given content_object_id has + a content_object and it is not currently part of any ModerationRequest + + :return: + """ + if self.errors: + return self.cleaned_data + + content_type = self.cleaned_data['content_type'] + + try: + content_object = content_type.get_object_for_this_type( + pk=self.cleaned_data['content_object_id'], + is_page_type=False, + publisher_is_draft=True, + ) + except content_type.model_class().DoesNotExist: + content_object = None + + if not content_object: + raise forms.ValidationError(_('Invalid content_object_id, does not exist')) + + request_with_object_exists = ModerationRequest.objects.filter( + content_type=content_type, + object_id=content_object.pk, + ).exists() + + if request_with_object_exists: + raise forms.ValidationError(_( + "{} is already part of existing moderation request which is part " + "of another active collection".format(content_object) + )) + + self.cleaned_data['content_object'] = content_object + return self.cleaned_data + + class SubmitCollectionForModerationForm(forms.Form): moderator = forms.ModelChoiceField( label=_('moderator'), diff --git a/djangocms_moderation/models.py b/djangocms_moderation/models.py index 9140da53..fc0d10b3 100644 --- a/djangocms_moderation/models.py +++ b/djangocms_moderation/models.py @@ -330,6 +330,19 @@ def add_object(self, content_object): "of another active collection".format(content_object) ) + def _add_object(self, content_object): + """ + Add object to the ModerationRequest in this collection. + Requires validation from .forms.CollectionItemForm + :return: + """ + content_type = ContentType.objects.get_for_model(content_object) + return self.moderation_requests.create( + content_type=content_type, + object_id=content_object.pk, + collection=self, + ) + @python_2_unicode_compatible class ModerationRequest(models.Model): diff --git a/djangocms_moderation/templates/djangocms_moderation/item_to_collection.html b/djangocms_moderation/templates/djangocms_moderation/item_to_collection.html new file mode 100644 index 00000000..045874bc --- /dev/null +++ b/djangocms_moderation/templates/djangocms_moderation/item_to_collection.html @@ -0,0 +1,73 @@ +{% extends "admin/change_form.html" %} +{% load i18n %} +{% load static %} +{% block content %} +
    +{% csrf_token %} +
    +

    {% trans "Add to existing collection" %}

    + {% if form.non_field_errors %} + {{ form.non_field_errors }} + {% endif %} +
    + +
    + +
    + + + + + + + + + + + + + {% for content_object in content_object_list %} + + + + + + + + + {% endfor %} + +
    {% trans "Content Type" %}{% trans "Identifier" %}{% trans "Author" %}{% trans "Edit Date" %}{% trans "Version" %}{% trans "Status" %}
    {{ content_object.content_type }}{{ content_object.content_object }}Stub Author [versioning]Stub Date [versioning]Stub VNumber [versioning]Stub Published [versioning]
    +
    +
    + +
    +
    +
    + + +{% endblock %} diff --git a/djangocms_moderation/templates/djangocms_moderation/request_form.html b/djangocms_moderation/templates/djangocms_moderation/request_form.html index 546d3902..4fc0a38a 100644 --- a/djangocms_moderation/templates/djangocms_moderation/request_form.html +++ b/djangocms_moderation/templates/djangocms_moderation/request_form.html @@ -34,7 +34,7 @@
    - +
    {% endblock %} diff --git a/djangocms_moderation/views.py b/djangocms_moderation/views.py index 135bb288..599443e9 100644 --- a/djangocms_moderation/views.py +++ b/djangocms_moderation/views.py @@ -1,201 +1,83 @@ from __future__ import unicode_literals -from django.contrib import messages +from django.contrib import admin, messages +from django.contrib.contenttypes.models import ContentType from django.core.urlresolvers import reverse -from django.http import ( - HttpResponseBadRequest, - HttpResponseForbidden, - HttpResponseRedirect, -) +from django.http import HttpResponseRedirect from django.shortcuts import get_object_or_404, render from django.utils.translation import ugettext_lazy as _ -from django.views.generic import FormView, ListView +from django.views.generic import FormView +from cms.models import Page from cms.utils.urlutils import add_url_parameters -from .forms import ( - SubmitCollectionForModerationForm, - UpdateModerationRequestForm, -) -from .helpers import ( - get_active_moderation_request, - get_moderation_workflow, - get_page_or_404, -) -from .models import ( - ConfirmationFormSubmission, - ConfirmationPage, - ModerationCollection, - ModerationRequest, -) +from .forms import CollectionItemForm, SubmitCollectionForModerationForm +from .models import ConfirmationPage, ModerationCollection from .utils import get_admin_url from . import constants # isort:skip -class ModerationRequestView(FormView): - - action = None - page_title = None - success_message = None - template_name = 'djangocms_moderation/request_form.html' +class CollectionItemView(FormView): + template_name = 'djangocms_moderation/item_to_collection.html' + form_class = CollectionItemForm success_template_name = 'djangocms_moderation/request_finalized.html' - def dispatch(self, request, *args, **kwargs): - user = request.user - self.page_id = args[0] - self.language = args[1] - self.page = get_page_or_404(self.page_id, self.language) - self.workflow = None - self.active_request = get_active_moderation_request(self.page, self.language) - - if self.active_request: - self.workflow = self.active_request.workflow - - needs_ongoing = self.action in (constants.ACTION_APPROVED, constants.ACTION_REJECTED) - - if self.action == constants.ACTION_STARTED: - # Can't start new request if there's one already. - return HttpResponseBadRequest('Page already has an active moderation request.') - elif self.active_request.is_approved() and needs_ongoing: - # Can't reject or approve a moderation request whose steps have all - # already been approved. - return HttpResponseBadRequest('Moderation request has already been approved.') - elif needs_ongoing and not self.active_request.user_can_take_moderation_action(user): - # User can't approve or reject a request where he's not part of the workflow - return HttpResponseForbidden('User is not allowed to update request.') - elif self.action == constants.ACTION_APPROVED: - next_step = self.active_request.user_get_step(self.request.user) - confirmation_is_valid = True - - if next_step and next_step.role: - confirmation_page_instance = next_step.role.confirmation_page - else: - confirmation_page_instance = None - - if confirmation_page_instance: - confirmation_is_valid = confirmation_page_instance.is_valid( - active_request=self.active_request, - for_step=next_step, - is_reviewed=request.GET.get('reviewed'), - ) - - if not confirmation_is_valid: - redirect_url = add_url_parameters( - confirmation_page_instance.get_absolute_url(), - content_view=True, - page=self.page_id, - language=self.language, - ) - return HttpResponseRedirect(redirect_url) - elif self.action != constants.ACTION_STARTED: - # All except for the new request endpoint require an active moderation request - return HttpResponseBadRequest('Page does not have an active moderation request.') - else: - self.workflow = get_moderation_workflow() + def get_form_kwargs(self): + kwargs = super(CollectionItemView, self).get_form_kwargs() + kwargs['initial'].update({ + 'content_object_id': self.request.GET.get('content_object_id'), + 'content_type': ContentType.objects.get_for_model(Page).pk, + }) + collection_id = self.request.GET.get('collection_id') - if not self.workflow: - return HttpResponseBadRequest('No moderation workflow exists for page.') - return super(ModerationRequestView, self).dispatch(request, *args, **kwargs) + if collection_id: + kwargs['initial']['collection'] = collection_id + return kwargs def form_valid(self, form): - form.save() - context = self.get_context_data(form=form) - messages.success(self.request, self.success_message) - return render(self.request, self.success_template_name, context) + content_object = form.cleaned_data['content_object'] + collection = form.cleaned_data['collection'] + collection.add_object(content_object) + messages.success(self.request, _('Item successfully added to moderation collection')) + return render(self.request, self.success_template_name, {}) - def get_form_kwargs(self): - kwargs = super(ModerationRequestView, self).get_form_kwargs() - kwargs['action'] = self.action - kwargs['language'] = self.language - kwargs['page'] = self.page - kwargs['user'] = self.request.user - kwargs['workflow'] = self.workflow - kwargs['active_request'] = self.active_request - return kwargs + def get_form(self, **kwargs): + form = super(CollectionItemView, self).get_form(**kwargs) + form.set_collection_widget(self.request) + return form def get_context_data(self, **kwargs): - opts = ModerationRequest._meta - form_submission_opts = ConfirmationFormSubmission._meta - - if self.active_request: - form_submissions = self.active_request.form_submissions.all() + """ + Gets collection_id from params or from the first collection in the list + when no ?collection_id is not supplied + + Always gets content_object_list from a collection at a time + """ + context = super(CollectionItemView, self).get_context_data(**kwargs) + opts_meta = ModerationCollection._meta + collection_id = self.request.GET.get('collection_id') + + if collection_id: + collection = ModerationCollection.objects.get(pk=collection_id) + content_object_list = collection.moderation_requests.all() else: - form_submissions = [] + content_object_list = [] - context = super(ModerationRequestView, self).get_context_data(**kwargs) + model_admin = admin.site._registry[ModerationCollection] context.update({ - 'title': self.page_title, - 'has_change_permission': True, - 'opts': opts, - 'root_path': reverse('admin:index'), - 'app_label': opts.app_label, - 'adminform': context['form'], - 'is_popup': True, - 'form_submissions': form_submissions, - 'form_submission_opts': form_submission_opts, + 'content_object_list': content_object_list, + 'opts': opts_meta, + 'title': _('Add to collection'), + 'form': self.get_form(), + 'media': model_admin.media, }) - return context - - -cancel_moderation_request = ModerationRequestView.as_view( - action=constants.ACTION_CANCELLED, - page_title=_('Cancel request'), - form_class=UpdateModerationRequestForm, - success_message=_('The moderation request has been cancelled.'), -) - -reject_moderation_request = ModerationRequestView.as_view( - action=constants.ACTION_REJECTED, - page_title=_('Send for rework'), - form_class=UpdateModerationRequestForm, - success_message=_('The moderation request has been sent for rework.'), -) - -approve_moderation_request = ModerationRequestView.as_view( - action=constants.ACTION_APPROVED, - page_title=_('Approve changes'), - form_class=UpdateModerationRequestForm, - success_message=_('The changes have been approved.'), -) - -resubmit_moderation_request = ModerationRequestView.as_view( - action=constants.ACTION_RESUBMITTED, - page_title=_('Resubmit changes'), - form_class=UpdateModerationRequestForm, - success_message=_('The request has been re-submitted.'), -) - - -class ModerationCommentsView(ListView): - template_name = 'djangocms_moderation/comment_list.html' - - def dispatch(self, request, page_id, language, *args, **kwargs): - page_obj = get_page_or_404(page_id, language) - self.active_request = get_active_moderation_request(page_obj, language) - - if not self.active_request.user_can_view_comments(request.user): - return HttpResponseForbidden('User is not allowed to view comments.') - - return super(ModerationCommentsView, self).dispatch( - request, page_id, language, *args, **kwargs - ) - - def get_queryset(self): - return self.active_request.actions.all() - - def get_context_data(self, **kwargs): - context = super(ModerationCommentsView, self).get_context_data(**kwargs) - context.update({ - 'title': _('View Comments'), - 'is_popup': True, - }) return context -moderation_comments = ModerationCommentsView.as_view() +add_item_to_collection = CollectionItemView.as_view() def moderation_confirmation_page(request, confirmation_id): diff --git a/tests/test_app_registration.py b/tests/test_app_registration.py index bca219b6..4da9719e 100644 --- a/tests/test_app_registration.py +++ b/tests/test_app_registration.py @@ -60,8 +60,11 @@ class CMSConfigIntegrationTest(CMSTestCase): def setUp(self): app_registration.get_cms_extension_apps.cache_clear() app_registration.get_cms_config_apps.cache_clear() - self.moderated_models = (App2PostContent, App2TitleContent, - App1PostContent, App1TitleContent) + + self.moderated_models = ( + App2PostContent, App2TitleContent, + App1PostContent, App1TitleContent + ) def test_config_with_two_apps(self): setup_cms_apps() diff --git a/tests/test_views.py b/tests/test_views.py index 70ce9cd9..38af5a58 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -1,292 +1,175 @@ -import json import mock from django.contrib.auth.models import User +from django.contrib.contenttypes.models import ContentType from django.urls import reverse from django.utils.translation import ugettext_lazy as _ from cms.utils.urlutils import add_url_parameters -from djangocms_moderation import constants -from djangocms_moderation.forms import UpdateModerationRequestForm -from djangocms_moderation.models import ( - ConfirmationFormSubmission, - ConfirmationPage, - ModerationCollection, -) +from djangocms_moderation.forms import CollectionItemForm +from djangocms_moderation.models import ModerationCollection, ModerationRequest from djangocms_moderation.utils import get_admin_url from .utils.base import BaseViewTestCase -class ModerationRequestViewTest(BaseViewTestCase): +class CollectionItemViewTest(BaseViewTestCase): - def _assert_render(self, response, page, action, workflow, active_request, form_cls, title): - view = response.context_data['view'] - form = response.context_data['adminform'] - self.assertEqual(response.status_code, 200) - self.assertEqual(response.template_name[0], 'djangocms_moderation/request_form.html') - self.assertEqual(view.language, 'en') - self.assertEqual(view.page, page) - self.assertEqual(view.action, action) - self.assertEqual(view.workflow, workflow) - self.assertEqual(view.active_request, active_request) - self.assertEqual(response.context_data['title'], title) - self.assertIsInstance(form, form_cls) - - def test_cancel_request_view_with_form(self): - response = self.client.get(get_admin_url( - name='cms_moderation_cancel_request', - language='en', - args=(self.pg1.pk, 'en') - )) - self._assert_render( - response=response, - page=self.pg1, - action=constants.ACTION_CANCELLED, - active_request=self.moderation_request1, - workflow=self.wf1, - form_cls=UpdateModerationRequestForm, - title=_('Cancel request') - ) + def setUp(self): - def test_reject_request_view_with_form(self): - response = self.client.get(get_admin_url( - name='cms_moderation_reject_request', - language='en', - args=(self.pg1.pk, 'en') - )) - self._assert_render( - response=response, - page=self.pg1, - action=constants.ACTION_REJECTED, - active_request=self.moderation_request1, - workflow=self.wf1, - form_cls=UpdateModerationRequestForm, - title=_('Send for rework') + self.user = User.objects.create_user( + username='test1', email='test1@test.com', password='test1', is_staff=True ) - def test_resubmit_request_view_with_form(self): - response = self.client.get(get_admin_url( - name='cms_moderation_resubmit_request', - language='en', - args=(self.pg1.pk, 'en') - )) - self._assert_render( - response=response, - page=self.pg1, - action=constants.ACTION_RESUBMITTED, - active_request=self.moderation_request1, - workflow=self.wf1, - form_cls=UpdateModerationRequestForm, - title=_('Resubmit changes') + self.collection_1 = ModerationCollection.objects.create( + author=self.user, name='My collection 1', workflow=self.wf1 ) - - def test_approve_request_view_with_form(self): - response = self.client.get(get_admin_url( - name='cms_moderation_approve_request', - language='en', - args=(self.pg1.pk, 'en') - )) - self._assert_render( - response=response, - page=self.pg1, - action=constants.ACTION_APPROVED, - active_request=self.moderation_request1, - workflow=self.wf1, - form_cls=UpdateModerationRequestForm, - title=_('Approve changes') + self.collection_2 = ModerationCollection.objects.create( + author=self.user, name='My collection 2', workflow=self.wf1 ) - def test_throws_no_active_moderation_request(self): - response = self.client.get(get_admin_url( - name='cms_moderation_cancel_request', - language='en', - args=(self.pg2.pk, 'en') # pg2 => no active requests - )) - self.assertEqual(response.status_code, 400) - self.assertEqual(response.content, b'Page does not have an active moderation request.') - - def test_throws_error_already_approved(self): - response = self.client.get(get_admin_url( - name='cms_moderation_approve_request', - language='en', - args=(self.pg3.pk, 'en') # pg3 => active request with all approved steps - )) - self.assertEqual(response.status_code, 400) - self.assertEqual(response.content, b'Moderation request has already been approved.') - - def test_throws_error_forbidden_user(self): - from django.contrib.auth.models import User - user = User.objects.create_user(username='test1', email='test1@test.com', password='test1', is_staff=True) - self.client.force_login(user) - response = self.client.get(get_admin_url( - name='cms_moderation_approve_request', - language='en', - args=(self.pg1.pk, 'en') # pg1 => active request - )) - self.assertEqual(response.status_code, 403) - self.assertEqual(response.content, b'User is not allowed to update request.') - - def _create_confirmation_page(self, moderation_request): - # First delete all the form submissions for the passed moderation_request - # This will make sure there are no form submissions - # attached with the passed moderation_request - moderation_request.form_submissions.all().delete() - self.cp = ConfirmationPage.objects.create( - name='Checklist Form', - ) - self.role1.confirmation_page = self.cp - self.role1.save() + self.content_type = ContentType.objects.get_for_model(self.pg1) - def test_redirects_to_confirmation_page_if_invalid_check(self): - self._create_confirmation_page(self.moderation_request1) - response = self.client.get( - get_admin_url( - name='cms_moderation_approve_request', - language='en', - args=(self.pg1.pk, 'en') - ) - ) - redirect_url = add_url_parameters( - self.cp.get_absolute_url(), - content_view=True, - page=self.pg1.pk, - language='en', - ) - self.assertEqual(response.status_code, 302) # redirection - self.assertEqual(response.url, redirect_url) - - def test_does_not_redirect_to_confirmation_page_if_valid_check(self): - self._create_confirmation_page(self.moderation_request1) - ConfirmationFormSubmission.objects.create( - request=self.moderation_request1, - for_step=self.wf1st1, - by_user=self.user, - data=json.dumps([{'label': 'Question 1', 'answer': 'Yes'}]), - confirmation_page=self.cp, - ) - response = self.client.get( - get_admin_url( - name='cms_moderation_approve_request', - language='en', - args=(self.pg1.pk, 'en') - ) - ) - self._assert_render( - response=response, - page=self.pg1, - action=constants.ACTION_APPROVED, - active_request=self.moderation_request1, - workflow=self.wf1, - form_cls=UpdateModerationRequestForm, - title=_('Approve changes') - ) + def _assert_render(self, response): + form = response.context_data['form'] - def test_renders_all_form_submissions(self): - self._create_confirmation_page(self.moderation_request1) - ConfirmationFormSubmission.objects.create( - request=self.moderation_request1, - for_step=self.wf1st1, - by_user=self.user, - data=json.dumps([{'label': 'Question 1', 'answer': 'Yes'}]), - confirmation_page=self.cp, - ) - response = self.client.get( + self.assertIsInstance(form, CollectionItemForm) + self.assertEqual(response.status_code, 200) + self.assertEqual(response.template_name[0], 'djangocms_moderation/item_to_collection.html') + + self.assertEqual(response.context_data['title'], _('Add to collection')) + + def test_add_object_to_collections(self): + ModerationRequest.objects.all().delete() + self.client.force_login(self.user) + response = self.client.post( get_admin_url( - name='cms_moderation_approve_request', + name='cms_moderation_item_to_collection', language='en', - args=(self.pg1.pk, 'en') + args=() + ), {'collection': self.collection_1.pk, + 'content_type': self.content_type.pk, + 'content_object_id': self.pg1.pk + } ) - ) - form_submissions = response.context_data['form_submissions'] - results = ConfirmationFormSubmission.objects.filter(request=self.moderation_request1) - self.assertQuerysetEqual(form_submissions, results, transform=lambda x: x, ordered=False) + self.assertEqual(response.status_code, 200) + # self.assertContains(response, 'reloadBrowser') + + content_type = ContentType.objects.get_for_model(self.pg1) + moderation_request = ModerationRequest.objects.filter( + content_type=content_type, + object_id=self.pg1.pk, + )[0] -class ModerationCommentsViewTest(BaseViewTestCase): + self.assertEqual(moderation_request.collection, self.collection_1) - def test_comment_list(self): - response = self.client.get( + def test_invalid_content_already_in_collection(self): + # add object + self.collection_1._add_object(self.pg1) + + self.client.force_login(self.user) + + response = self.client.post( get_admin_url( - name='cms_moderation_comments', + name='cms_moderation_item_to_collection', language='en', - args=(self.pg3.pk, 'en') - ) - ) + args=() + ), {'collection': self.collection_1.pk, + 'content_type': self.content_type.pk, + 'content_object_id': self.pg1.pk}) self.assertEqual(response.status_code, 200) - self.assertEqual(response.context_data['object_list'].count(), 3) - - def test_comment_list_protected(self): - new_user = User.objects.create_superuser( - username='new_user', email='new_user@test.com', password='test' + self.assertIn( + "is already part of existing moderation request which is part", + response.context_data['form'].errors['__all__'][0] ) - self.client.force_login(new_user) - response = self.client.get( + def test_non_existing_content_object(self): + self.client.force_login(self.user) + content_type = ContentType.objects.get_for_model(self.pg1) + response = self.client.post( get_admin_url( - name='cms_moderation_comments', + name='cms_moderation_item_to_collection', language='en', - args=(self.pg3.pk, 'en') - ) - ) - - self.assertEqual(response.status_code, 403) + args=() + ), {'collection': self.collection_1.pk, + 'content_type': content_type.pk, + 'content_object_id': 9000}) + self.assertEqual(response.status_code, 200) + self.assertIn( + 'Invalid content_object_id, does not exist', + response.context_data['form'].errors['__all__'][0] + ) -class ModerationConfirmationPageTest(BaseViewTestCase): + def test_exclude_locked_collections(self): + ModerationRequest.objects.all().delete() + self.collection_1.is_locked = True + self.collection_1.save() - def setUp(self): - super(ModerationConfirmationPageTest, self).setUp() - self.cp = ConfirmationPage.objects.create( - name='Checklist Form', - ) + self.client.force_login(self.user) + response = self.client.post( + get_admin_url( + name='cms_moderation_item_to_collection', + language='en', + args=() + ), {'collection': self.collection_1.pk, 'content_object_id': self.pg1.pk}) - def test_renders_build_view(self): - response = self.client.get(self.cp.get_absolute_url()) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.templates[0].name, self.cp.template) + # locked collection are not part of the list self.assertEqual( - response.context['CONFIRMATION_BASE_TEMPLATE'], - 'djangocms_moderation/base_confirmation_build.html', + "Select a valid choice. That choice is not one of the available choices.", + response.context_data['form'].errors['collection'][0] ) - def test_renders_content_view(self): + def test_list_content_objects_from_collection_id_param(self): + ModerationRequest.objects.all().delete() + + self.collection_1._add_object(self.pg1) + self.collection_2._add_object(self.pg2) + + self.client.force_login(self.user) response = self.client.get( add_url_parameters( - self.cp.get_absolute_url(), - content_view=True, - page=self.pg1.pk, - language='en', + get_admin_url( + name='cms_moderation_item_to_collection', + language='en', + args=() + ), collection_id=self.collection_2.pk ) ) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.templates[0].name, self.cp.template) - self.assertEqual(response.context['CONFIRMATION_BASE_TEMPLATE'], 'djangocms_moderation/base_confirmation.html') - def test_renders_post_view(self): - response = self.client.post( + moderation_requests = ModerationRequest.objects.filter(collection=self.collection_2) + # moderation request is content_object + for mod_request in moderation_requests: + self.assertTrue(mod_request in response.context_data['content_object_list']) + + def test_content_object_id_from_params(self): + self.client.force_login(self.user) + response = self.client.get( add_url_parameters( - self.cp.get_absolute_url(), - content_view=True, - page=self.pg1.pk, - language='en', + get_admin_url( + name='cms_moderation_item_to_collection', + language='en', + args=() + ), content_object_id=self.pg1.pk ) ) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.templates[0].name, self.cp.template) - self.assertEqual(response.context['CONFIRMATION_BASE_TEMPLATE'], 'djangocms_moderation/base_confirmation.html') - self.assertTrue(response.context['submitted']) - redirect_url = add_url_parameters( + + form = response.context_data['form'] + self.assertEqual(self.pg1.pk, int(form.initial['content_object_id'])) + + def test_authenticated_users_only(self): + response = self.client.get( get_admin_url( - name='cms_moderation_approve_request', + name='cms_moderation_item_to_collection', language='en', - args=(self.pg1.pk, 'en'), - ), - reviewed=True, + args=() + ) ) - self.assertEqual(response.context['redirect_url'], redirect_url) + + self.assertEqual(response.status_code, 302) class SubmitCollectionForModerationViewTest(BaseViewTestCase): From c3b39f80a64fbea983ddcdf7a781a95dc349979e Mon Sep 17 00:00:00 2001 From: Vadim Sikora Date: Thu, 9 Aug 2018 17:02:20 +0200 Subject: [PATCH 009/147] FIL-518 use django jquery when listening to change event on collection Django loads own jQuery when rendering "add-related" widgets and triggers change event on them in their own jQuery. Since these are not native browser events we can't listen to them via other jQuery. --- .../item_to_collection.html | 32 +++++++++---------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/djangocms_moderation/templates/djangocms_moderation/item_to_collection.html b/djangocms_moderation/templates/djangocms_moderation/item_to_collection.html index 045874bc..9f752c07 100644 --- a/djangocms_moderation/templates/djangocms_moderation/item_to_collection.html +++ b/djangocms_moderation/templates/djangocms_moderation/item_to_collection.html @@ -50,24 +50,24 @@

    {% trans "Add to existing collection" %}

    - {% endblock %} From d8ec17efe45ec3a88fb884b991525ac5232ebb53 Mon Sep 17 00:00:00 2001 From: Damilare Onajole Date: Tue, 14 Aug 2018 10:46:14 +0100 Subject: [PATCH 010/147] Make toolbar collection item aware (#41) --- djangocms_moderation/cms_toolbars.py | 43 +++++++++++------ tests/test_cms_toolbars.py | 71 ++++++++++++++++++++++++++++ tests/utils/app_1/cms_config.py | 6 ++- tests/utils/app_2/cms_config.py | 6 ++- 4 files changed, 108 insertions(+), 18 deletions(-) create mode 100644 tests/test_cms_toolbars.py diff --git a/djangocms_moderation/cms_toolbars.py b/djangocms_moderation/cms_toolbars.py index cd935a1c..92ab46cc 100644 --- a/djangocms_moderation/cms_toolbars.py +++ b/djangocms_moderation/cms_toolbars.py @@ -1,4 +1,5 @@ # -*- coding: utf-8 -*- +from django.contrib.contenttypes.models import ContentType from django.utils.translation import ugettext_lazy as _ from cms.api import get_page_draft @@ -6,6 +7,7 @@ from cms.toolbar_pool import toolbar_pool from cms.utils.urlutils import add_url_parameters +from .models import ModerationRequest from .utils import get_admin_url @@ -27,20 +29,33 @@ def post_template_populate(self): if not page: return None - url = add_url_parameters( - get_admin_url( - name='cms_moderation_item_to_collection', - language=self.current_lang, - args=() - ), - content_object_id=page.pk - ) - - self.toolbar.add_modal_button( - name=_('Submit for moderation'), - url=url, - side=self.toolbar.RIGHT, - ) + try: + content_type = ContentType.objects.get_for_model(page) + moderation_request = ModerationRequest.objects.get( + content_type=content_type, + object_id=page.pk, + ) + self.toolbar.add_modal_button( + name=_('In Moderation "%s"' % moderation_request.collection.name), + url='#', + disabled=True, + side=self.toolbar.RIGHT, + ) + except ModerationRequest.DoesNotExist: + url = add_url_parameters( + get_admin_url( + name='cms_moderation_item_to_collection', + language=self.current_lang, + args=() + ), + content_object_id=page.pk + ) + + self.toolbar.add_modal_button( + name=_('Submit for moderation'), + url=url, + side=self.toolbar.RIGHT, + ) toolbar_pool.register(ModerationToolbar) diff --git a/tests/test_cms_toolbars.py b/tests/test_cms_toolbars.py new file mode 100644 index 00000000..b40f9b7b --- /dev/null +++ b/tests/test_cms_toolbars.py @@ -0,0 +1,71 @@ +from django.contrib.auth.models import AnonymousUser +from django.test.client import RequestFactory + +from cms.middleware.toolbar import ToolbarMiddleware +from cms.toolbar.toolbar import CMSToolbar +from cms.utils.conf import get_cms_setting + +from djangocms_moderation.cms_toolbars import ModerationToolbar +from djangocms_moderation.models import ModerationRequest + +from .utils.base import BaseTestCase + + +class TestCMSToolbars(BaseTestCase): + + def get_page_request(self, page, user, path=None, edit=False, + preview=False, structure=False, lang_code='en', disable=False): + if not path: + path = page.get_absolute_url() + + if edit: + path += '?%s' % get_cms_setting('CMS_TOOLBAR_URL__EDIT_ON') + + if structure: + path += '?%s' % get_cms_setting('CMS_TOOLBAR_URL__BUILD') + + if preview: + path += '?preview' + + request = RequestFactory().get(path) + request.session = {} + request.user = user + request.LANGUAGE_CODE = lang_code + if edit: + request.GET = {'edit': None} + else: + request.GET = {'edit_off': None} + if disable: + request.GET[get_cms_setting('CMS_TOOLBAR_URL__DISABLE')] = None + request.current_page = page + mid = ToolbarMiddleware() + mid.process_request(request) + if hasattr(request, 'toolbar'): + request.toolbar.populate() + return request + + def test_submit_for_moderation(self): + ModerationRequest.objects.all().delete() + + request = self.get_page_request(self.pg1, AnonymousUser(), '/') + toolbar = CMSToolbar(request) + toolbar = ModerationToolbar(request, toolbar=toolbar, is_current_app=True, app_path='/') + toolbar.populate() + toolbar.post_template_populate() + + self.assertEquals( + toolbar.toolbar.get_right_items()[0].buttons[0].name, + 'Submit for moderation' + ) + + def test_page_in_moderation(self): + request = self.get_page_request(self.pg1, AnonymousUser(), '/') + toolbar = CMSToolbar(request) + toolbar = ModerationToolbar(request, toolbar=toolbar, is_current_app=True, app_path='/') + toolbar.populate() + toolbar.post_template_populate() + + self.assertEquals( + toolbar.toolbar.get_right_items()[0].buttons[0].name, + 'In Moderation "%s"' % self.collection1.name + ) diff --git a/tests/utils/app_1/cms_config.py b/tests/utils/app_1/cms_config.py index 83ec92c4..a73937c2 100644 --- a/tests/utils/app_1/cms_config.py +++ b/tests/utils/app_1/cms_config.py @@ -13,10 +13,12 @@ class CMSApp1Config(CMSAppConfig): versioning = [ VersionableItem( content_model=App1PostContent, - grouper_field_name='post' + grouper_field_name='post', + copy_function=lambda x: x ), VersionableItem( content_model=App1TitleContent, - grouper_field_name='title' + grouper_field_name='title', + copy_function=lambda x: x ) ] diff --git a/tests/utils/app_2/cms_config.py b/tests/utils/app_2/cms_config.py index 425d28a6..801eadc3 100644 --- a/tests/utils/app_2/cms_config.py +++ b/tests/utils/app_2/cms_config.py @@ -13,10 +13,12 @@ class CMSApp1Config(CMSAppConfig): versioning = [ VersionableItem( content_model=App2PostContent, - grouper_field_name='post' + grouper_field_name='post', + copy_function=lambda x: x ), VersionableItem( content_model=App2TitleContent, - grouper_field_name='title' + grouper_field_name='title', + copy_function=lambda x: x ) ] From 97a405c865140eeed56a4b91a889a521f60026cb Mon Sep 17 00:00:00 2001 From: Noel da Costa Date: Mon, 20 Aug 2018 11:52:13 +0100 Subject: [PATCH 011/147] Collection locks and status (#42) --- djangocms_moderation/admin.py | 15 +---- djangocms_moderation/constants.py | 11 ++++ djangocms_moderation/forms.py | 10 +-- .../migrations/0001_initial.py | 16 +++-- .../0002_moderationcollection_author.py | 28 --------- .../migrations/0003_auto_20180724_1521.py | 26 -------- djangocms_moderation/models.py | 53 ++++------------ .../moderation_request_change_list.html | 4 +- tests/test_forms.py | 2 +- tests/test_models.py | 63 +++---------------- tests/test_views.py | 25 ++++---- 11 files changed, 71 insertions(+), 182 deletions(-) delete mode 100644 djangocms_moderation/migrations/0002_moderationcollection_author.py delete mode 100644 djangocms_moderation/migrations/0003_auto_20180724_1521.py diff --git a/djangocms_moderation/admin.py b/djangocms_moderation/admin.py index 0b6ce7ca..d8745f28 100644 --- a/djangocms_moderation/admin.py +++ b/djangocms_moderation/admin.py @@ -96,12 +96,12 @@ def changelist_view(self, request, extra_context=None): pass else: extra_context = dict(collection=collection) - if collection.allow_submit_for_moderation: - submit_for_moderation_url = reverse( + if collection.allow_submit_for_review: + submit_for_review_url = reverse( 'admin:cms_moderation_submit_collection_for_moderation', args=(collection_id,) ) - extra_context['submit_for_moderation_url'] = submit_for_moderation_url + extra_context['submit_for_review_url'] = submit_for_review_url return super(ModerationRequestAdmin, self).changelist_view(request, extra_context) def get_status(self, obj): @@ -162,7 +162,6 @@ class ModerationCollectionAdmin(admin.ModelAdmin): 'get_moderator', 'workflow', 'status', - 'is_locked', 'date_created', ] @@ -183,14 +182,6 @@ def get_moderator(self, obj): return obj.author get_moderator.short_description = _('Moderator') - def status(self, obj): - # TODO more statuses to come in the future, once implemented. - # It will very likely be a ModerationCollection.status field - if obj.is_locked: - return _("In review") - return _("Collection") - status.short_description = _('Status') - def get_urls(self): def _url(regex, fn, name, **kwargs): return url(regex, self.admin_site.admin_view(fn), kwargs=kwargs, name=name) diff --git a/djangocms_moderation/constants.py b/djangocms_moderation/constants.py index c70b58af..98d9c2ab 100644 --- a/djangocms_moderation/constants.py +++ b/djangocms_moderation/constants.py @@ -42,3 +42,14 @@ CONTENT_TYPE_PLAIN = 'plain' CONTENT_TYPE_FORM = 'form' + +# masks for Collectoin STATUS +COLLECTING = 'COLLECTING' +IN_REVIEW = 'IN_REVIEW' +ARCHIVED = 'ARCHIVED' + +STATUS_CHOICES = ( + (COLLECTING, _('Collecting')), + (IN_REVIEW, _('In Review')), + (ARCHIVED, _('Archived')), +) diff --git a/djangocms_moderation/forms.py b/djangocms_moderation/forms.py index ab918efd..35d8e629 100644 --- a/djangocms_moderation/forms.py +++ b/djangocms_moderation/forms.py @@ -10,7 +10,7 @@ from adminsortable2.admin import CustomInlineFormSet -from .constants import ACTION_CANCELLED, ACTION_REJECTED, ACTION_RESUBMITTED +from .constants import ACTION_CANCELLED, ACTION_REJECTED, ACTION_RESUBMITTED, COLLECTING from .models import ModerationCollection, ModerationRequest @@ -105,7 +105,7 @@ def save(self): class CollectionItemForm(forms.Form): collection = forms.ModelChoiceField( - queryset=ModerationCollection.objects.filter(is_locked=False), + queryset=ModerationCollection.objects.filter(status=COLLECTING), required=True ) content_type = forms.ModelChoiceField( @@ -169,7 +169,7 @@ def clean(self): class SubmitCollectionForModerationForm(forms.Form): moderator = forms.ModelChoiceField( - label=_('moderator'), + label=_('Select review group'), queryset=get_user_model().objects.none(), required=False, ) @@ -187,12 +187,12 @@ def configure_moderator_field(self): self.fields['moderator'].queryset = users def clean(self): - if not self.collection.allow_submit_for_moderation: + if not self.collection.allow_submit_for_review: self.add_error(None, _("This collection can't be submitted for a review")) return super(SubmitCollectionForModerationForm, self).clean() def save(self): - self.collection.submit_for_moderation( + self.collection.submit_for_review( by_user=self.user, to_user=self.cleaned_data.get('moderator'), ) diff --git a/djangocms_moderation/migrations/0001_initial.py b/djangocms_moderation/migrations/0001_initial.py index d8cde7ef..5a435fe6 100644 --- a/djangocms_moderation/migrations/0001_initial.py +++ b/djangocms_moderation/migrations/0001_initial.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -# Generated by Django 1.11.13 on 2018-07-17 15:49 +# Generated by Django 1.11.13 on 2018-08-16 08:57 from __future__ import unicode_literals import cms.models.fields @@ -7,8 +7,8 @@ from django.db import migrations, models import django.db.models.deletion -from djangocms_moderation import conf -from djangocms_moderation.constants import ACTION_CHOICES +from djangocms_moderation import conf +from djangocms_moderation.constants import ACTION_CHOICES, STATUS_CHOICES class Migration(migrations.Migration): @@ -18,6 +18,7 @@ class Migration(migrations.Migration): dependencies = [ ('auth', '0008_alter_user_username_max_length'), ('cms', '0020_old_tree_cleanup'), + ('cms', '0028_remove_page_placeholders'), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ('contenttypes', '0002_remove_content_type_name'), ] @@ -55,7 +56,10 @@ class Migration(migrations.Migration): fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('name', models.CharField(max_length=128, verbose_name='name')), - ('is_locked', models.BooleanField(default=False, verbose_name='is locked')), + ('status', models.CharField(choices=STATUS_CHOICES, db_index=True, default='COLLECTING', max_length=10)), + ('date_created', models.DateTimeField(auto_now_add=True)), + ('date_modified', models.DateTimeField(auto_now=True)), + ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='author')), ], ), migrations.CreateModel( @@ -175,6 +179,10 @@ class Migration(migrations.Migration): name='workflowstep', unique_together=set([('role', 'workflow')]), ), + migrations.AlterUniqueTogether( + name='moderationrequest', + unique_together=set([('collection', 'object_id', 'content_type')]), + ), migrations.AlterUniqueTogether( name='confirmationformsubmission', unique_together=set([('request', 'for_step')]), diff --git a/djangocms_moderation/migrations/0002_moderationcollection_author.py b/djangocms_moderation/migrations/0002_moderationcollection_author.py deleted file mode 100644 index 152690fa..00000000 --- a/djangocms_moderation/migrations/0002_moderationcollection_author.py +++ /dev/null @@ -1,28 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11.13 on 2018-07-18 14:01 -from __future__ import unicode_literals - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), - ('djangocms_moderation', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='moderationcollection', - name='author', - field=models.ForeignKey(default=0, on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='author'), - preserve_default=False, - ), - migrations.AlterUniqueTogether( - name='moderationrequest', - unique_together=set([('collection', 'object_id', 'content_type')]), - ), - ] diff --git a/djangocms_moderation/migrations/0003_auto_20180724_1521.py b/djangocms_moderation/migrations/0003_auto_20180724_1521.py deleted file mode 100644 index 63533099..00000000 --- a/djangocms_moderation/migrations/0003_auto_20180724_1521.py +++ /dev/null @@ -1,26 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11.13 on 2018-07-24 14:21 -from __future__ import unicode_literals - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('djangocms_moderation', '0002_moderationcollection_author'), - ] - - operations = [ - migrations.AddField( - model_name='moderationcollection', - name='date_created', - field=models.DateTimeField(auto_now_add=True), - preserve_default=False, - ), - migrations.AddField( - model_name='moderationcollection', - name='date_modified', - field=models.DateTimeField(auto_now=True), - ), - ] diff --git a/djangocms_moderation/models.py b/djangocms_moderation/models.py index fc0d10b3..b429d8c4 100644 --- a/djangocms_moderation/models.py +++ b/djangocms_moderation/models.py @@ -17,7 +17,6 @@ from cms.models.fields import PlaceholderField from .emails import notify_collection_moderators -from .exceptions import CollectionIsLocked, ObjectAlreadyInCollection from .utils import generate_compliance_number @@ -262,8 +261,12 @@ class ModerationCollection(models.Model): verbose_name=_('workflow'), related_name='moderation_collections', ) - # TODO: proper implementations and handlers coming later for is_locked - is_locked = models.BooleanField(verbose_name=_('is locked'), default=False) + status = models.CharField( + max_length=10, + choices=constants.STATUS_CHOICES, + default=constants.COLLECTING, + db_index=True, + ) date_created = models.DateTimeField(auto_now_add=True) date_modified = models.DateTimeField(auto_now=True) @@ -274,10 +277,10 @@ def __str__(self): def author_name(self): return self.author.get_full_name() or self.author.get_username() - def submit_for_moderation(self, by_user, to_user=None): + def submit_for_review(self, by_user, to_user=None): """ Submit all the moderation requests belonging to this collection for - moderation and mark the collection as locked + review and mark the collection as locked """ for moderation_request in self.moderation_requests.all(): action = moderation_request.actions.create( @@ -286,51 +289,21 @@ def submit_for_moderation(self, by_user, to_user=None): action=constants.ACTION_STARTED, ) # Lock the collection as it has been now submitted for moderation - self.is_locked = True - self.save(update_fields=['is_locked']) + self.status = constants.IN_REVIEW + self.save(update_fields=['status']) # It is fine to pass any `action` from any moderation_request.actions # above as it will have the same moderators notify_collection_moderators(collection=self, action=action) @property - def allow_submit_for_moderation(self): + def allow_submit_for_review(self): """ - Can this collection submitted for moderation? + Can this collection be submitted for review? :return: """ - # TODO limited check for now, consider makings this a model field - return not self.is_locked and self.moderation_requests.exists() + return self.status == constants.COLLECTING and self.moderation_requests.exists() def add_object(self, content_object): - """ - Add object to the ModerationRequest in this collection. - :return: - """ - if self.is_locked: - raise CollectionIsLocked( - "Can't add the object to the collection, because it is locked" - ) - - content_type = ContentType.objects.get_for_model(content_object) - # Object can ever be part of only one collection - existing_request_exists = ModerationRequest.objects.filter( - content_type=content_type, - object_id=content_object.pk, - ).exists() - - if not existing_request_exists: - return self.moderation_requests.create( - content_type=content_type, - object_id=content_object.pk, - collection=self, - ) - else: - raise ObjectAlreadyInCollection( - "{} is already part of existing moderation request which is part " - "of another active collection".format(content_object) - ) - - def _add_object(self, content_object): """ Add object to the ModerationRequest in this collection. Requires validation from .forms.CollectionItemForm diff --git a/djangocms_moderation/templates/djangocms_moderation/moderation_request_change_list.html b/djangocms_moderation/templates/djangocms_moderation/moderation_request_change_list.html index 8516d60a..185b403f 100644 --- a/djangocms_moderation/templates/djangocms_moderation/moderation_request_change_list.html +++ b/djangocms_moderation/templates/djangocms_moderation/moderation_request_change_list.html @@ -3,9 +3,9 @@ {% block object-tools-items %} {{ block.super }} -{% if submit_for_moderation_url %} +{% if submit_for_review_url %}
  • - {% trans 'Submit for review' %} + {% trans 'Submit for review' %}
  • {% endif %} {% endblock %} diff --git a/tests/test_forms.py b/tests/test_forms.py index 28cd3eb3..f21126bf 100644 --- a/tests/test_forms.py +++ b/tests/test_forms.py @@ -85,7 +85,7 @@ def test_form_save(self): class SubmitCollectionForModerationFormTest(BaseTestCase): - @mock.patch.object(ModerationCollection, 'allow_submit_for_moderation') + @mock.patch.object(ModerationCollection, 'allow_submit_for_review') def test_form_is_invalid_if_collection_cant_be_submitted_for_review(self, allow_submit_mock): data = { 'moderator': None, diff --git a/tests/test_models.py b/tests/test_models.py index de6e086b..416cc1e0 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -9,10 +9,6 @@ from cms.api import create_page from djangocms_moderation import constants -from djangocms_moderation.exceptions import ( - CollectionIsLocked, - ObjectAlreadyInCollection, -) from djangocms_moderation.models import ( ConfirmationFormSubmission, ConfirmationPage, @@ -489,23 +485,23 @@ def setUp(self): self.page1 = create_page(title='My page 1', template='page.html', language='en',) self.page2 = create_page(title='My page 2', template='page.html', language='en',) - def test_allow_submit_for_moderation(self): - self.collection1.is_locked = False + def test_allow_submit_for_review(self): + self.collection1.status = constants.COLLECTING self.collection1.save() # This is false, as we don't have any moderation requests in this collection - self.assertFalse(self.collection1.allow_submit_for_moderation) + self.assertFalse(self.collection1.allow_submit_for_review) ModerationRequest.objects.create( content_object=self.pg1, collection=self.collection1, is_active=True ) - self.assertTrue(self.collection1.allow_submit_for_moderation) + self.assertTrue(self.collection1.allow_submit_for_review) - self.collection1.is_locked = True + self.collection1.status = constants.IN_REVIEW self.collection1.save() - self.assertFalse(self.collection1.allow_submit_for_moderation) + self.assertFalse(self.collection1.allow_submit_for_review) @patch('djangocms_moderation.models.notify_collection_moderators') - def test_submit_for_moderation(self, mock_ncm): + def test_submit_for_review(self, mock_ncm): ModerationRequest.objects.create( content_object=self.pg1, language='en', collection=self.collection1 ) @@ -519,15 +515,15 @@ def test_submit_for_moderation(self, mock_ncm): ).exists() ) - self.collection1.is_locked = False + self.collection1.status = constants.COLLECTING self.collection1.save() - self.collection1.submit_for_moderation(self.user, None) + self.collection1.submit_for_review(self.user, None) self.assertEqual(1, mock_ncm.call_count) self.collection1.refresh_from_db() # Collection should lock itself - self.assertTrue(self.collection1.is_locked) + self.assertEquals(self.collection1.status, constants.IN_REVIEW) # We will now have 2 actions with status STARTED. self.assertEqual( 2, ModerationRequestAction.objects.filter( @@ -548,42 +544,3 @@ def _moderation_requests_count(self, obj, collection=None): if collection: queryset = queryset.filter(collection=collection) return queryset.count() - - def test_add_object(self): - self.assertEqual(0, self._moderation_requests_count(self.page1)) - # Add `page1` to `collection1` - self.collection1.add_object(self.page1) - self.assertEqual(1, self._moderation_requests_count(self.page1)) - self.assertEqual(1, self._moderation_requests_count(self.page1, self.collection1)) - - # Adding the same object to the same collection will raise an exception - with self.assertRaises(ObjectAlreadyInCollection): - self.collection1.add_object(self.page1) - - self.assertEqual(1, self._moderation_requests_count(self.page1, self.collection1)) - self.assertEqual(1, self._moderation_requests_count(self.page1)) - - # This should not work as `page1` is already part of `collection1` - with self.assertRaises(ObjectAlreadyInCollection): - self.collection2.add_object(self.page1) - - # We can add `page2` to the `collection1` as it is not there yet - self.assertEqual(0, self._moderation_requests_count(self.page2)) - self.collection1.add_object(self.page2) - self.assertEqual(1, self._moderation_requests_count(self.page2)) - self.assertEqual(1, self._moderation_requests_count(self.page2, self.collection1)) - self.assertEqual(1, self._moderation_requests_count(self.page1, self.collection1)) - - def test_add_object_locked_collection(self): - # This works, as the collection is not locked - self.collection2.add_object(self.page1) - self.assertEqual(1, self._moderation_requests_count(self.page1)) - - # Now, let's lock the collection, so we can't add to it anymore - self.collection2.is_locked = True - self.collection2.save() - - with self.assertRaises(CollectionIsLocked): - self.collection2.add_object(self.page1) - - self.assertEqual(1, self._moderation_requests_count(self.page1)) diff --git a/tests/test_views.py b/tests/test_views.py index 38af5a58..1104a4b5 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -10,6 +10,7 @@ from djangocms_moderation.forms import CollectionItemForm from djangocms_moderation.models import ModerationCollection, ModerationRequest from djangocms_moderation.utils import get_admin_url +from djangocms_moderation import constants from .utils.base import BaseViewTestCase @@ -67,7 +68,7 @@ def test_add_object_to_collections(self): def test_invalid_content_already_in_collection(self): # add object - self.collection_1._add_object(self.pg1) + self.collection_1.add_object(self.pg1) self.client.force_login(self.user) @@ -104,11 +105,13 @@ def test_non_existing_content_object(self): response.context_data['form'].errors['__all__'][0] ) - def test_exclude_locked_collections(self): + def test_prevent_locked_collections(self): + """ + from being selected when adding to collection + """ ModerationRequest.objects.all().delete() - self.collection_1.is_locked = True + self.collection_1.status = constants.IN_REVIEW self.collection_1.save() - self.client.force_login(self.user) response = self.client.post( get_admin_url( @@ -126,8 +129,8 @@ def test_exclude_locked_collections(self): def test_list_content_objects_from_collection_id_param(self): ModerationRequest.objects.all().delete() - self.collection_1._add_object(self.pg1) - self.collection_2._add_object(self.pg2) + self.collection_1.add_object(self.pg1) + self.collection_2.add_object(self.pg2) self.client.force_login(self.user) response = self.client.get( @@ -185,7 +188,7 @@ def setUp(self): self.collection2.pk ) - @mock.patch.object(ModerationCollection, 'submit_for_moderation') + @mock.patch.object(ModerationCollection, 'submit_for_review') def test_submit_collection_for_moderation(self, submit_mock): response = self.client.get(self.url) self.assertEqual(200, response.status_code) @@ -217,16 +220,16 @@ def test_change_list_view_should_contain_collection_object(self): self.assertEqual(200, response.status_code) self.assertEqual(response.context['collection'], self.collection2) - @mock.patch.object(ModerationCollection, 'allow_submit_for_moderation') + @mock.patch.object(ModerationCollection, 'allow_submit_for_review') def test_change_list_view_should_contain_submit_collection_url(self, allow_submit_mock): response = self.client.get(self.url) self.assertEqual(200, response.status_code) - self.assertNotIn('submit_for_moderation_url', response.context) + self.assertNotIn('submit_for_review_url', response.context) allow_submit_mock.__get__ = mock.Mock(return_value=False) response = self.client.get(self.url_with_filter) - self.assertNotIn('submit_for_moderation_url', response.context) + self.assertNotIn('submit_for_review_url', response.context) allow_submit_mock.__get__ = mock.Mock(return_value=True) response = self.client.get(self.url_with_filter) - self.assertIn('submit_for_moderation_url', response.context) + self.assertIn('submit_for_review_url', response.context) From 1c9287bfafe3de2309be67549521fd8c68a606c6 Mon Sep 17 00:00:00 2001 From: Mateusz Kamycki Date: Mon, 20 Aug 2018 16:33:12 +0200 Subject: [PATCH 012/147] Fixed Circle CI caching problem (#45) --- .circleci/config.yml | 59 ++++++++++++++++------------------- djangocms_moderation/forms.py | 7 ++++- tests/test_views.py | 2 +- 3 files changed, 34 insertions(+), 34 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index a489597f..28e2b07e 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -7,9 +7,9 @@ py34default: &py34default - setup_remote_docker: docker_layer_caching: true - checkout - - restore_cache: - keys: py34- - - run: docker load -i caches/py34.tar || true + - attach_workspace: + at: /tmp/images + - run: docker load -i /tmp/images/py34.tar || true - run: docker run py34 tox -e $CIRCLE_STAGE py35default: &py35default @@ -19,9 +19,9 @@ py35default: &py35default - setup_remote_docker: docker_layer_caching: true - checkout - - restore_cache: - keys: py35- - - run: docker load -i caches/py35.tar || true + - attach_workspace: + at: /tmp/images + - run: docker load -i /tmp/images/py35.tar || true - run: docker run py35 tox -e $CIRCLE_STAGE @@ -32,9 +32,9 @@ py36default: &py36default - setup_remote_docker: docker_layer_caching: true - checkout - - restore_cache: - keys: py36- - - run: docker load -i caches/py36.tar || true + - attach_workspace: + at: /tmp/images + - run: docker load -i /tmp/images/py36.tar || true - run: docker run py36 tox -e $CIRCLE_STAGE py34_requires: &py34_requires @@ -58,12 +58,11 @@ jobs: - setup_remote_docker: docker_layer_caching: true - run: docker build -f .circleci/Dockerfile --build-arg PYTHON_VERSION=3.4 -t py34 . - - run: mkdir caches - - run: docker save -o caches/py34.tar py34 - - save_cache: - key: py34-{{ .Environment.CIRCLE_SHA1 }} - paths: - - "caches/" + - run: mkdir images + - run: docker save -o images/py34.tar py34 + - persist_to_workspace: + root: images + paths: py34.tar py36_base: docker: - image: circleci/python:3.6 @@ -72,12 +71,11 @@ jobs: - setup_remote_docker: docker_layer_caching: true - run: docker build -f .circleci/Dockerfile --build-arg PYTHON_VERSION=3.6 -t py36 . - - run: mkdir caches - - run: docker save -o caches/py36.tar py36 - - save_cache: - key: py36-{{ .Environment.CIRCLE_SHA1 }} - paths: - - "caches/" + - run: mkdir images + - run: docker save -o images/py36.tar py36 + - persist_to_workspace: + root: images + paths: py36.tar py35_base: docker: @@ -87,12 +85,11 @@ jobs: - setup_remote_docker: docker_layer_caching: true - run: docker build -f .circleci/Dockerfile --build-arg PYTHON_VERSION=3.5 -t py35 . - - run: mkdir caches - - run: docker save -o caches/py35.tar py35 - - save_cache: - key: py35-{{ .Environment.CIRCLE_SHA1 }} - paths: - - "caches/" + - run: mkdir images + - run: docker save -o images/py35.tar py35 + - persist_to_workspace: + root: images + paths: py35.tar flake8: <<: *py35default @@ -105,9 +102,9 @@ jobs: - setup_remote_docker: docker_layer_caching: true - checkout - - restore_cache: - keys: py35- - - run: docker load -i caches/py35.tar || true + - attach_workspace: + at: /tmp/images + - run: docker load -i /tmp/images/py35.tar || true - run: docker run py35 gulp lint py34-dj111-sqlite-cms4: <<: *py34default @@ -158,5 +155,3 @@ workflows: - py36-dj20-sqlite-cms4: requires: - py36_base - - diff --git a/djangocms_moderation/forms.py b/djangocms_moderation/forms.py index 35d8e629..5dd8df2e 100644 --- a/djangocms_moderation/forms.py +++ b/djangocms_moderation/forms.py @@ -10,7 +10,12 @@ from adminsortable2.admin import CustomInlineFormSet -from .constants import ACTION_CANCELLED, ACTION_REJECTED, ACTION_RESUBMITTED, COLLECTING +from .constants import ( + ACTION_CANCELLED, + ACTION_REJECTED, + ACTION_RESUBMITTED, + COLLECTING, +) from .models import ModerationCollection, ModerationRequest diff --git a/tests/test_views.py b/tests/test_views.py index 1104a4b5..d55ec07f 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -7,10 +7,10 @@ from cms.utils.urlutils import add_url_parameters +from djangocms_moderation import constants from djangocms_moderation.forms import CollectionItemForm from djangocms_moderation.models import ModerationCollection, ModerationRequest from djangocms_moderation.utils import get_admin_url -from djangocms_moderation import constants from .utils.base import BaseViewTestCase From 9afd53ddba1523bc574d028cbd182529d2a15e6e Mon Sep 17 00:00:00 2001 From: Noel da Costa Date: Tue, 21 Aug 2018 16:56:19 +0100 Subject: [PATCH 013/147] Added pre-flight check (#46) --- djangocms_moderation/admin.py | 36 +++++++++++++- djangocms_moderation/admin_actions.py | 34 +++++++++++++ djangocms_moderation/models.py | 13 +++++ djangocms_moderation/views.py | 4 ++ tests/test_admin_actions.py | 71 +++++++++++++++++++++++++++ tests/test_models.py | 22 +++++++++ tests/test_views.py | 11 ++--- tests/utils/base.py | 13 ++++- 8 files changed, 196 insertions(+), 8 deletions(-) create mode 100644 djangocms_moderation/admin_actions.py create mode 100644 tests/test_admin_actions.py diff --git a/djangocms_moderation/admin.py b/djangocms_moderation/admin.py index d8745f28..7fbad9f3 100644 --- a/djangocms_moderation/admin.py +++ b/djangocms_moderation/admin.py @@ -3,6 +3,7 @@ from django.conf.urls import url from django.contrib import admin from django.core.urlresolvers import reverse +from django.http import Http404 from django.utils.html import format_html, format_html_join from django.utils.translation import ugettext, ugettext_lazy as _ @@ -10,6 +11,7 @@ from adminsortable2.admin import SortableInlineAdminMixin +from .admin_actions import publish_selected from .forms import WorkflowStepInlineFormSet from .helpers import get_form_submission_for_step from .models import ( @@ -65,7 +67,7 @@ def form_submission(self, obj): class ModerationRequestAdmin(admin.ModelAdmin): - actions = None # remove `delete_selected` for now, it will be handled later + actions = [publish_selected] inlines = [ModerationRequestActionInline] list_display = ['id', 'content_type', 'get_title', 'collection', 'get_preview_link', 'get_status'] list_filter = ['collection'] @@ -85,6 +87,16 @@ def get_preview_link(self, obj): def has_add_permission(self, request): return False + def get_actions(self, request): + actions = super().get_actions(request) + # If there is nothing to publish, then remove `publish_selected` action + if 'publish_selected' in actions and ( + not hasattr(request, '_collection') or + not request._collection.allow_pre_flight(request.user) + ): + del actions['publish_selected'] + return actions + def changelist_view(self, request, extra_context=None): # If we filter by a specific collection, we want to add this collection # to the context @@ -92,6 +104,7 @@ def changelist_view(self, request, extra_context=None): if collection_id: try: collection = ModerationCollection.objects.get(pk=int(collection_id)) + request._collection = collection except (ValueError, ModerationCollection.DoesNotExist): pass else: @@ -102,12 +115,22 @@ def changelist_view(self, request, extra_context=None): args=(collection_id,) ) extra_context['submit_for_review_url'] = submit_for_review_url + else: + # If no collection id, then don't show all requests + # as each collection's actions, buttons and privileges may differ + raise Http404 + return super(ModerationRequestAdmin, self).changelist_view(request, extra_context) def get_status(self, obj): last_action = obj.get_last_action() if obj.is_approved(): status = ugettext('Ready for publishing') + + # TODO: consider published status for version e.g.: + # elif obj.content_object.is_published(): + # status = ugettext('Published') + elif obj.is_active and obj.has_pending_step(): next_step = obj.get_next_required() role = next_step.role.name @@ -164,6 +187,17 @@ class ModerationCollectionAdmin(admin.ModelAdmin): 'status', 'date_created', ] + editonly_fields = ('status',) # fields editable only on EDIT + addonly_fields = ('workflow',) # fields editable only on CREATE + + def get_readonly_fields(self, request, obj=None): + """ + Override to provide editonly_fields and addonly_fields functionality + """ + if obj: # Editing an existing object + return self.readonly_fields + self.addonly_fields + else: # Adding a new object + return self.readonly_fields + self.editonly_fields def get_name_with_requests_link(self, obj): """ diff --git a/djangocms_moderation/admin_actions.py b/djangocms_moderation/admin_actions.py new file mode 100644 index 00000000..37934984 --- /dev/null +++ b/djangocms_moderation/admin_actions.py @@ -0,0 +1,34 @@ +from django.contrib import messages +from django.core.exceptions import PermissionDenied +from django.utils.translation import ugettext_lazy as _, ungettext + + +def publish_selected(modeladmin, request, queryset): + if request.user != request._collection.author: + raise PermissionDenied + + num_published_requests = 0 + for moderation_request in queryset.all(): + if moderation_request.is_approved(): + num_published_requests += 1 + publish_content_object(moderation_request.content_object) + + # notify the UI of the action results + messages.success( + request, + ungettext( + '%(count)d request successfully published', + '%(count)d requests successfully published', + num_published_requests + ) % { + 'count': num_published_requests + }, + ) + + +publish_selected.short_description = _("Publish selected requests") + + +def publish_content_object(content_object): + # TODO: e.g.moderation_request.content_object.publish(request.user) + return True diff --git a/djangocms_moderation/models.py b/djangocms_moderation/models.py index b429d8c4..be8bd5a3 100644 --- a/djangocms_moderation/models.py +++ b/djangocms_moderation/models.py @@ -303,6 +303,19 @@ def allow_submit_for_review(self): """ return self.status == constants.COLLECTING and self.moderation_requests.exists() + def allow_pre_flight(self, user): + """ + Is this collection ready for pre-flight? + :return: + """ + if self.status != constants.IN_REVIEW or user != self.author: + return False + moderation_requests = self.moderation_requests.filter(is_active=True) + for moderation_request in moderation_requests: + if moderation_request.is_approved(): + return True + return False + def add_object(self, content_object): """ Add object to the ModerationRequest in this collection. diff --git a/djangocms_moderation/views.py b/djangocms_moderation/views.py index 599443e9..1de4b0a8 100644 --- a/djangocms_moderation/views.py +++ b/djangocms_moderation/views.py @@ -26,6 +26,7 @@ class CollectionItemView(FormView): def get_form_kwargs(self): kwargs = super(CollectionItemView, self).get_form_kwargs() + # TODO: replace page object with Version object kwargs['initial'].update({ 'content_object_id': self.request.GET.get('content_object_id'), 'content_type': ContentType.objects.get_for_model(Page).pk, @@ -81,6 +82,9 @@ def get_context_data(self, **kwargs): def moderation_confirmation_page(request, confirmation_id): + """ + This is an implementation of Aldryn-forms to provide a review confirmation page + """ confirmation_page_instance = get_object_or_404(ConfirmationPage, pk=confirmation_id) content_view = bool(request.GET.get('content_view')) page_id = request.GET.get('page') diff --git a/tests/test_admin_actions.py b/tests/test_admin_actions.py new file mode 100644 index 00000000..b9c189a3 --- /dev/null +++ b/tests/test_admin_actions.py @@ -0,0 +1,71 @@ +import mock + +from django.contrib.admin import ACTION_CHECKBOX_NAME +from django.urls import reverse + +from cms.api import create_page + +from djangocms_moderation import constants +from djangocms_moderation.models import ( + ModerationCollection, + ModerationRequest, + Workflow, +) + +from .utils.base import BaseTestCase + + +class AdminActionTest(BaseTestCase): + + def setUp(self): + self.wf = Workflow.objects.create(name='Workflow Test',) + self.collection = ModerationCollection.objects.create( + author=self.user, name='Collection Admin Actions', workflow=self.wf, status=constants.IN_REVIEW + ) + + pg1 = create_page(title='Page 1', template='page.html', language='en',) + pg2 = create_page(title='Page 2', template='page.html', language='en',) + + self.mr1 = ModerationRequest.objects.create( + content_object=pg1, language='en', collection=self.collection, is_active=True,) + + self.wfst = self.wf.steps.create(role=self.role1, is_required=True, order=1,) + + # this moderation request is approved + self.mr1.actions.create(by_user=self.user, action=constants.ACTION_STARTED,) + self.mr1.actions.create( + by_user=self.user, + to_user=self.user2, + action=constants.ACTION_APPROVED, + step_approved=self.wfst, + ) + + # this moderation request is not approved + self.mr2 = ModerationRequest.objects.create( + content_object=pg2, language='en', collection=self.collection, is_active=True,) + self.mr2.actions.create(by_user=self.user, action=constants.ACTION_STARTED,) + + self.url = reverse('admin:djangocms_moderation_moderationrequest_changelist') + self.url_with_filter = "{}?collection__id__exact={}".format( + self.url, self.collection.pk + ) + + self.client.force_login(self.user) + + @mock.patch('djangocms_moderation.admin_actions.publish_content_object') + def test_publish_selected(self, mock_publish_content_object): + + fixtures = [self.mr1, self.mr2] + data = { + 'action': 'publish_selected', + 'select_across': 0, + 'index': 0, + ACTION_CHECKBOX_NAME: [str(f.pk) for f in fixtures] + } + self.client.post(self.url_with_filter, data, follow=True) + self.assertTrue(self.mr1.is_approved()) + self.assertFalse(self.mr2.is_approved()) + + assert mock_publish_content_object.called + # check it has been called only once, i.e. with the approved mr1 + mock_publish_content_object.assert_called_once_with(self.mr1.content_object) diff --git a/tests/test_models.py b/tests/test_models.py index 416cc1e0..df40d778 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -500,6 +500,28 @@ def test_allow_submit_for_review(self): self.collection1.save() self.assertFalse(self.collection1.allow_submit_for_review) + def test_allow_pre_flight(self): + self.collection4.status = constants.COLLECTING + self.collection4.save() + + # This is false, as there are no approved requests and status is COLLECTING + self.assertFalse(self.collection4.allow_pre_flight(self.user)) + + self.collection4.status = constants.IN_REVIEW + self.collection4.save() + + # This is false, as there are no approved requests + self.assertFalse(self.collection4.allow_pre_flight(self.user)) + + self.moderation_request5.update_status( + action=constants.ACTION_APPROVED, + by_user=self.user, + message='Approved', + ) + + self.assertTrue(self.collection4.allow_pre_flight(self.user)) + self.assertFalse(self.collection4.allow_pre_flight(self.user2)) + @patch('djangocms_moderation.models.notify_collection_moderators') def test_submit_for_review(self, mock_ncm): ModerationRequest.objects.create( diff --git a/tests/test_views.py b/tests/test_views.py index d55ec07f..5fcb0dcd 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -211,21 +211,20 @@ def setUp(self): self.url, self.collection2.pk ) - def test_change_list_view_should_contain_collection_object(self): + def test_change_list_view_should_404_if_not_filtered(self): response = self.client.get(self.url) + self.assertEqual(404, response.status_code) + + response = self.client.get(self.url_with_filter) self.assertEqual(200, response.status_code) - self.assertNotIn('collection', response.context) + def test_change_list_view_should_contain_collection_object(self): response = self.client.get(self.url_with_filter) self.assertEqual(200, response.status_code) self.assertEqual(response.context['collection'], self.collection2) @mock.patch.object(ModerationCollection, 'allow_submit_for_review') def test_change_list_view_should_contain_submit_collection_url(self, allow_submit_mock): - response = self.client.get(self.url) - self.assertEqual(200, response.status_code) - self.assertNotIn('submit_for_review_url', response.context) - allow_submit_mock.__get__ = mock.Mock(return_value=False) response = self.client.get(self.url_with_filter) self.assertNotIn('submit_for_review_url', response.context) diff --git a/tests/utils/base.py b/tests/utils/base.py index 8c78bce8..f82bbb49 100644 --- a/tests/utils/base.py +++ b/tests/utils/base.py @@ -20,13 +20,15 @@ def setUpTestData(cls): cls.wf1 = Workflow.objects.create(pk=1, name='Workflow 1', is_default=True,) cls.wf2 = Workflow.objects.create(pk=2, name='Workflow 2',) cls.wf3 = Workflow.objects.create(pk=3, name='Workflow 3',) + cls.wf4 = Workflow.objects.create(pk=4, name='Workflow 4',) # create pages cls.pg1 = create_page(title='Page 1', template='page.html', language='en',) cls.pg2 = create_page(title='Page 2', template='page.html', language='en',) cls.pg3 = create_page(title='Page 3', template='page.html', language='en', published=True) cls.pg4 = create_page(title='Page 4', template='page.html', language='en',) - cls.pg5 = create_page(title='Page 4', template='page.html', language='en', published=True) + cls.pg5 = create_page(title='Page 5', template='page.html', language='en', published=True) + cls.pg6 = create_page(title='Page 5', template='page.html', language='en',) # create users, groups and roles cls.user = User.objects.create_superuser( @@ -55,6 +57,8 @@ def setUpTestData(cls): cls.wf3st1 = cls.wf3.steps.create(role=cls.role1, is_required=True, order=1,) cls.wf3st2 = cls.wf3.steps.create(role=cls.role3, is_required=False, order=2,) + cls.wf4st4 = cls.wf4.steps.create(role=cls.role1, is_required=True, order=1,) + # create page moderation requests and actions cls.collection1 = ModerationCollection.objects.create( author=cls.user, name='Collection 1', workflow=cls.wf1 @@ -65,6 +69,9 @@ def setUpTestData(cls): cls.collection3 = ModerationCollection.objects.create( author=cls.user, name='Collection 3', workflow=cls.wf3 ) + cls.collection4 = ModerationCollection.objects.create( + author=cls.user, name='Collection 4', workflow=cls.wf4 + ) cls.moderation_request1 = ModerationRequest.objects.create( content_object=cls.pg1, language='en', collection=cls.collection1, is_active=True,) @@ -99,6 +106,10 @@ def setUpTestData(cls): cls.moderation_request4.actions.create(by_user=cls.user, action=constants.ACTION_STARTED,) cls.moderation_request4.actions.create(by_user=cls.user2, action=constants.ACTION_REJECTED) + cls.moderation_request5 = ModerationRequest.objects.create( + content_object=cls.pg6, language='en', collection=cls.collection4, is_active=True,) + cls.moderation_request5.actions.create(by_user=cls.user, action=constants.ACTION_STARTED,) + class BaseViewTestCase(BaseTestCase): From 8bbf16146eb0894a653ede3bd4ea3a0bab4804f3 Mon Sep 17 00:00:00 2001 From: Damilare Onajole Date: Tue, 21 Aug 2018 18:03:01 +0100 Subject: [PATCH 014/147] Delete selected moderation requests (remove items from collection) (#44) --- README.rst | 2 +- djangocms_moderation/admin.py | 7 ++---- djangocms_moderation/admin_actions.py | 22 ++++++++++++++++++ tests/test_admin_actions.py | 32 +++++++++++++++++++++++++++ 4 files changed, 57 insertions(+), 6 deletions(-) diff --git a/README.rst b/README.rst index 302fc510..d724bd27 100644 --- a/README.rst +++ b/README.rst @@ -9,7 +9,7 @@ Installation Requirements ============ -django CMS Moderation requires that you have a django CMS 3.4.3 (or higher) project already running and set up. +django CMS Moderation requires that you have a django CMS 4.0 (or higher) project already running and set up. To install diff --git a/djangocms_moderation/admin.py b/djangocms_moderation/admin.py index 7fbad9f3..c3303565 100644 --- a/djangocms_moderation/admin.py +++ b/djangocms_moderation/admin.py @@ -11,7 +11,7 @@ from adminsortable2.admin import SortableInlineAdminMixin -from .admin_actions import publish_selected +from .admin_actions import delete_selected, publish_selected from .forms import WorkflowStepInlineFormSet from .helpers import get_form_submission_for_step from .models import ( @@ -39,9 +39,6 @@ class ModerationRequestActionInline(admin.TabularInline): def has_add_permission(self, request): return False - def has_delete_permission(self, request, obj=None): - return False - def show_user(self, obj): _name = obj.get_by_user_name() return ugettext('By {user}').format(user=_name) @@ -67,7 +64,7 @@ def form_submission(self, obj): class ModerationRequestAdmin(admin.ModelAdmin): - actions = [publish_selected] + actions = [delete_selected, publish_selected] inlines = [ModerationRequestActionInline] list_display = ['id', 'content_type', 'get_title', 'collection', 'get_preview_link', 'get_status'] list_filter = ['collection'] diff --git a/djangocms_moderation/admin_actions.py b/djangocms_moderation/admin_actions.py index 37934984..1cd69bd0 100644 --- a/djangocms_moderation/admin_actions.py +++ b/djangocms_moderation/admin_actions.py @@ -3,6 +3,28 @@ from django.utils.translation import ugettext_lazy as _, ungettext +def delete_selected(modeladmin, request, queryset): + if not modeladmin.has_delete_permission(request): + raise PermissionDenied + + if queryset.exclude(collection__author=request.user).exists(): + raise PermissionDenied + + num_deleted_requests = queryset.count() + queryset.delete() + + messages.success( + request, + ungettext( + '%(count)d request successfully deleted', + '%(count)d requests successfully deleted', + num_deleted_requests + ) % { + 'count': num_deleted_requests + }, + ) + + def publish_selected(modeladmin, request, queryset): if request.user != request._collection.author: raise PermissionDenied diff --git a/tests/test_admin_actions.py b/tests/test_admin_actions.py index b9c189a3..06d119d4 100644 --- a/tests/test_admin_actions.py +++ b/tests/test_admin_actions.py @@ -6,6 +6,7 @@ from cms.api import create_page from djangocms_moderation import constants +from djangocms_moderation.admin import ModerationRequestAdmin from djangocms_moderation.models import ( ModerationCollection, ModerationRequest, @@ -52,6 +53,37 @@ def setUp(self): self.client.force_login(self.user) + @mock.patch.object(ModerationRequestAdmin, 'has_delete_permission') + def test_delete_selected(self, mock_has_delete_permission): + mock_has_delete_permission.return_value = True + self.assertEqual(ModerationRequest.objects.filter(collection=self.collection).count(), 2) + + fixtures = [self.mr1, self.mr2] + data = { + 'action': 'delete_selected', + ACTION_CHECKBOX_NAME: [str(f.pk) for f in fixtures] + } + # This user is not the collection author + self.client.force_login(self.user2) + response = self.client.post(self.url_with_filter, data) + self.assertEqual(response.status_code, 403) + # Nothing is deleted + self.assertEqual(ModerationRequest.objects.filter(collection=self.collection).count(), 2) + + # Now lets try with collection author, but without delete permission + mock_has_delete_permission.return_value = False + self.client.force_login(self.user) + response = self.client.post(self.url_with_filter, data) + self.assertEqual(response.status_code, 403) + # Nothing is deleted + self.assertEqual(ModerationRequest.objects.filter(collection=self.collection).count(), 2) + + mock_has_delete_permission.return_value = True + self.client.force_login(self.user) + response = self.client.post(self.url_with_filter, data) + self.assertEqual(response.status_code, 302) + self.assertEqual(ModerationRequest.objects.filter(collection=self.collection).count(), 0) + @mock.patch('djangocms_moderation.admin_actions.publish_content_object') def test_publish_selected(self, mock_publish_content_object): From 14083e16d12937042d9ded2010b1360715bd5f9d Mon Sep 17 00:00:00 2001 From: Matus Moravcik Date: Mon, 3 Sep 2018 15:24:13 +0100 Subject: [PATCH 015/147] Bulk actions (#47) --- djangocms_moderation/admin.py | 131 +++++++--- djangocms_moderation/admin_actions.py | 172 ++++++++++++- djangocms_moderation/cms_toolbars.py | 3 +- djangocms_moderation/emails.py | 64 ++--- djangocms_moderation/forms.py | 3 +- djangocms_moderation/helpers.py | 3 +- djangocms_moderation/models.py | 37 ++- .../emails/moderation-request/approved.txt | 20 +- .../emails/moderation-request/cancelled.txt | 20 +- .../emails/moderation-request/rejected.txt | 24 +- .../emails/moderation-request/request.txt | 6 +- .../moderation_request_change_list.html | 24 +- tests/test_admin.py | 151 ++++++++++++ tests/test_admin_actions.py | 228 ++++++++++++++++-- tests/test_app_registration.py | 2 + tests/test_forms.py | 4 +- tests/test_models.py | 56 +++-- tests/test_views.py | 4 +- tests/utils/app_1/models.py | 6 + tests/utils/app_2/models.py | 6 + tests/utils/base.py | 6 +- 21 files changed, 813 insertions(+), 157 deletions(-) create mode 100644 tests/test_admin.py diff --git a/djangocms_moderation/admin.py b/djangocms_moderation/admin.py index c3303565..525457be 100644 --- a/djangocms_moderation/admin.py +++ b/djangocms_moderation/admin.py @@ -11,7 +11,14 @@ from adminsortable2.admin import SortableInlineAdminMixin -from .admin_actions import delete_selected, publish_selected +from .admin_actions import ( + approve_selected, + delete_selected, + publish_selected, + reject_selected, + resubmit_selected, +) +from .constants import ARCHIVED, IN_REVIEW from .forms import WorkflowStepInlineFormSet from .helpers import get_form_submission_for_step from .models import ( @@ -64,14 +71,26 @@ def form_submission(self, obj): class ModerationRequestAdmin(admin.ModelAdmin): - actions = [delete_selected, publish_selected] + actions = [ # filtered out in `self.get_actions` + delete_selected, + publish_selected, + approve_selected, + reject_selected, + resubmit_selected, + ] inlines = [ModerationRequestActionInline] list_display = ['id', 'content_type', 'get_title', 'collection', 'get_preview_link', 'get_status'] - list_filter = ['collection'] fields = ['id', 'collection', 'workflow', 'is_active', 'get_status'] readonly_fields = fields change_list_template = 'djangocms_moderation/moderation_request_change_list.html' + def has_module_permission(self, request): + """ + Don't display Requests in the admin index as they should be accessed + and filtered through the Collection list view + """ + return False + def get_title(self, obj): return obj.content_object get_title.short_description = _('Title') @@ -85,14 +104,53 @@ def has_add_permission(self, request): return False def get_actions(self, request): + """ + By default, all actions are enabled. But we need to only keep the actions + which have a moderation requests ready for. + E.g. if there are no moderation requests ready to be published, + we don't need to keep the `publish_selected` action + """ + try: + collection = request._collection + except AttributeError: + # If we are not in the collection aware list, then don't + # offer any bulk actions + return {} + actions = super().get_actions(request) - # If there is nothing to publish, then remove `publish_selected` action - if 'publish_selected' in actions and ( - not hasattr(request, '_collection') or - not request._collection.allow_pre_flight(request.user) - ): - del actions['publish_selected'] - return actions + actions_to_keep = [] + + if collection.status in [IN_REVIEW, ARCHIVED]: + # Keep track how many actions we've added in the below loop (_actions_kept). + # If we added all of them (_max_to_keep), we can exit the for loop + if collection.status == IN_REVIEW: + _max_to_keep = 4 # publish_selected, approve_selected, reject_selected, resubmit_selected + else: + # If the collection is archived, then no other action than + # `publish_selected` is possible. + _max_to_keep = 1 # publish_selected + + for mr in collection.moderation_requests.all(): + if len(actions_to_keep) == _max_to_keep: + break # We have found all the actions, so no need to loop anymore + if 'publish_selected' not in actions_to_keep: + if mr.is_approved() and request.user == collection.author: + actions_to_keep.append('publish_selected') + if collection.status == IN_REVIEW and 'approve_selected' not in actions_to_keep: + if mr.user_can_take_moderation_action(request.user): + actions_to_keep.append('approve_selected') + actions_to_keep.append('reject_selected') + if collection.status == IN_REVIEW and 'resubmit_selected' not in actions_to_keep: + if mr.user_can_resubmit(request.user): + actions_to_keep.append('resubmit_selected') + + # Only collection author can delete moderation requests + if collection.author == request.user: + actions_to_keep.append('delete_selected') + + return { + key: value for key, value in actions.items() if key in actions_to_keep + } def changelist_view(self, request, extra_context=None): # If we filter by a specific collection, we want to add this collection @@ -106,7 +164,7 @@ def changelist_view(self, request, extra_context=None): pass else: extra_context = dict(collection=collection) - if collection.allow_submit_for_review: + if collection.allow_submit_for_review(user=request.user): submit_for_review_url = reverse( 'admin:cms_moderation_submit_collection_for_moderation', args=(collection_id,) @@ -117,30 +175,32 @@ def changelist_view(self, request, extra_context=None): # as each collection's actions, buttons and privileges may differ raise Http404 - return super(ModerationRequestAdmin, self).changelist_view(request, extra_context) + return super().changelist_view(request, extra_context) def get_status(self, obj): + # We can have moderation requests without any action (e.g. the + # ones not submitted for moderation yet) last_action = obj.get_last_action() - if obj.is_approved(): - status = ugettext('Ready for publishing') - - # TODO: consider published status for version e.g.: - # elif obj.content_object.is_published(): - # status = ugettext('Published') - - elif obj.is_active and obj.has_pending_step(): - next_step = obj.get_next_required() - role = next_step.role.name - status = ugettext('Pending %(role)s approval') % {'role': role} - elif last_action: - # We can have moderation requests without any action (e.g. the - # ones not submitted for moderation yet) - user_name = last_action.get_by_user_name() - message_data = { - 'action': last_action.get_action_display(), - 'name': user_name, - } - status = ugettext('%(action)s by %(name)s') % message_data + + if last_action: + if obj.is_approved(): + status = ugettext('Ready for publishing') + # TODO: consider published status for version e.g.: + # elif obj.content_object.is_published(): + # status = ugettext('Published') + elif obj.is_rejected(): + status = ugettext('Pending author rework') + elif obj.is_active and obj.has_pending_step(): + next_step = obj.get_next_required() + role = next_step.role.name + status = ugettext('Pending %(role)s approval') % {'role': role} + else: + user_name = last_action.get_by_user_name() + message_data = { + 'action': last_action.get_action_display(), + 'name': user_name, + } + status = ugettext('%(action)s by %(name)s') % message_data else: status = ugettext('Ready for submission') return status @@ -179,6 +239,7 @@ class ModerationCollectionAdmin(admin.ModelAdmin): list_display = [ 'id', 'get_name_with_requests_link', + 'job_id', 'get_moderator', 'workflow', 'status', @@ -229,7 +290,7 @@ def _url(regex, fn, name, **kwargs): name='cms_moderation_item_to_collection', ) ] - return url_patterns + super(ModerationCollectionAdmin, self).get_urls() + return url_patterns + super().get_urls() class ConfirmationPageAdmin(PlaceholderAdminMixin, admin.ModelAdmin): @@ -246,7 +307,7 @@ def _url(regex, fn, name, **kwargs): name='cms_moderation_confirmation_page', ), ] - return url_patterns + super(ConfirmationPageAdmin, self).get_urls() + return url_patterns + super().get_urls() class ConfirmationFormSubmissionAdmin(admin.ModelAdmin): @@ -264,7 +325,7 @@ def change_view(self, request, object_id, form_url='', extra_context=None): extra_context = extra_context or {} extra_context['show_save'] = False extra_context['show_save_and_continue'] = False - return super(ConfirmationFormSubmissionAdmin, self).change_view( + return super().change_view( request, object_id, form_url, extra_context=extra_context, ) diff --git a/djangocms_moderation/admin_actions.py b/djangocms_moderation/admin_actions.py index 1cd69bd0..e6f37403 100644 --- a/djangocms_moderation/admin_actions.py +++ b/djangocms_moderation/admin_actions.py @@ -2,6 +2,157 @@ from django.core.exceptions import PermissionDenied from django.utils.translation import ugettext_lazy as _, ungettext +from djangocms_moderation import constants +from djangocms_moderation.emails import ( + notify_collection_author, + notify_collection_moderators, +) + + +def resubmit_selected(modeladmin, request, queryset): + """ + Validate and re-submit all the selected moderation requests for + moderation and notify reviewers via email. + """ + resubmitted_requests = [] + + for mr in queryset.all(): + if mr.user_can_resubmit(request.user): + resubmitted_requests.append(mr) + mr.update_status( + action=constants.ACTION_RESUBMITTED, + by_user=request.user, + ) + + if resubmitted_requests: + # Lets notify reviewers. TODO task queue? + notify_collection_moderators( + collection=request._collection, + moderation_requests=resubmitted_requests, + # We can take any action here, as all the requests are in the same + # stage of moderation - at the beginning + action_obj=resubmitted_requests[0].get_last_action() + ) + + messages.success( + request, + ungettext( + '%(count)d request successfully resubmitted for review', + '%(count)d requests successfully resubmitted for review', + len(resubmitted_requests) + ) % { + 'count': len(resubmitted_requests) + }, + ) +resubmit_selected.short_description = _("Resubmit changes for review") # noqa: E305 + + +def reject_selected(modeladmin, request, queryset): + """ + Validate and reject all the selected moderation requests and notify + the author about these requests + """ + rejected_requests = [] + + for moderation_request in queryset.all(): + if moderation_request.user_can_take_moderation_action(request.user): + rejected_requests.append(moderation_request) + moderation_request.update_status( + action=constants.ACTION_REJECTED, + by_user=request.user, + ) + + # Now we need to notify collection reviewers and moderator. TODO task queue? + if rejected_requests: + notify_collection_author( + collection=request._collection, + moderation_requests=rejected_requests, + action=constants.ACTION_REJECTED, + by_user=request.user, + ) + + messages.success( + request, + ungettext( + '%(count)d request successfully submitted for rework', + '%(count)d requests successfully submitted for rework', + len(rejected_requests) + ) % { + 'count': len(rejected_requests) + }, + ) +reject_selected.short_description = _('Submit for rework') # noqa: E305 + + +def approve_selected(modeladmin, request, queryset): + """ + Validate and approve all the selected moderation requests and notify + the author and reviewers. + + When bulk approving, we need to check for the next line of reviewers and + notify them about the pending moderation requests assigned to them. + + Because this is a bulk action, we need to group the approved_requests + by the action.step_approved, so we notify the correct reviewers. + + For example, if some requests are in the first stage of approval, + and some in the second, then the reviewers we need to notify are + different per request, depending on which stage the request is in + """ + approved_requests = [] + # Variable we are using to group the requests by action.step_approved + request_action_mapping = dict() + + for mr in queryset.all(): + if mr.user_can_take_moderation_action(request.user): + approved_requests.append(mr) + mr.update_status( + action=constants.ACTION_APPROVED, + by_user=request.user, + ) + action = mr.get_last_action() + if action.to_user_id or action.to_role_id: + # We group the moderation requests by step_approved.pk. + # Sometimes it can be None, in which case they can be grouped + # together and we use "0" as a key + step_approved_key = str(action.step_approved.pk if action.step_approved else 0) + if step_approved_key not in request_action_mapping: + request_action_mapping[step_approved_key] = [mr] + request_action_mapping['action_' + step_approved_key] = action + else: + request_action_mapping[step_approved_key].append(mr) + + if approved_requests: # TODO task queue? + # Lets notify the collection author about the approval + notify_collection_author( + collection=request._collection, + moderation_requests=approved_requests, + action=constants.ACTION_APPROVED, + by_user=request.user, + ) + + # Notify reviewers + for key, moderation_requests in sorted(request_action_mapping.items(), key=lambda x: x[0]): + if not key.startswith('action_'): + notify_collection_moderators( + collection=request._collection, + moderation_requests=moderation_requests, + action_obj=request_action_mapping['action_' + key] + ) + + messages.success( + request, + ungettext( + '%(count)d request successfully approved', + '%(count)d requests successfully approved', + len(approved_requests) + ) % { + 'count': len(approved_requests) + }, + ) + + post_bulk_actions(request._collection) + def delete_selected(modeladmin, request, queryset): if not modeladmin.has_delete_permission(request): @@ -11,8 +162,16 @@ def delete_selected(modeladmin, request, queryset): raise PermissionDenied num_deleted_requests = queryset.count() - queryset.delete() + if num_deleted_requests: # TODO task queue? + notify_collection_author( + collection=request._collection, + moderation_requests=[mr for mr in queryset], + action=constants.ACTION_CANCELLED, + by_user=request.user, + ) + + queryset.delete() messages.success( request, ungettext( @@ -24,6 +183,9 @@ def delete_selected(modeladmin, request, queryset): }, ) + post_bulk_actions(request._collection) +delete_selected.short_description = _('Cancel selected') # noqa: E305 + def publish_selected(modeladmin, request, queryset): if request.user != request._collection.author: @@ -47,8 +209,14 @@ def publish_selected(modeladmin, request, queryset): }, ) + post_bulk_actions(request._collection) +publish_selected.short_description = _("Publish selected requests") # noqa: E305 + -publish_selected.short_description = _("Publish selected requests") +def post_bulk_actions(collection): + if collection.should_be_archived(): + collection.status = constants.ARCHIVED + collection.save(update_fields=['status']) def publish_content_object(content_object): diff --git a/djangocms_moderation/cms_toolbars.py b/djangocms_moderation/cms_toolbars.py index 92ab46cc..0f0c63da 100644 --- a/djangocms_moderation/cms_toolbars.py +++ b/djangocms_moderation/cms_toolbars.py @@ -2,7 +2,6 @@ from django.contrib.contenttypes.models import ContentType from django.utils.translation import ugettext_lazy as _ -from cms.api import get_page_draft from cms.toolbar_base import CMSToolbar from cms.toolbar_pool import toolbar_pool from cms.utils.urlutils import add_url_parameters @@ -24,7 +23,7 @@ def post_template_populate(self): :return: """ super(ModerationToolbar, self).post_template_populate() - page = get_page_draft(self.request.current_page) + page = self.request.current_page if not page: return None diff --git a/djangocms_moderation/emails.py b/djangocms_moderation/emails.py index c68b210e..0ccd7f10 100644 --- a/djangocms_moderation/emails.py +++ b/djangocms_moderation/emails.py @@ -19,27 +19,32 @@ email_subjects = { - constants.ACTION_APPROVED: _('Changes Approved'), - constants.ACTION_CANCELLED: _('Request for moderation cancelled'), - constants.ACTION_REJECTED: _('Changes Rejected'), + constants.ACTION_APPROVED: _('Approved moderation requests'), + constants.ACTION_REJECTED: _('Rejected moderation requests'), + constants.ACTION_CANCELLED: _('Request for moderation deleted'), } -def _send_email(collection, action, recipients, subject, template): - if action.to_user_id: - moderator_name = action.get_to_user_name() - elif action.to_role_id: - moderator_name = action.to_role.name - else: - moderator_name = '' +def _send_email( + collection, + moderation_requests, + recipients, + subject, + template, + by_user +): + admin_url = "{}?collection__id__exact={}".format( + reverse('admin:djangocms_moderation_moderationrequest_changelist'), + collection.id + ) - admin_url = reverse('admin:djangocms_moderation_moderationcollection_change', args=(collection.pk,)) context = { 'collection': collection, + 'moderation_requests': moderation_requests, 'author_name': collection.author_name, - 'by_user_name': action.get_by_user_name(), - 'moderator_name': moderator_name, 'admin_url': get_absolute_url(admin_url), + 'job_id': collection.job_id, + 'by_user': by_user, } template = 'djangocms_moderation/emails/moderation-request/{}'.format(template) @@ -56,32 +61,28 @@ def _send_email(collection, action, recipients, subject, template): return message.send() -def notify_request_author(request, action): - if action.action not in email_subjects: - # TODO: FINISH THIS - return 0 - - if not request.author.email: - return 0 +def notify_collection_author(collection, moderation_requests, action, by_user): + if action not in email_subjects or not collection.author.email: + return status = _send_email( - request=request, - action=action, - recipients=[request.author.email], - subject=email_subjects[action.action], - template='{}.txt'.format(action.action), + collection=collection, + moderation_requests=moderation_requests, + recipients=[collection.author.email], + subject=email_subjects[action], + template='{}.txt'.format(action), + by_user=by_user, ) return status -def notify_collection_moderators(collection, action): - if action.to_user_id and not action.to_user.email: +def notify_collection_moderators(collection, moderation_requests, action_obj): + if action_obj.to_user_id and not action_obj.to_user.email: return 0 - try: - recipients = [action.to_user.email] + recipients = [action_obj.to_user.email] except AttributeError: - users = action.to_role.get_users_queryset().exclude(email='') + users = action_obj.to_role.get_users_queryset().exclude(email='') recipients = users.values_list('email', flat=True) if not recipients: @@ -89,9 +90,10 @@ def notify_collection_moderators(collection, action): status = _send_email( collection=collection, - action=action, + moderation_requests=moderation_requests, recipients=recipients, subject=_('Review requested'), template='request.txt', + by_user=action_obj.by_user ) return status diff --git a/djangocms_moderation/forms.py b/djangocms_moderation/forms.py index 5dd8df2e..4ae23c1b 100644 --- a/djangocms_moderation/forms.py +++ b/djangocms_moderation/forms.py @@ -149,7 +149,6 @@ def clean(self): content_object = content_type.get_object_for_this_type( pk=self.cleaned_data['content_object_id'], is_page_type=False, - publisher_is_draft=True, ) except content_type.model_class().DoesNotExist: content_object = None @@ -192,7 +191,7 @@ def configure_moderator_field(self): self.fields['moderator'].queryset = users def clean(self): - if not self.collection.allow_submit_for_review: + if not self.collection.allow_submit_for_review(user=self.user): self.add_error(None, _("This collection can't be submitted for a review")) return super(SubmitCollectionForModerationForm, self).clean() diff --git a/djangocms_moderation/helpers.py b/djangocms_moderation/helpers.py index ecaf6855..0dfed959 100644 --- a/djangocms_moderation/helpers.py +++ b/djangocms_moderation/helpers.py @@ -39,8 +39,7 @@ def get_page_or_404(obj_id, language): return content_type.get_object_for_this_type( pk=obj_id, is_page_type=False, - publisher_is_draft=True, - title_set__language=language, + pagecontent_set__language=language, ) diff --git a/djangocms_moderation/models.py b/djangocms_moderation/models.py index be8bd5a3..a7f17e81 100644 --- a/djangocms_moderation/models.py +++ b/djangocms_moderation/models.py @@ -273,6 +273,10 @@ class ModerationCollection(models.Model): def __str__(self): return self.name + @property + def job_id(self): + return "{}".format(self.pk) + @property def author_name(self): return self.author.get_full_name() or self.author.get_username() @@ -293,28 +297,35 @@ def submit_for_review(self, by_user, to_user=None): self.save(update_fields=['status']) # It is fine to pass any `action` from any moderation_request.actions # above as it will have the same moderators - notify_collection_moderators(collection=self, action=action) + notify_collection_moderators( + collection=self, + moderation_requests=self.moderation_requests.all(), + action_obj=action, + ) - @property - def allow_submit_for_review(self): + def allow_submit_for_review(self, user): """ Can this collection be submitted for review? :return: """ - return self.status == constants.COLLECTING and self.moderation_requests.exists() + return all([ + self.author == user, + self.status == constants.COLLECTING, + self.moderation_requests.exists(), + ]) - def allow_pre_flight(self, user): + def should_be_archived(self): """ - Is this collection ready for pre-flight? + Collection should be archived if all moderation requests are moderated :return: """ - if self.status != constants.IN_REVIEW or user != self.author: + if self.status in [constants.COLLECTING, constants.ARCHIVED]: return False - moderation_requests = self.moderation_requests.filter(is_active=True) - for moderation_request in moderation_requests: - if moderation_request.is_approved(): - return True - return False + # TODO this is not efficient, is there a better way? + for mr in self.moderation_requests.all(): + if not mr.is_approved(): + return False + return True def add_object(self, content_object): """ @@ -369,6 +380,7 @@ class Meta: verbose_name = _('Request') verbose_name_plural = _('Requests') unique_together = ('collection', 'object_id', 'content_type') + ordering = ['id'] def __str__(self): return "{} {}".format( @@ -496,7 +508,6 @@ def user_can_take_moderation_action(self, user): return False pending_steps = self.get_pending_steps().select_related('role') - for step in pending_steps.iterator(): is_assigned = step.role.user_is_assigned(user) diff --git a/djangocms_moderation/templates/djangocms_moderation/emails/moderation-request/approved.txt b/djangocms_moderation/templates/djangocms_moderation/emails/moderation-request/approved.txt index df41922e..4bfcc35f 100644 --- a/djangocms_moderation/templates/djangocms_moderation/emails/moderation-request/approved.txt +++ b/djangocms_moderation/templates/djangocms_moderation/emails/moderation-request/approved.txt @@ -1,14 +1,30 @@ {% load i18n %} -{% blocktrans %} -Hello {{ author_name }}, {{ by_user_name }} has approved your changes in {{ page_url }}. +{% blocktrans with collection.author_name as collection_author and collection.name as collection_name %} +Hello {{ collection_author }}, {{ by_user }} has approved some moderation +requests in the collection {{ collection_name }}. {% endblocktrans %} +

    +{% trans 'Approved moderation requests' %}: +

    + +
      +{% for moderation_request in moderation_requests %} +
    • + {{ moderation_request.pk }} - {{ moderation_request.content_object }} ({{ moderation_request.content_type }}) +
    • +{% endfor %} +
    + + {% if comment %} {% trans 'Comment' %}:
    {{ comment }}
    {% endif %} +{% if job_id %} {% trans 'Job ID' %}: {{ job_id }}
    +{% endif %} {% if admin_url %} {% trans 'Admin Url' %}:
    diff --git a/djangocms_moderation/templates/djangocms_moderation/emails/moderation-request/cancelled.txt b/djangocms_moderation/templates/djangocms_moderation/emails/moderation-request/cancelled.txt index f9210c95..c1f0ae98 100644 --- a/djangocms_moderation/templates/djangocms_moderation/emails/moderation-request/cancelled.txt +++ b/djangocms_moderation/templates/djangocms_moderation/emails/moderation-request/cancelled.txt @@ -1,17 +1,31 @@ {% load i18n %} -{% blocktrans %} -Hello {{ author_name }}, {{ by_user_name }} has cancelled your request for moderation on {{ page_url }}. +{% blocktrans with collection.author_name as collection_author and collection.name as collection_name %} +Hello {{ collection_author }}, the following moderation requests in the collection +{{ collection_name }} have been cancelled {% endblocktrans %} +

    +{% trans 'Cancelled moderation requests' %}: +

    + +
      +{% for moderation_request in moderation_requests %} +
    • + {{ moderation_request.pk }} - {{ moderation_request.content_object }} ({{ moderation_request.content_type }}) +
    • +{% endfor %} +
    + {% if comment %} {% trans 'Comment' %}:
    {{ comment }}
    {% endif %} +{% if job_id %} {% trans 'Job ID' %}: {{ job_id }}
    +{% endif %} {% if admin_url %} {% trans 'Admin Url' %}:
    {{ admin_url }}
    {% endif %} - diff --git a/djangocms_moderation/templates/djangocms_moderation/emails/moderation-request/rejected.txt b/djangocms_moderation/templates/djangocms_moderation/emails/moderation-request/rejected.txt index cc7f4f9e..2995502b 100644 --- a/djangocms_moderation/templates/djangocms_moderation/emails/moderation-request/rejected.txt +++ b/djangocms_moderation/templates/djangocms_moderation/emails/moderation-request/rejected.txt @@ -1,16 +1,32 @@ {% load i18n %} -{% blocktrans %} -Hello {{ author_name }}, {{ by_user_name }} has rejected your changes in {{ page_url }}. - -You can resubmit the necessary changes for another review, or cancel the moderation request. +{% blocktrans with collection.author_name as collection_author and collection.name as collection_name %} +Hello {{ collection_author }}, {{ by_user }} has rejected some moderation +requests in the collection {{ collection_name }}. {% endblocktrans %} +

    +{% trans 'You can resubmit the necessary changes for another review, or remove the moderation requests from the collection.' %} + +

    +

    +{% trans 'Rejected moderation requests' %}: +

    + +
      +{% for moderation_request in moderation_requests %} +
    • + {{ moderation_request.pk }} - {{ moderation_request.content_object }} ({{ moderation_request.content_type }}) +
    • +{% endfor %} +
    {% if comment %} {% trans 'Comment' %}:
    {{ comment }}
    {% endif %} +{% if job_id %} {% trans 'Job ID' %}: {{ job_id }}
    +{% endif %} {% if admin_url %} {% trans 'Admin Url' %}:
    diff --git a/djangocms_moderation/templates/djangocms_moderation/emails/moderation-request/request.txt b/djangocms_moderation/templates/djangocms_moderation/emails/moderation-request/request.txt index 01c003c3..f3767b0c 100644 --- a/djangocms_moderation/templates/djangocms_moderation/emails/moderation-request/request.txt +++ b/djangocms_moderation/templates/djangocms_moderation/emails/moderation-request/request.txt @@ -1,14 +1,14 @@ {% load i18n %} {% blocktrans with collection.name as collection_name %} -Hello {{ moderator_name }}, {{ by_user_name }} has requested your approval for +Hello, {{ by_user }} has requested your approval for changes in the collection {{ collection_name }}. {% endblocktrans %}

    -{% trans 'Items included in this collection:' %} +{% trans 'Items included in this request' %}:

      -{% for moderation_request in collection.moderation_requests.all %} +{% for moderation_request in moderation_requests %}
    • {{ moderation_request.pk }} - {{ moderation_request.content_object }} ({{ moderation_request.content_type }})
    • diff --git a/djangocms_moderation/templates/djangocms_moderation/moderation_request_change_list.html b/djangocms_moderation/templates/djangocms_moderation/moderation_request_change_list.html index 185b403f..e1500e07 100644 --- a/djangocms_moderation/templates/djangocms_moderation/moderation_request_change_list.html +++ b/djangocms_moderation/templates/djangocms_moderation/moderation_request_change_list.html @@ -1,21 +1,23 @@ {% extends "admin/change_list.html" %} {% load i18n %} +{% block content_title %} + {% if collection %} +

      {{ collection.name }}

      +

      + {% trans 'Job ID' %}: {{ collection.job_id }}
      + {% trans 'Status' %}: {{ collection.get_status_display }}
      + {% trans 'Workflow' %}: {{ collection.workflow.name }}
      + {% trans 'Author' %}: {{ collection.author_name }}
      +

      + {% endif %} +{% endblock %} + {% block object-tools-items %} {{ block.super }} {% if submit_for_review_url %}
    • - {% trans 'Submit for review' %} + {% trans 'Submit collection for review' %}
    • {% endif %} {% endblock %} - -{% block content %} -{{ block.super }} - -{% comment %} - TODO This template will be overridden to provide the required functionality. - For now, lets just output the selected collection -{% endcomment %} -Selected collection: {{ collection }} -{% endblock %} diff --git a/tests/test_admin.py b/tests/test_admin.py new file mode 100644 index 00000000..a6be7bb8 --- /dev/null +++ b/tests/test_admin.py @@ -0,0 +1,151 @@ +from django.contrib import admin +from django.urls import reverse + +from cms.api import create_page + +from djangocms_moderation import constants +from djangocms_moderation.admin import ModerationRequestAdmin +from djangocms_moderation.constants import ACTION_REJECTED +from djangocms_moderation.models import ( + ModerationCollection, + ModerationRequest, + Workflow, +) + +from .utils.base import BaseTestCase + + +class MockRequest: + GET = {} + + +class ModerationRequestAdminTestCase(BaseTestCase): + def setUp(self): + self.wf = Workflow.objects.create(name='Workflow Test',) + self.collection = ModerationCollection.objects.create( + author=self.user, name='Collection Admin Actions', workflow=self.wf, status=constants.IN_REVIEW + ) + + pg1 = create_page(title='Page 1', template='page.html', language='en',) + pg2 = create_page(title='Page 2', template='page.html', language='en',) + + self.mr1 = ModerationRequest.objects.create( + content_object=pg1, language='en', collection=self.collection, is_active=True,) + + self.wfst = self.wf.steps.create(role=self.role2, is_required=True, order=1,) + + # this moderation request is approved + self.mr1.actions.create(by_user=self.user, action=constants.ACTION_STARTED,) + self.mr1action2 = self.mr1.actions.create( + by_user=self.user, + to_user=self.user2, + action=constants.ACTION_APPROVED, + step_approved=self.wfst, + ) + + # this moderation request is not approved + self.mr2 = ModerationRequest.objects.create( + content_object=pg2, language='en', collection=self.collection, is_active=True,) + self.mr2.actions.create(by_user=self.user, action=constants.ACTION_STARTED,) + + self.url = reverse('admin:djangocms_moderation_moderationrequest_changelist') + self.url_with_filter = "{}?collection__id__exact={}".format( + self.url, self.collection.pk + ) + self.mra = ModerationRequestAdmin(ModerationRequest, admin.AdminSite()) + + def test_delete_selected_action_visibility(self): + mock_request = MockRequest() + mock_request.user = self.user + mock_request._collection = self.collection + actions = self.mra.get_actions(request=mock_request) + self.assertIn('delete_selected', actions) + + # user2 won't be able to delete requests, as they are not the collection + # author + mock_request.user = self.user2 + actions = self.mra.get_actions(request=mock_request) + self.assertNotIn('delete_selected', actions) + + def test_publish_selected_action_visibility(self): + mock_request = MockRequest() + mock_request.user = self.user + mock_request._collection = self.collection + actions = self.mra.get_actions(request=mock_request) + # mr1 request is approved, so user1 can see the publish selected option + self.assertIn('publish_selected', actions) + + # user2 should not be able to see it + mock_request.user = self.user2 + actions = self.mra.get_actions(request=mock_request) + self.assertNotIn('publish_selected', actions) + + # if there are no approved requests, user can't see the button either + mock_request.user = self.user + self.mr1.get_last_action().delete() + actions = self.mra.get_actions(request=mock_request) + self.assertNotIn('publish_selected', actions) + + def test_approve_and_reject_selected_action_visibility(self): + mock_request = MockRequest() + mock_request.user = self.user + mock_request._collection = self.collection + actions = self.mra.get_actions(request=mock_request) + # mr1 is not a moderator for collection1 so he can't approve or reject + # anything + self.assertNotIn('approve_selected', actions) + self.assertNotIn('reject_selected', actions) + + # user2 is moderator and there is 1 unapproved request + mock_request.user = self.user2 + actions = self.mra.get_actions(request=mock_request) + self.assertIn('approve_selected', actions) + self.assertIn('reject_selected', actions) + + # now everything is approved, so not even user2 can see the actions + self.mr2.delete() + actions = self.mra.get_actions(request=mock_request) + self.assertNotIn('approve_selected', actions) + self.assertNotIn('reject_selected', actions) + + def test_resubmit_selected_action_visibility(self): + mock_request = MockRequest() + mock_request.user = self.user + mock_request._collection = self.collection + actions = self.mra.get_actions(request=mock_request) + # There is nothing set to re-work, so user can't see the resubmit action + self.assertNotIn('resubmit_selected', actions) + + self.mr1action2.action = ACTION_REJECTED + self.mr1action2.save() + actions = self.mra.get_actions(request=mock_request) + # There is 1 mr to rework now, so user can do it + self.assertIn('resubmit_selected', actions) + + # user2 can't, as they are not the author of the request + mock_request.user = self.user2 + actions = self.mra.get_actions(request=mock_request) + self.assertNotIn('resubmit_selected', actions) + + def test_in_review_status_is_considered(self): + mock_request = MockRequest() + mock_request.user = self.user + mock_request._collection = self.collection + self.collection.status = constants.ARCHIVED + self.collection.save() + + actions = self.mra.get_actions(request=mock_request) + # for self.user, the publish_selected should be available even if + # collection status is ARCHIVED + self.assertIn('publish_selected', actions) + + mock_request.user = self.user2 + actions = self.mra.get_actions(request=mock_request) + # mr2 request is not approved, so user2 should see the + # approve_selected option, but the collection is not in IN_REVIEW + self.assertNotIn('approve_selected', actions) + + self.collection.status = constants.IN_REVIEW + self.collection.save() + actions = self.mra.get_actions(request=mock_request) + self.assertIn('approve_selected', actions) diff --git a/tests/test_admin_actions.py b/tests/test_admin_actions.py index 06d119d4..125cf6c6 100644 --- a/tests/test_admin_actions.py +++ b/tests/test_admin_actions.py @@ -7,9 +7,11 @@ from djangocms_moderation import constants from djangocms_moderation.admin import ModerationRequestAdmin +from djangocms_moderation.constants import ACTION_REJECTED from djangocms_moderation.models import ( ModerationCollection, ModerationRequest, + Role, Workflow, ) @@ -21,7 +23,10 @@ class AdminActionTest(BaseTestCase): def setUp(self): self.wf = Workflow.objects.create(name='Workflow Test',) self.collection = ModerationCollection.objects.create( - author=self.user, name='Collection Admin Actions', workflow=self.wf, status=constants.IN_REVIEW + author=self.user, + name='Collection Admin Actions', + workflow=self.wf, + status=constants.IN_REVIEW, ) pg1 = create_page(title='Page 1', template='page.html', language='en',) @@ -30,16 +35,13 @@ def setUp(self): self.mr1 = ModerationRequest.objects.create( content_object=pg1, language='en', collection=self.collection, is_active=True,) - self.wfst = self.wf.steps.create(role=self.role1, is_required=True, order=1,) + self.wfst1 = self.wf.steps.create(role=self.role1, is_required=True, order=1,) + self.wfst2 = self.wf.steps.create(role=self.role2, is_required=True, order=1,) # this moderation request is approved self.mr1.actions.create(by_user=self.user, action=constants.ACTION_STARTED,) - self.mr1.actions.create( - by_user=self.user, - to_user=self.user2, - action=constants.ACTION_APPROVED, - step_approved=self.wfst, - ) + self.mr1.update_status(constants.ACTION_APPROVED, self.user) + self.mr1.update_status(constants.ACTION_APPROVED, self.user2) # this moderation request is not approved self.mr2 = ModerationRequest.objects.create( @@ -54,7 +56,9 @@ def setUp(self): self.client.force_login(self.user) @mock.patch.object(ModerationRequestAdmin, 'has_delete_permission') - def test_delete_selected(self, mock_has_delete_permission): + @mock.patch('djangocms_moderation.admin_actions.notify_collection_moderators') + @mock.patch('djangocms_moderation.admin_actions.notify_collection_author') + def test_delete_selected(self, notify_author_mock, notify_moderators_mock, mock_has_delete_permission): mock_has_delete_permission.return_value = True self.assertEqual(ModerationRequest.objects.filter(collection=self.collection).count(), 2) @@ -65,8 +69,7 @@ def test_delete_selected(self, mock_has_delete_permission): } # This user is not the collection author self.client.force_login(self.user2) - response = self.client.post(self.url_with_filter, data) - self.assertEqual(response.status_code, 403) + self.client.post(self.url_with_filter, data) # Nothing is deleted self.assertEqual(ModerationRequest.objects.filter(collection=self.collection).count(), 2) @@ -78,26 +81,221 @@ def test_delete_selected(self, mock_has_delete_permission): # Nothing is deleted self.assertEqual(ModerationRequest.objects.filter(collection=self.collection).count(), 2) + self.collection.refresh_from_db() + self.assertEqual(self.collection.status, constants.IN_REVIEW) + mock_has_delete_permission.return_value = True self.client.force_login(self.user) response = self.client.post(self.url_with_filter, data) self.assertEqual(response.status_code, 302) self.assertEqual(ModerationRequest.objects.filter(collection=self.collection).count(), 0) + notify_author_mock.assert_called_once_with( + collection=self.collection, + moderation_requests=[self.mr1, self.mr2], + action=constants.ACTION_CANCELLED, + by_user=self.user, + ) + + self.assertFalse(notify_moderators_mock.called) + + # All moderation requests were deleted, so collection should be archived + self.collection.refresh_from_db() + self.assertEqual(self.collection.status, constants.ARCHIVED) + @mock.patch('djangocms_moderation.admin_actions.publish_content_object') def test_publish_selected(self, mock_publish_content_object): fixtures = [self.mr1, self.mr2] data = { 'action': 'publish_selected', - 'select_across': 0, - 'index': 0, ACTION_CHECKBOX_NAME: [str(f.pk) for f in fixtures] } self.client.post(self.url_with_filter, data, follow=True) - self.assertTrue(self.mr1.is_approved()) - self.assertFalse(self.mr2.is_approved()) assert mock_publish_content_object.called # check it has been called only once, i.e. with the approved mr1 mock_publish_content_object.assert_called_once_with(self.mr1.content_object) + + @mock.patch('djangocms_moderation.admin_actions.notify_collection_moderators') + @mock.patch('djangocms_moderation.admin_actions.notify_collection_author') + def test_approve_selected(self, notify_author_mock, notify_moderators_mock): + fixtures = [self.mr1, self.mr2] + data = { + 'action': 'approve_selected', + ACTION_CHECKBOX_NAME: [str(f.pk) for f in fixtures] + } + self.assertFalse(self.mr2.is_approved()) + self.assertTrue(self.mr1.is_approved()) + + self.client.post(self.url_with_filter, data) + + notify_author_mock.assert_called_once_with( + collection=self.collection, + moderation_requests=[self.mr2], + action=self.mr2.get_last_action().action, + by_user=self.user, + ) + + notify_moderators_mock.assert_called_once_with( + collection=self.collection, + moderation_requests=[self.mr2], + action_obj=self.mr2.get_last_action(), + ) + notify_author_mock.reset_mock() + notify_moderators_mock.reset_mock() + + # There are 2 steps so we need to approve both to get mr2 approved + self.assertFalse(self.mr2.is_approved()) + self.assertTrue(self.mr1.is_approved()) + self.collection.refresh_from_db() + self.assertEqual(self.collection.status, constants.IN_REVIEW) + + self.client.force_login(self.user2) + self.client.post(self.url_with_filter, data) + + self.assertTrue(self.mr2.is_approved()) + self.assertTrue(self.mr1.is_approved()) + self.collection.refresh_from_db() + self.assertEqual(self.collection.status, constants.ARCHIVED) + + notify_author_mock.assert_called_once_with( + collection=self.collection, + moderation_requests=[self.mr2], + action=self.mr2.get_last_action().action, + by_user=self.user2, + ) + self.assertFalse(notify_moderators_mock.called) + + @mock.patch('djangocms_moderation.admin_actions.notify_collection_moderators') + @mock.patch('djangocms_moderation.admin_actions.notify_collection_author') + def test_reject_selected(self, notify_author_mock, notify_moderators_mock): + fixtures = [self.mr1, self.mr2] + data = { + 'action': 'reject_selected', + ACTION_CHECKBOX_NAME: [str(f.pk) for f in fixtures] + } + self.assertFalse(self.mr2.is_approved()) + self.assertFalse(self.mr2.is_rejected()) + self.assertTrue(self.mr1.is_approved()) + + self.client.post(self.url_with_filter, data) + + self.assertFalse(self.mr2.is_approved()) + self.assertTrue(self.mr2.is_rejected()) + self.assertTrue(self.mr1.is_approved()) + + self.assertFalse(notify_moderators_mock.called) + + notify_author_mock.assert_called_once_with( + collection=self.collection, + moderation_requests=[self.mr2], + action=self.mr2.get_last_action().action, + by_user=self.user, + ) + + self.collection.refresh_from_db() + self.assertEqual(self.collection.status, constants.IN_REVIEW) + + @mock.patch('djangocms_moderation.admin_actions.notify_collection_moderators') + @mock.patch('djangocms_moderation.admin_actions.notify_collection_author') + def test_resubmit_selected(self, notify_author_mock, notify_moderators_mock): + self.mr2.update_status( + action=ACTION_REJECTED, + by_user=self.user + ) + + fixtures = [self.mr1, self.mr2] + data = { + 'action': 'resubmit_selected', + ACTION_CHECKBOX_NAME: [str(f.pk) for f in fixtures] + } + self.assertTrue(self.mr2.is_rejected()) + self.assertTrue(self.mr1.is_approved()) + + self.client.post(self.url_with_filter, data) + + self.assertFalse(self.mr2.is_rejected()) + self.assertTrue(self.mr1.is_approved()) + + self.assertFalse(notify_author_mock.called) + notify_moderators_mock.assert_called_once_with( + collection=self.collection, + moderation_requests=[self.mr2], + action_obj=self.mr2.get_last_action(), + ) + + self.collection.refresh_from_db() + self.assertEqual(self.collection.status, constants.IN_REVIEW) + + @mock.patch('djangocms_moderation.admin_actions.notify_collection_moderators') + def test_approve_selected_sends_correct_emails(self, notify_moderators_mock): + role4 = Role.objects.create(user=self.user) + # Add two more steps + self.wf.steps.create(role=self.role3, is_required=True, order=1,) + self.wf.steps.create(role=role4, is_required=True, order=1,) + self.user.groups.add(self.group) + + # Add one more, partially approved request + pg3 = create_page(title='Page 3', template='page.html', language='en', ) + self.mr3 = ModerationRequest.objects.create( + content_object=pg3, language='en', collection=self.collection, is_active=True,) + self.mr3.actions.create(by_user=self.user, action=constants.ACTION_STARTED,) + self.mr3.update_status(by_user=self.user, action=constants.ACTION_APPROVED,) + self.mr3.update_status(by_user=self.user2, action=constants.ACTION_APPROVED,) + + self.user.groups.add(self.group) + + fixtures = [self.mr1, self.mr2, self.mr3] + data = { + 'action': 'approve_selected', + ACTION_CHECKBOX_NAME: [str(f.pk) for f in fixtures] + } + + # First post as `self.user` should notify mr1 and mr2 and mr3 moderators + self.client.post(self.url_with_filter, data) + # The notify email will be send accordingly. As mr1 and mr3 are in the + # different stages of approval compared to mr 2, + # we need to send 2 emails to appropriate moderators + self.assertEqual(notify_moderators_mock.call_count, 2) + self.assertEqual( + notify_moderators_mock.call_args_list[0], + mock.call(collection=self.collection, + moderation_requests=[self.mr1, self.mr3], + action_obj=self.mr1.get_last_action() + ) + ) + + self.assertEqual( + notify_moderators_mock.call_args_list[1], + mock.call(collection=self.collection, + moderation_requests=[self.mr2], + action_obj=self.mr2.get_last_action(), + ) + ) + self.assertFalse(self.mr1.is_approved()) + self.assertFalse(self.mr3.is_approved()) + + notify_moderators_mock.reset_mock() + self.client.post(self.url_with_filter, data) + # Second post approves m3 and mr1, but as this is the last stage of + # the approval, there is no need for notification emails anymore + self.assertEqual(notify_moderators_mock.call_count, 0) + self.assertTrue(self.mr1.is_approved()) + self.assertTrue(self.mr3.is_approved()) + + self.client.force_login(self.user2) + # user2 can approve only 1 request, mr2, so one notification email + # should go out + self.client.post(self.url_with_filter, data) + notify_moderators_mock.assert_called_once_with( + collection=self.collection, + moderation_requests=[self.mr2], + action_obj=self.mr2.get_last_action(), + ) + + self.user.groups.remove(self.group) + + # Not all request have been fully approved + self.collection.refresh_from_db() + self.assertEqual(self.collection.status, constants.IN_REVIEW) diff --git a/tests/test_app_registration.py b/tests/test_app_registration.py index 4da9719e..6838abd2 100644 --- a/tests/test_app_registration.py +++ b/tests/test_app_registration.py @@ -8,6 +8,7 @@ from django.apps import apps from django.core.exceptions import ImproperlyConfigured +from django.test import ignore_warnings from cms import app_registration from cms.test_utils.testcases import CMSTestCase @@ -55,6 +56,7 @@ def test_model_not_in_versionables_by_content(self, get_app_config): extension.configure_app(cms_config) +@ignore_warnings(module='djangocms_versioning.helpers') class CMSConfigIntegrationTest(CMSTestCase): def setUp(self): diff --git a/tests/test_forms.py b/tests/test_forms.py index f21126bf..e7243c14 100644 --- a/tests/test_forms.py +++ b/tests/test_forms.py @@ -91,7 +91,7 @@ def test_form_is_invalid_if_collection_cant_be_submitted_for_review(self, allow_ 'moderator': None, } - allow_submit_mock.__get__ = mock.Mock(return_value=False) + allow_submit_mock.return_value = False form = SubmitCollectionForModerationForm( data, collection=self.collection1, @@ -99,7 +99,7 @@ def test_form_is_invalid_if_collection_cant_be_submitted_for_review(self, allow_ ) self.assertFalse(form.is_valid()) - allow_submit_mock.__get__ = mock.Mock(return_value=True) + allow_submit_mock.return_value = True form = SubmitCollectionForModerationForm( data, collection=self.collection1, diff --git a/tests/test_models.py b/tests/test_models.py index df40d778..8eecdecc 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -381,7 +381,6 @@ def test_compliance_number_sequential_number_with_identifier_prefix_backend(self class ModerationRequestActionTest(BaseTestCase): - def test_get_by_user_name(self): action = self.moderation_request3.actions.last() self.assertEqual(action.get_by_user_name(), self.user.username) @@ -485,42 +484,49 @@ def setUp(self): self.page1 = create_page(title='My page 1', template='page.html', language='en',) self.page2 = create_page(title='My page 2', template='page.html', language='en',) - def test_allow_submit_for_review(self): + def test_job_id(self): + self.assertEqual(str(self.collection1.pk), self.collection1.job_id) + self.assertEqual(str(self.collection2.pk), self.collection2.job_id) + + @patch.object(ModerationRequest, 'is_approved') + def test_should_be_archived(self, is_approved_mock): self.collection1.status = constants.COLLECTING self.collection1.save() - # This is false, as we don't have any moderation requests in this collection - self.assertFalse(self.collection1.allow_submit_for_review) + self.assertFalse(self.collection1.should_be_archived()) - ModerationRequest.objects.create( - content_object=self.pg1, collection=self.collection1, is_active=True - ) - self.assertTrue(self.collection1.allow_submit_for_review) + self.collection1.status = constants.ARCHIVED + self.collection1.save() + self.assertFalse(self.collection1.should_be_archived()) self.collection1.status = constants.IN_REVIEW self.collection1.save() - self.assertFalse(self.collection1.allow_submit_for_review) - - def test_allow_pre_flight(self): - self.collection4.status = constants.COLLECTING - self.collection4.save() + self.assertTrue(self.collection1.should_be_archived()) - # This is false, as there are no approved requests and status is COLLECTING - self.assertFalse(self.collection4.allow_pre_flight(self.user)) + ModerationRequest.objects.create( + content_object=self.pg1, collection=self.collection1, is_active=True + ) + is_approved_mock.return_value = False + self.assertFalse(self.collection1.should_be_archived()) - self.collection4.status = constants.IN_REVIEW - self.collection4.save() + is_approved_mock.return_value = True + self.assertTrue(self.collection1.should_be_archived()) - # This is false, as there are no approved requests - self.assertFalse(self.collection4.allow_pre_flight(self.user)) + def test_allow_submit_for_review(self): + self.collection1.status = constants.COLLECTING + self.collection1.save() + # This is false, as we don't have any moderation requests in this collection + self.assertFalse(self.collection1.allow_submit_for_review(user=self.user)) - self.moderation_request5.update_status( - action=constants.ACTION_APPROVED, - by_user=self.user, - message='Approved', + ModerationRequest.objects.create( + content_object=self.pg1, collection=self.collection1, is_active=True ) + self.assertTrue(self.collection1.allow_submit_for_review(user=self.user)) + # Only collection author can submit + self.assertFalse(self.collection1.allow_submit_for_review(user=self.user2)) - self.assertTrue(self.collection4.allow_pre_flight(self.user)) - self.assertFalse(self.collection4.allow_pre_flight(self.user2)) + self.collection1.status = constants.IN_REVIEW + self.collection1.save() + self.assertFalse(self.collection1.allow_submit_for_review(user=self.user)) @patch('djangocms_moderation.models.notify_collection_moderators') def test_submit_for_review(self, mock_ncm): diff --git a/tests/test_views.py b/tests/test_views.py index 5fcb0dcd..af099a19 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -225,10 +225,10 @@ def test_change_list_view_should_contain_collection_object(self): @mock.patch.object(ModerationCollection, 'allow_submit_for_review') def test_change_list_view_should_contain_submit_collection_url(self, allow_submit_mock): - allow_submit_mock.__get__ = mock.Mock(return_value=False) + allow_submit_mock.return_value = False response = self.client.get(self.url_with_filter) self.assertNotIn('submit_for_review_url', response.context) - allow_submit_mock.__get__ = mock.Mock(return_value=True) + allow_submit_mock.return_value = True response = self.client.get(self.url_with_filter) self.assertIn('submit_for_review_url', response.context) diff --git a/tests/utils/app_1/models.py b/tests/utils/app_1/models.py index c0dd2ec2..158bf8a6 100644 --- a/tests/utils/app_1/models.py +++ b/tests/utils/app_1/models.py @@ -8,6 +8,9 @@ class App1Post(models.Model): class App1PostContent(models.Model): post = models.ForeignKey(App1Post, on_delete=models.CASCADE) + def get_absolute_url(self): + return '/' + class App1Title(models.Model): pass @@ -15,3 +18,6 @@ class App1Title(models.Model): class App1TitleContent(models.Model): title = models.ForeignKey(App1Title, on_delete=models.CASCADE) + + def get_absolute_url(self): + return '/' diff --git a/tests/utils/app_2/models.py b/tests/utils/app_2/models.py index 692ba5a5..3d60e8ef 100644 --- a/tests/utils/app_2/models.py +++ b/tests/utils/app_2/models.py @@ -8,6 +8,9 @@ class App2Post(models.Model): class App2PostContent(models.Model): post = models.ForeignKey(App2Post, on_delete=models.CASCADE) + def get_absolute_url(self): + return '/' + class App2Title(models.Model): pass @@ -15,3 +18,6 @@ class App2Title(models.Model): class App2TitleContent(models.Model): title = models.ForeignKey(App2Title, on_delete=models.CASCADE) + + def get_absolute_url(self): + return '/' diff --git a/tests/utils/base.py b/tests/utils/base.py index f82bbb49..170ba3bd 100644 --- a/tests/utils/base.py +++ b/tests/utils/base.py @@ -25,10 +25,10 @@ def setUpTestData(cls): # create pages cls.pg1 = create_page(title='Page 1', template='page.html', language='en',) cls.pg2 = create_page(title='Page 2', template='page.html', language='en',) - cls.pg3 = create_page(title='Page 3', template='page.html', language='en', published=True) + cls.pg3 = create_page(title='Page 3', template='page.html', language='en',) cls.pg4 = create_page(title='Page 4', template='page.html', language='en',) - cls.pg5 = create_page(title='Page 5', template='page.html', language='en', published=True) - cls.pg6 = create_page(title='Page 5', template='page.html', language='en',) + cls.pg5 = create_page(title='Page 5', template='page.html', language='en',) + cls.pg6 = create_page(title='Page 6', template='page.html', language='en',) # create users, groups and roles cls.user = User.objects.create_superuser( From 821ea158c19803907d741c20dffa2a59078f2612 Mon Sep 17 00:00:00 2001 From: Paulo Date: Tue, 4 Sep 2018 16:16:39 -0400 Subject: [PATCH 016/147] Bumped version to 1.0.0 --- djangocms_moderation/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/djangocms_moderation/__init__.py b/djangocms_moderation/__init__.py index 783097c6..c7d56bb1 100644 --- a/djangocms_moderation/__init__.py +++ b/djangocms_moderation/__init__.py @@ -1,3 +1,3 @@ -__version__ = '0.0.7' +__version__ = '1.0.0' default_app_config = 'djangocms_moderation.apps.ModerationConfig' From 4c7bbba7ee06538d970ede93abfcbcf9573e9d52 Mon Sep 17 00:00:00 2001 From: Noel da Costa Date: Wed, 5 Sep 2018 13:18:52 +0100 Subject: [PATCH 017/147] Comments in collection (#48) --- djangocms_moderation/admin.py | 200 +++++++++++++++--- djangocms_moderation/conf.py | 12 ++ djangocms_moderation/forms.py | 37 +++- djangocms_moderation/helpers.py | 14 ++ .../migrations/0002_auto_20180905_1152.py | 52 +++++ djangocms_moderation/models.py | 30 +++ .../collectioncomment/change_list.html | 31 +++ .../requestcomment/change_list.html | 32 +++ .../moderation_request_change_list.html | 21 ++ djangocms_moderation/utils.py | 11 +- djangocms_moderation/views.py | 2 +- tests/test_admin.py | 27 ++- tests/test_utils.py | 43 ++++ 13 files changed, 480 insertions(+), 32 deletions(-) create mode 100644 djangocms_moderation/migrations/0002_auto_20180905_1152.py create mode 100644 djangocms_moderation/templates/admin/djangocms_moderation/collectioncomment/change_list.html create mode 100644 djangocms_moderation/templates/admin/djangocms_moderation/requestcomment/change_list.html create mode 100644 tests/test_utils.py diff --git a/djangocms_moderation/admin.py b/djangocms_moderation/admin.py index 525457be..3dc273b5 100644 --- a/djangocms_moderation/admin.py +++ b/djangocms_moderation/admin.py @@ -19,33 +19,44 @@ resubmit_selected, ) from .constants import ARCHIVED, IN_REVIEW -from .forms import WorkflowStepInlineFormSet -from .helpers import get_form_submission_for_step +from .forms import ( + CollectionCommentForm, + RequestCommentForm, + WorkflowStepInlineFormSet, +) +from .helpers import EditAndAddOnlyFieldsMixin, get_form_submission_for_step from .models import ( + CollectionComment, ConfirmationFormSubmission, ConfirmationPage, ModerationCollection, ModerationRequest, ModerationRequestAction, + RequestComment, Role, Workflow, WorkflowStep, ) +from . import conf # isort:skip +from . import utils # isort:skip from . import views # isort:skip class ModerationRequestActionInline(admin.TabularInline): model = ModerationRequestAction fields = ['show_user', 'message', 'date_taken', 'form_submission'] - readonly_fields = fields + readonly_fields = ['show_user', 'date_taken', 'form_submission'] verbose_name = _('Action') verbose_name_plural = _('Actions') def has_add_permission(self, request): return False + def has_delete_permission(self, request, obj=None): + return False + def show_user(self, obj): _name = obj.get_by_user_name() return ugettext('By {user}').format(user=_name) @@ -79,7 +90,7 @@ class ModerationRequestAdmin(admin.ModelAdmin): resubmit_selected, ] inlines = [ModerationRequestActionInline] - list_display = ['id', 'content_type', 'get_title', 'collection', 'get_preview_link', 'get_status'] + list_filter = ['collection'] fields = ['id', 'collection', 'workflow', 'is_active', 'get_status'] readonly_fields = fields change_list_template = 'djangocms_moderation/moderation_request_change_list.html' @@ -91,6 +102,12 @@ def has_module_permission(self, request): """ return False + def get_list_display(self, request): + list_display = ['id', 'content_type', 'get_title', 'get_content_author', 'get_preview_link', 'get_status'] + if conf.REQUEST_COMMENTS_ENABLED: + list_display.append('get_comments_link') + return list_display + def get_title(self, obj): return obj.content_object get_title.short_description = _('Title') @@ -100,6 +117,23 @@ def get_preview_link(self, obj): return "Link placeholder" get_preview_link.short_description = _('Preview') + def get_comments_link(self, obj): + return format_html( + '{}', + reverse('admin:djangocms_moderation_requestcomment_changelist'), + obj.id, + _('View') + ) + get_comments_link.short_description = _('Comments') + + def get_content_author(self, obj): + """ + This is not necessarily the same person as the RequestAction author + """ + # TODO this should get the author from the version object e.g. obj.content_object.created_by + return "author placeholder" + get_content_author.short_description = _('Content author') + def has_add_permission(self, request): return False @@ -212,6 +246,114 @@ class RoleAdmin(admin.ModelAdmin): fields = ['name', 'user', 'group', 'confirmation_page'] +class CollectionCommentAdmin(admin.ModelAdmin): + list_display = ['message', 'author', 'date_created'] + fields = ['collection', 'message', 'author'] + + def get_changeform_initial_data(self, request): + # Extract the id from the URL. The id is stored in _changelsit_filters + # by Django so that the request knows where to return to after form submission. + data = { + 'author': request.user, + } + collection_id = utils.extract_filter_param_from_changelist_url( + request, '_changelist_filters', 'collection__id__exact' + ) + if collection_id: + data['collection'] = collection_id + return data + + def get_form(self, request, obj=None, **kwargs): + return CollectionCommentForm + + def has_module_permission(self, request): + """ + Hide the model from admin index as it depends on foreighKey + """ + return False + + def changelist_view(self, request, extra_context=None): + # If we filter by a specific collection, we want to add this collection + # to the context + collection_id = request.GET.get('collection__id__exact') + if collection_id: + try: + collection = ModerationCollection.objects.get(pk=int(collection_id)) + request._collection = collection + except (ValueError, ModerationCollection.DoesNotExist): + raise Http404 + else: + extra_context = dict( + collection=collection, + title=_('Collection comments') + ) + else: + raise Http404 + + return super().changelist_view(request, extra_context) + + +class RequestCommentAdmin(admin.ModelAdmin): + list_display = ['message', 'get_request_link', 'author', 'date_created'] + fields = ['moderation_request', 'message', 'author'] + + def get_changeform_initial_data(self, request): + data = { + 'author': request.user, + } + moderation_request_id = utils.extract_filter_param_from_changelist_url( + request, '_changelist_filters', 'moderation_request__id__exact' + ) + if moderation_request_id: + data['moderation_request'] = moderation_request_id + return data + + def get_request_link(self, obj): + opts = ModerationRequest._meta + url = reverse( + 'admin:{}_{}_change'.format(opts.app_label, opts.model_name), + args=[obj.pk], + ) + return format_html( + '{}', + url, + _('View') + ) + get_request_link.short_description = _('Request') + + def get_form(self, request, obj=None, **kwargs): + return RequestCommentForm + + def has_module_permission(self, request): + """ + Hide the model from admin index as it depends on foreighKey + """ + return False + + def changelist_view(self, request, extra_context=None): + # If we filter by a specific collection, we want to add this collection + # to the context + moderation_request_id = request.GET.get('moderation_request__id__exact') + if moderation_request_id: + try: + moderation_request = ModerationRequest.objects.get(pk=int(moderation_request_id)) + collection = moderation_request.collection + request._collection = collection + except (ValueError, ModerationRequest.DoesNotExist): + raise Http404 + else: + extra_context = dict( + collection=collection, + title=_("Request comments") + ) + else: + # If no collection id, then don't show all requests + # as each collection's actions, buttons and privileges may differ + raise Http404 + + return super().changelist_view(request, extra_context) + + class WorkflowStepInline(SortableInlineAdminMixin, admin.TabularInline): formset = WorkflowStepInlineFormSet model = WorkflowStep @@ -234,30 +376,26 @@ class WorkflowAdmin(admin.ModelAdmin): ] -class ModerationCollectionAdmin(admin.ModelAdmin): +class ModerationCollectionAdmin(EditAndAddOnlyFieldsMixin, admin.ModelAdmin): actions = None # remove `delete_selected` for now, it will be handled later - list_display = [ - 'id', - 'get_name_with_requests_link', - 'job_id', - 'get_moderator', - 'workflow', - 'status', - 'date_created', - ] editonly_fields = ('status',) # fields editable only on EDIT addonly_fields = ('workflow',) # fields editable only on CREATE - def get_readonly_fields(self, request, obj=None): - """ - Override to provide editonly_fields and addonly_fields functionality - """ - if obj: # Editing an existing object - return self.readonly_fields + self.addonly_fields - else: # Adding a new object - return self.readonly_fields + self.editonly_fields + def get_list_display(self, request): + list_display = [ + 'id', + 'name', + 'get_moderator', + 'workflow', + 'status', + 'date_created', + 'get_requests_link' + ] + if conf.COLLECTION_COMMENTS_ENABLED: + list_display.append('get_comments_link') + return list_display - def get_name_with_requests_link(self, obj): + def get_requests_link(self, obj): """ Name of the collection should link to the list of associated moderation requests @@ -266,9 +404,18 @@ def get_name_with_requests_link(self, obj): '{}', reverse('admin:djangocms_moderation_moderationrequest_changelist'), obj.pk, - obj.name, + _('View') ) - get_name_with_requests_link.short_description = _('Name') + get_requests_link.short_description = _('Requests') + + def get_comments_link(self, obj): + return format_html( + '{}', + reverse('admin:djangocms_moderation_collectioncomment_changelist'), + obj.id, + _('View') + ) + get_comments_link.short_description = _('Comments') def get_moderator(self, obj): return obj.author @@ -348,9 +495,10 @@ def form_data(self, obj): admin.site.register(ModerationRequest, ModerationRequestAdmin) +admin.site.register(CollectionComment, CollectionCommentAdmin) +admin.site.register(RequestComment, RequestCommentAdmin) admin.site.register(ModerationCollection, ModerationCollectionAdmin) admin.site.register(Role, RoleAdmin) admin.site.register(Workflow, WorkflowAdmin) - admin.site.register(ConfirmationPage, ConfirmationPageAdmin) admin.site.register(ConfirmationFormSubmission, ConfirmationFormSubmissionAdmin) diff --git a/djangocms_moderation/conf.py b/djangocms_moderation/conf.py index cefdd86c..9c9e6559 100644 --- a/djangocms_moderation/conf.py +++ b/djangocms_moderation/conf.py @@ -47,3 +47,15 @@ 'CMS_MODERATION_CONFIRMATION_PAGE_TEMPLATES', CORE_CONFIRMATION_PAGE_TEMPLATES, ) + +COLLECTION_COMMENTS_ENABLED = getattr( + settings, + 'CMS_MODERATION_COLLECTION_COMMENTS_ENABLED', + True, +) + +REQUEST_COMMENTS_ENABLED = getattr( + settings, + 'CMS_MODERATION_REQUEST_COMMENTS_ENABLED', + True, +) diff --git a/djangocms_moderation/forms.py b/djangocms_moderation/forms.py index 4ae23c1b..11a2fcd9 100644 --- a/djangocms_moderation/forms.py +++ b/djangocms_moderation/forms.py @@ -16,7 +16,12 @@ ACTION_RESUBMITTED, COLLECTING, ) -from .models import ModerationCollection, ModerationRequest +from .models import ( + CollectionComment, + ModerationCollection, + ModerationRequest, + RequestComment, +) class WorkflowStepInlineFormSet(CustomInlineFormSet): @@ -200,3 +205,33 @@ def save(self): by_user=self.user, to_user=self.cleaned_data.get('moderator'), ) + + +class CollectionCommentForm(forms.ModelForm): + """ + The author and moderation request should be pre-filled and non-editable. + NB: Hidden fields seems to be the only reliable way to do this; + readonly fields do not work for add, only for edit. + """ + class Meta: + model = CollectionComment + fields = '__all__' + widgets = { + 'author': forms.HiddenInput(), + 'collection': forms.HiddenInput(), + } + + +class RequestCommentForm(forms.ModelForm): + """ + The author and moderation request should be pre-filled and non-editable. + NB: Hidden fields seems to be the only reliable way to do this; + readonly fields do not work for add, only for edit. + """ + class Meta: + model = RequestComment + fields = '__all__' + widgets = { + 'author': forms.HiddenInput(), + 'moderation_request': forms.HiddenInput(), + } diff --git a/djangocms_moderation/helpers.py b/djangocms_moderation/helpers.py index 0dfed959..6d6b7f3d 100644 --- a/djangocms_moderation/helpers.py +++ b/djangocms_moderation/helpers.py @@ -50,3 +50,17 @@ def get_form_submission_for_step(active_request, current_step): .filter(request=active_request, for_step=current_step) ) return lookup.first() + + +class EditAndAddOnlyFieldsMixin(object): + editonly_fields = () + addonly_fields = () + + def get_readonly_fields(self, request, obj=None): + """ + Override to provide editonly_fields and addonly_fields functionality + """ + if obj: # Editing an existing object + return self.readonly_fields + self.editonly_fields + else: # Adding a new object + return self.readonly_fields + self.addonly_fields diff --git a/djangocms_moderation/migrations/0002_auto_20180905_1152.py b/djangocms_moderation/migrations/0002_auto_20180905_1152.py new file mode 100644 index 00000000..4c7af74f --- /dev/null +++ b/djangocms_moderation/migrations/0002_auto_20180905_1152.py @@ -0,0 +1,52 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.13 on 2018-09-05 10:52 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('djangocms_moderation', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='CollectionComment', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('message', models.TextField(blank=True, verbose_name='message')), + ('date_created', models.DateTimeField(auto_now_add=True)), + ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='author')), + ('collection', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='djangocms_moderation.ModerationCollection')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='RequestComment', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('message', models.TextField(blank=True, verbose_name='message')), + ('date_created', models.DateTimeField(auto_now_add=True)), + ('author', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='author')), + ], + options={ + 'abstract': False, + }, + ), + migrations.AlterModelOptions( + name='moderationrequest', + options={'ordering': ['id'], 'verbose_name': 'Request', 'verbose_name_plural': 'Requests'}, + ), + migrations.AddField( + model_name='requestcomment', + name='moderation_request', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='djangocms_moderation.ModerationRequest'), + ), + ] diff --git a/djangocms_moderation/models.py b/djangocms_moderation/models.py index a7f17e81..6a04aa5f 100644 --- a/djangocms_moderation/models.py +++ b/djangocms_moderation/models.py @@ -642,6 +642,36 @@ def save(self, **kwargs): super(ModerationRequestAction, self).save(**kwargs) +class AbstractComment(models.Model): + message = models.TextField( + blank=True, + verbose_name=_('message'), + ) + author = models.ForeignKey( + to=settings.AUTH_USER_MODEL, + verbose_name=_('author'), + on_delete=models.CASCADE, + ) + date_created = models.DateTimeField(auto_now_add=True) + + class Meta: + abstract = True + + +class CollectionComment(AbstractComment): + collection = models.ForeignKey( + to=ModerationCollection, + on_delete=models.CASCADE, + ) + + +class RequestComment(AbstractComment): + moderation_request = models.ForeignKey( + to=ModerationRequest, + on_delete=models.CASCADE, + ) + + class ConfirmationFormSubmission(models.Model): request = models.ForeignKey( to=ModerationRequest, diff --git a/djangocms_moderation/templates/admin/djangocms_moderation/collectioncomment/change_list.html b/djangocms_moderation/templates/admin/djangocms_moderation/collectioncomment/change_list.html new file mode 100644 index 00000000..c5e5a79e --- /dev/null +++ b/djangocms_moderation/templates/admin/djangocms_moderation/collectioncomment/change_list.html @@ -0,0 +1,31 @@ +{% extends "admin/change_list.html" %} +{% load i18n %} + +{% block object-tools-items %} +{{ block.super }} +{% if submit_for_review_url %} +
    • + {% trans 'Submit for review' %} +
    • +{% endif %} +{% endblock %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +{{ collection }} +{{ block.super }} + +{% comment %} + TODO This template will be overridden to provide the required functionality. + For now, lets just output the selected collection +{% endcomment %} + +{% endblock %} diff --git a/djangocms_moderation/templates/admin/djangocms_moderation/requestcomment/change_list.html b/djangocms_moderation/templates/admin/djangocms_moderation/requestcomment/change_list.html new file mode 100644 index 00000000..55607ab1 --- /dev/null +++ b/djangocms_moderation/templates/admin/djangocms_moderation/requestcomment/change_list.html @@ -0,0 +1,32 @@ +{% extends "admin/change_list.html" %} +{% load i18n %} +{% load l10n %} + +{% block object-tools-items %} +{{ block.super }} +{% if submit_for_review_url %} +
    • + {% trans 'Submit for review' %} +
    • +{% endif %} +{% endblock %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +{{ block.super }} + +{% comment %} + TODO This template will be overridden to provide the required functionality. + For now, lets just output the selected collection +{% endcomment %} + +{% endblock %} diff --git a/djangocms_moderation/templates/djangocms_moderation/moderation_request_change_list.html b/djangocms_moderation/templates/djangocms_moderation/moderation_request_change_list.html index e1500e07..97a7d72b 100644 --- a/djangocms_moderation/templates/djangocms_moderation/moderation_request_change_list.html +++ b/djangocms_moderation/templates/djangocms_moderation/moderation_request_change_list.html @@ -21,3 +21,24 @@

      {{ collection.name }}

      {% endif %} {% endblock %} + +{% block breadcrumbs %} + +{% endblock %} + +{% block content %} +{{ collection }} +{{ block.super }} + +{% comment %} + TODO This template will be overridden to provide the required functionality. + For now, lets just output the selected collection +{% endcomment %} + +{% endblock %} + diff --git a/djangocms_moderation/utils.py b/djangocms_moderation/utils.py index 3b821ea1..b9bd11d4 100644 --- a/djangocms_moderation/utils.py +++ b/djangocms_moderation/utils.py @@ -4,7 +4,7 @@ from django.contrib.sites.models import Site from django.utils.lru_cache import lru_cache from django.utils.module_loading import import_string -from django.utils.six.moves.urllib.parse import urljoin +from django.utils.six.moves.urllib.parse import parse_qs, urljoin from django.utils.translation import override as force_language from cms.utils.urlutils import admin_reverse @@ -35,3 +35,12 @@ def load_backend(path): def generate_compliance_number(path, **kwargs): backend = load_backend(path) return backend(**kwargs) + + +def extract_filter_param_from_changelist_url(request, keyname, parametername): + """ + Searches request.GET for a given key and decodes the value for a particular parameter + """ + changelist_filters = request.GET.get(keyname) + parameter_value = parse_qs(changelist_filters).get(parametername) + return parameter_value[0] diff --git a/djangocms_moderation/views.py b/djangocms_moderation/views.py index 1de4b0a8..5ac66c96 100644 --- a/djangocms_moderation/views.py +++ b/djangocms_moderation/views.py @@ -145,7 +145,7 @@ def get_form_kwargs(self): def form_valid(self, form): form.save() - messages.success(self.request, _("Your collection has been submitted for a review")) + messages.success(self.request, _("Your collection has been submitted for review")) # Redirect back to the collection filtered moderation request change list redirect_url = reverse('admin:djangocms_moderation_moderationrequest_changelist') redirect_url = "{}?collection__id__exact={}".format( diff --git a/tests/test_admin.py b/tests/test_admin.py index a6be7bb8..65d6ea1b 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -3,8 +3,11 @@ from cms.api import create_page -from djangocms_moderation import constants -from djangocms_moderation.admin import ModerationRequestAdmin +from djangocms_moderation import conf, constants +from djangocms_moderation.admin import ( + ModerationCollectionAdmin, + ModerationRequestAdmin, +) from djangocms_moderation.constants import ACTION_REJECTED from djangocms_moderation.models import ( ModerationCollection, @@ -19,7 +22,7 @@ class MockRequest: GET = {} -class ModerationRequestAdminTestCase(BaseTestCase): +class ModerationAdminTestCase(BaseTestCase): def setUp(self): self.wf = Workflow.objects.create(name='Workflow Test',) self.collection = ModerationCollection.objects.create( @@ -53,6 +56,7 @@ def setUp(self): self.url, self.collection.pk ) self.mra = ModerationRequestAdmin(ModerationRequest, admin.AdminSite()) + self.mca = ModerationCollectionAdmin(ModerationCollection, admin.AdminSite()) def test_delete_selected_action_visibility(self): mock_request = MockRequest() @@ -149,3 +153,20 @@ def test_in_review_status_is_considered(self): self.collection.save() actions = self.mra.get_actions(request=mock_request) self.assertIn('approve_selected', actions) + + def test_change_list_view_should_respect_conf(self): + mock_request = MockRequest() + mock_request.user = self.user + mock_request._collection = self.collection + conf.COLLECTION_COMMENTS_ENABLED = False + list_display = self.mca.get_list_display(mock_request) + self.assertNotIn('get_comments_link', list_display) + conf.COLLECTION_COMMENTS_ENABLED = True + list_display = self.mca.get_list_display(mock_request) + self.assertIn('get_comments_link', list_display) + conf.REQUEST_COMMENTS_ENABLED = False + list_display = self.mra.get_list_display(mock_request) + self.assertNotIn('get_comments_link', list_display) + conf.REQUEST_COMMENTS_ENABLED = True + list_display = self.mra.get_list_display(mock_request) + self.assertIn('get_comments_link', list_display) diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 00000000..c1e0919f --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,43 @@ +from django.test.client import RequestFactory + +from djangocms_moderation import utils + +from .utils.base import BaseTestCase + + +class UtilsTestCase(BaseTestCase): + def setUp(self): + self.rf = RequestFactory() + + def test_extract_filter_param_from_changelist_url(self): + mock_request = self.rf.get( + '/admin/djangocms_moderation/collectioncomment/add/?_changelist_filters=collection__id__exact%3D1' + ) + collection_id = utils.extract_filter_param_from_changelist_url( + mock_request, '_changelist_filters', 'collection__id__exact' + ) + self.assertEquals(collection_id, '1') + + mock_request = self.rf.get( + '/admin/djangocms_moderation/collectioncomment/add/?_changelist_filters=collection__id__exact%3D4' + ) + collection_id = utils.extract_filter_param_from_changelist_url( + mock_request, '_changelist_filters', 'collection__id__exact' + ) + self.assertEquals(collection_id, '4') + + mock_request = self.rf.get( + '/admin/djangocms_moderation/requestcomment/add/?_changelist_filters=moderation_request__id__exact%3D1' + ) + action_id = utils.extract_filter_param_from_changelist_url( + mock_request, '_changelist_filters', 'moderation_request__id__exact' + ) + self.assertEquals(action_id, '1') + + mock_request = self.rf.get( + '/admin/djangocms_moderation/requestcomment/add/?_changelist_filters=moderation_request__id__exact%3D2' + ) + action_id = utils.extract_filter_param_from_changelist_url( + mock_request, '_changelist_filters', 'moderation_request__id__exact' + ) + self.assertEquals(action_id, '2') From b850e1c25dd597b794ac67eb55c6d5903b5f7934 Mon Sep 17 00:00:00 2001 From: Matus Moravcik Date: Wed, 5 Sep 2018 14:57:46 +0100 Subject: [PATCH 018/147] Fix EditAndAddOnlyFieldsMixin behaviour (#50) --- djangocms_moderation/helpers.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/djangocms_moderation/helpers.py b/djangocms_moderation/helpers.py index 6d6b7f3d..64a08106 100644 --- a/djangocms_moderation/helpers.py +++ b/djangocms_moderation/helpers.py @@ -60,7 +60,7 @@ def get_readonly_fields(self, request, obj=None): """ Override to provide editonly_fields and addonly_fields functionality """ - if obj: # Editing an existing object - return self.readonly_fields + self.editonly_fields - else: # Adding a new object + if obj: # Editing an existing object, so `addonly_fields` should be readonly return self.readonly_fields + self.addonly_fields + else: # Adding a new object + return self.readonly_fields + self.editonly_fields From 411d00e8ec245a2673a206854ffbdc64d51dd562 Mon Sep 17 00:00:00 2001 From: Paulo Date: Wed, 5 Sep 2018 10:27:29 -0400 Subject: [PATCH 019/147] Bumped version to 1.0.1 --- djangocms_moderation/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/djangocms_moderation/__init__.py b/djangocms_moderation/__init__.py index c7d56bb1..b126bbfb 100644 --- a/djangocms_moderation/__init__.py +++ b/djangocms_moderation/__init__.py @@ -1,3 +1,3 @@ -__version__ = '1.0.0' +__version__ = '1.0.1' default_app_config = 'djangocms_moderation.apps.ModerationConfig' From ef03f5ce524ec34e1827d7479d1679af847c144c Mon Sep 17 00:00:00 2001 From: Matus Moravcik Date: Wed, 5 Sep 2018 15:51:27 +0100 Subject: [PATCH 020/147] Remove collection filter from moderationrequest admin (#51) --- djangocms_moderation/admin.py | 1 - 1 file changed, 1 deletion(-) diff --git a/djangocms_moderation/admin.py b/djangocms_moderation/admin.py index 3dc273b5..badb970c 100644 --- a/djangocms_moderation/admin.py +++ b/djangocms_moderation/admin.py @@ -90,7 +90,6 @@ class ModerationRequestAdmin(admin.ModelAdmin): resubmit_selected, ] inlines = [ModerationRequestActionInline] - list_filter = ['collection'] fields = ['id', 'collection', 'workflow', 'is_active', 'get_status'] readonly_fields = fields change_list_template = 'djangocms_moderation/moderation_request_change_list.html' From 262a99ce9244e1feeff2cdd0a4cf5476312f71b2 Mon Sep 17 00:00:00 2001 From: Matus Moravcik Date: Thu, 6 Sep 2018 12:09:30 +0100 Subject: [PATCH 021/147] Correct the get_request_link in admin (#52) --- djangocms_moderation/admin.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/djangocms_moderation/admin.py b/djangocms_moderation/admin.py index badb970c..7f14b6d5 100644 --- a/djangocms_moderation/admin.py +++ b/djangocms_moderation/admin.py @@ -311,7 +311,7 @@ def get_request_link(self, obj): opts = ModerationRequest._meta url = reverse( 'admin:{}_{}_change'.format(opts.app_label, opts.model_name), - args=[obj.pk], + args=[obj.moderation_request.pk], ) return format_html( '{}', From 856d1c9fb903c77547378608d3cce0b175c722d6 Mon Sep 17 00:00:00 2001 From: Paulo Date: Thu, 6 Sep 2018 10:47:41 -0400 Subject: [PATCH 022/147] Bumped version to 1.0.2 --- djangocms_moderation/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/djangocms_moderation/__init__.py b/djangocms_moderation/__init__.py index b126bbfb..dc4c7752 100644 --- a/djangocms_moderation/__init__.py +++ b/djangocms_moderation/__init__.py @@ -1,3 +1,3 @@ -__version__ = '1.0.1' +__version__ = '1.0.2' default_app_config = 'djangocms_moderation.apps.ModerationConfig' From 9c180b4c1111bbd6717481b62291420d22fd9ee8 Mon Sep 17 00:00:00 2001 From: Matus Moravcik Date: Thu, 6 Sep 2018 15:23:45 +0100 Subject: [PATCH 023/147] Remove generic foreign key in ModerationRequest (#49) --- djangocms_moderation/admin.py | 4 +- djangocms_moderation/admin_actions.py | 6 +-- djangocms_moderation/cms_toolbars.py | 18 +++---- djangocms_moderation/forms.py | 31 ++++------- djangocms_moderation/handlers.py | 51 +++++-------------- djangocms_moderation/helpers.py | 20 +------- .../migrations/0003_auto_20180903_1206.py | 35 +++++++++++++ djangocms_moderation/models.py | 22 ++++---- .../emails/moderation-request/approved.txt | 5 +- .../emails/moderation-request/cancelled.txt | 4 +- .../emails/moderation-request/rejected.txt | 4 +- .../emails/moderation-request/request.txt | 6 +-- .../item_to_collection.html | 12 ++--- djangocms_moderation/views.py | 10 ++-- tests/requirements.txt | 2 + tests/settings.py | 16 ++++++ tests/test_admin.py | 10 ++-- tests/test_admin_actions.py | 23 ++++----- tests/test_cms_toolbars.py | 8 +-- tests/test_forms.py | 8 +-- tests/test_handlers.py | 7 ++- tests/test_helpers.py | 17 ++----- tests/test_models.py | 36 +++---------- tests/test_moderation_flows.py | 4 +- tests/test_views.py | 45 ++++++++-------- tests/utils/base.py | 30 +++++------ 26 files changed, 193 insertions(+), 241 deletions(-) create mode 100644 djangocms_moderation/migrations/0003_auto_20180903_1206.py diff --git a/djangocms_moderation/admin.py b/djangocms_moderation/admin.py index 7f14b6d5..dc6c7bd6 100644 --- a/djangocms_moderation/admin.py +++ b/djangocms_moderation/admin.py @@ -102,13 +102,13 @@ def has_module_permission(self, request): return False def get_list_display(self, request): - list_display = ['id', 'content_type', 'get_title', 'get_content_author', 'get_preview_link', 'get_status'] + list_display = ['id', 'version', 'get_title', 'get_content_author', 'get_preview_link', 'get_status'] if conf.REQUEST_COMMENTS_ENABLED: list_display.append('get_comments_link') return list_display def get_title(self, obj): - return obj.content_object + return obj.version.content get_title.short_description = _('Title') def get_preview_link(self, obj): diff --git a/djangocms_moderation/admin_actions.py b/djangocms_moderation/admin_actions.py index e6f37403..9488ade0 100644 --- a/djangocms_moderation/admin_actions.py +++ b/djangocms_moderation/admin_actions.py @@ -195,7 +195,7 @@ def publish_selected(modeladmin, request, queryset): for moderation_request in queryset.all(): if moderation_request.is_approved(): num_published_requests += 1 - publish_content_object(moderation_request.content_object) + publish_version(moderation_request.version) # notify the UI of the action results messages.success( @@ -219,6 +219,6 @@ def post_bulk_actions(collection): collection.save(update_fields=['status']) -def publish_content_object(content_object): - # TODO: e.g.moderation_request.content_object.publish(request.user) +def publish_version(version): + # TODO: e.g.moderation_request.version.publish(request.user) return True diff --git a/djangocms_moderation/cms_toolbars.py b/djangocms_moderation/cms_toolbars.py index 0f0c63da..134eb133 100644 --- a/djangocms_moderation/cms_toolbars.py +++ b/djangocms_moderation/cms_toolbars.py @@ -1,11 +1,12 @@ # -*- coding: utf-8 -*- -from django.contrib.contenttypes.models import ContentType from django.utils.translation import ugettext_lazy as _ from cms.toolbar_base import CMSToolbar from cms.toolbar_pool import toolbar_pool from cms.utils.urlutils import add_url_parameters +from djangocms_versioning.models import Version + from .models import ModerationRequest from .utils import get_admin_url @@ -18,21 +19,18 @@ class Media: } def post_template_populate(self): - """ - @TODO replace page object with generic content object - :return: - """ - super(ModerationToolbar, self).post_template_populate() + super().post_template_populate() + # TODO replace page object with generic content object page = self.request.current_page if not page: return None try: - content_type = ContentType.objects.get_for_model(page) + # TODO Make this work with the correct version + version = Version.objects.get(pk=9999) moderation_request = ModerationRequest.objects.get( - content_type=content_type, - object_id=page.pk, + version=version ) self.toolbar.add_modal_button( name=_('In Moderation "%s"' % moderation_request.collection.name), @@ -40,7 +38,7 @@ def post_template_populate(self): disabled=True, side=self.toolbar.RIGHT, ) - except ModerationRequest.DoesNotExist: + except Version.DoesNotExist: url = add_url_parameters( get_admin_url( name='cms_moderation_item_to_collection', diff --git a/djangocms_moderation/forms.py b/djangocms_moderation/forms.py index 11a2fcd9..76a21228 100644 --- a/djangocms_moderation/forms.py +++ b/djangocms_moderation/forms.py @@ -4,11 +4,11 @@ from django.contrib import admin from django.contrib.admin.widgets import RelatedFieldWidgetWrapper from django.contrib.auth import get_user_model -from django.contrib.contenttypes.models import ContentType from django.forms.forms import NON_FIELD_ERRORS from django.utils.translation import ugettext, ugettext_lazy as _ from adminsortable2.admin import CustomInlineFormSet +from djangocms_versioning.models import Version from .constants import ( ACTION_CANCELLED, @@ -118,12 +118,11 @@ class CollectionItemForm(forms.Form): queryset=ModerationCollection.objects.filter(status=COLLECTING), required=True ) - content_type = forms.ModelChoiceField( - queryset=ContentType.objects.filter(app_label="cms", model="page"), + version = forms.ModelChoiceField( + queryset=Version.objects.all(), required=True, widget=forms.HiddenInput(), ) - content_object_id = forms.IntegerField() def set_collection_widget(self, request): related_modeladmin = admin.site._registry.get(ModerationCollection) @@ -148,31 +147,19 @@ def clean(self): if self.errors: return self.cleaned_data - content_type = self.cleaned_data['content_type'] + version = self.cleaned_data['version'] - try: - content_object = content_type.get_object_for_this_type( - pk=self.cleaned_data['content_object_id'], - is_page_type=False, - ) - except content_type.model_class().DoesNotExist: - content_object = None - - if not content_object: - raise forms.ValidationError(_('Invalid content_object_id, does not exist')) - - request_with_object_exists = ModerationRequest.objects.filter( - content_type=content_type, - object_id=content_object.pk, + request_with_version_exists = ModerationRequest.objects.filter( + version=version ).exists() - if request_with_object_exists: + if request_with_version_exists: raise forms.ValidationError(_( "{} is already part of existing moderation request which is part " - "of another active collection".format(content_object) + "of another active collection".format(version.content) )) - self.cleaned_data['content_object'] = content_object + self.cleaned_data['version'] = version return self.cleaned_data diff --git a/djangocms_moderation/handlers.py b/djangocms_moderation/handlers.py index 4a9759b1..59ba13b1 100644 --- a/djangocms_moderation/handlers.py +++ b/djangocms_moderation/handlers.py @@ -4,52 +4,25 @@ from django.dispatch import receiver -from cms.operations import PUBLISH_PAGE_TRANSLATION -from cms.signals import post_obj_operation - -from .constants import ACTION_FINISHED -from .helpers import get_active_moderation_request from .models import ConfirmationFormSubmission from .signals import confirmation_form_submission -@receiver(post_obj_operation) -def close_moderation_request(sender, **kwargs): - request = kwargs['request'] - operation_type = kwargs['operation'] - is_publish = operation_type == PUBLISH_PAGE_TRANSLATION - publish_successful = kwargs.get('successful') - - if not is_publish or not publish_successful: - return - - page = kwargs['obj'] - translation = kwargs['translation'] - - active_request = get_active_moderation_request(page, translation.language) - - if not active_request: - return - - active_request.update_status( - action=ACTION_FINISHED, - by_user=request.user, - ) - - @receiver(confirmation_form_submission) def moderation_confirmation_form_submission(sender, page, language, user, form_data, **kwargs): for field_data in form_data: if not set(('label', 'value')).issubset(field_data): raise ValueError('Each field dict should contain label and value keys.') - active_request = get_active_moderation_request(page, language) - next_step = active_request.user_get_step(user) - - ConfirmationFormSubmission.objects.create( - request=active_request, - for_step=next_step, - by_user=user, - data=json.dumps(form_data), - confirmation_page=next_step.role.confirmation_page, - ) + # TODO Confirmation pages are not used/working in 1.0.x yet + active_request = None # get_active_moderation_request(page, language) + if active_request: + next_step = active_request.user_get_step(user) + + ConfirmationFormSubmission.objects.create( + request=active_request, + for_step=next_step, + by_user=user, + data=json.dumps(form_data), + confirmation_page=next_step.role.confirmation_page, + ) diff --git a/djangocms_moderation/helpers.py b/djangocms_moderation/helpers.py index 64a08106..0d01e346 100644 --- a/djangocms_moderation/helpers.py +++ b/djangocms_moderation/helpers.py @@ -1,6 +1,6 @@ from django.contrib.contenttypes.models import ContentType -from .models import ConfirmationFormSubmission, ModerationRequest, Workflow +from .models import ConfirmationFormSubmission, Workflow def get_default_workflow(): @@ -13,27 +13,11 @@ def get_default_workflow(): def get_moderation_workflow(): # TODO for now just return default, this would need to depend on the collection - # Might be as well not needed in 4.x, leaving it here for now + # Might be as well not needed in 1.0.x, leaving it here for now return get_default_workflow() -def get_active_moderation_request(obj, language): - content_type = ContentType.objects.get_for_model(obj) - try: - return ModerationRequest.objects.get( - content_type=content_type, - object_id=obj.pk, - language=language, - is_active=True, - ) - except ModerationRequest.DoesNotExist: - return None - - def get_page_or_404(obj_id, language): - """ - TODO is this needed in 4.x? - """ content_type = ContentType.objects.get(app_label="cms", model="page") # how do we get this return content_type.get_object_for_this_type( diff --git a/djangocms_moderation/migrations/0003_auto_20180903_1206.py b/djangocms_moderation/migrations/0003_auto_20180903_1206.py new file mode 100644 index 00000000..52bcc9e1 --- /dev/null +++ b/djangocms_moderation/migrations/0003_auto_20180903_1206.py @@ -0,0 +1,35 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.13 on 2018-09-03 11:06 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('djangocms_versioning', '0010_version_proxies'), + ('djangocms_moderation', '0002_auto_20180905_1152'), + ] + + operations = [ + migrations.AddField( + model_name='moderationrequest', + name='version', + field=models.ForeignKey(default=0, on_delete=django.db.models.deletion.CASCADE, to='djangocms_versioning.Version'), + preserve_default=False, + ), + migrations.AlterUniqueTogether( + name='moderationrequest', + unique_together=set([('collection', 'version')]), + ), + migrations.RemoveField( + model_name='moderationrequest', + name='content_type', + ), + migrations.RemoveField( + model_name='moderationrequest', + name='object_id', + ), + ] diff --git a/djangocms_moderation/models.py b/djangocms_moderation/models.py index 6a04aa5f..c6f23615 100644 --- a/djangocms_moderation/models.py +++ b/djangocms_moderation/models.py @@ -5,8 +5,6 @@ from django.conf import settings from django.contrib.auth import get_user_model from django.contrib.auth.models import Group -from django.contrib.contenttypes.fields import GenericForeignKey -from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.core.urlresolvers import reverse from django.db import models, transaction @@ -16,6 +14,8 @@ from cms.models.fields import PlaceholderField +from djangocms_versioning.models import Version + from .emails import notify_collection_moderators from .utils import generate_compliance_number @@ -327,16 +327,14 @@ def should_be_archived(self): return False return True - def add_object(self, content_object): + def add_version(self, version): """ - Add object to the ModerationRequest in this collection. + Add version to the ModerationRequest in this collection. Requires validation from .forms.CollectionItemForm :return: """ - content_type = ContentType.objects.get_for_model(content_object) return self.moderation_requests.create( - content_type=content_type, - object_id=content_object.pk, + version=version, collection=self, ) @@ -348,18 +346,18 @@ class ModerationRequest(models.Model): related_name='moderation_requests', on_delete=models.CASCADE ) - content_type = models.ForeignKey( - ContentType, + version = models.ForeignKey( + to=Version, + verbose_name=_('version'), on_delete=models.CASCADE, ) - object_id = models.PositiveIntegerField() - content_object = GenericForeignKey('content_type', 'object_id') language = models.CharField( verbose_name=_('language'), max_length=5, choices=settings.LANGUAGES, ) is_active = models.BooleanField( + verbose_name=_('is active'), default=False, db_index=True, ) @@ -379,7 +377,7 @@ class ModerationRequest(models.Model): class Meta: verbose_name = _('Request') verbose_name_plural = _('Requests') - unique_together = ('collection', 'object_id', 'content_type') + unique_together = ('collection', 'version',) ordering = ['id'] def __str__(self): diff --git a/djangocms_moderation/templates/djangocms_moderation/emails/moderation-request/approved.txt b/djangocms_moderation/templates/djangocms_moderation/emails/moderation-request/approved.txt index 4bfcc35f..dbb52b4e 100644 --- a/djangocms_moderation/templates/djangocms_moderation/emails/moderation-request/approved.txt +++ b/djangocms_moderation/templates/djangocms_moderation/emails/moderation-request/approved.txt @@ -9,14 +9,13 @@ requests in the collection {{ collection_name }}.

        -{% for moderation_request in moderation_requests %} +{% for mr in moderation_requests %}
      • - {{ moderation_request.pk }} - {{ moderation_request.content_object }} ({{ moderation_request.content_type }}) + {{ mr.pk }} - {{ mr.version.content }} ({{ mr.version.content_type }})
      • {% endfor %}
      - {% if comment %} {% trans 'Comment' %}:
      {{ comment }}
      diff --git a/djangocms_moderation/templates/djangocms_moderation/emails/moderation-request/cancelled.txt b/djangocms_moderation/templates/djangocms_moderation/emails/moderation-request/cancelled.txt index c1f0ae98..84859fd6 100644 --- a/djangocms_moderation/templates/djangocms_moderation/emails/moderation-request/cancelled.txt +++ b/djangocms_moderation/templates/djangocms_moderation/emails/moderation-request/cancelled.txt @@ -9,9 +9,9 @@ Hello {{ collection_author }}, the following moderation requests in the collecti

        -{% for moderation_request in moderation_requests %} +{% for mr in moderation_requests %}
      • - {{ moderation_request.pk }} - {{ moderation_request.content_object }} ({{ moderation_request.content_type }}) + {{ mr.pk }} - {{ mr.version.content }} ({{ mr.version.content_type }})
      • {% endfor %}
      diff --git a/djangocms_moderation/templates/djangocms_moderation/emails/moderation-request/rejected.txt b/djangocms_moderation/templates/djangocms_moderation/emails/moderation-request/rejected.txt index 2995502b..a75bdc8e 100644 --- a/djangocms_moderation/templates/djangocms_moderation/emails/moderation-request/rejected.txt +++ b/djangocms_moderation/templates/djangocms_moderation/emails/moderation-request/rejected.txt @@ -12,9 +12,9 @@ requests in the collection {{ collection_name }}.

        -{% for moderation_request in moderation_requests %} +{% for mr in moderation_requests %}
      • - {{ moderation_request.pk }} - {{ moderation_request.content_object }} ({{ moderation_request.content_type }}) + {{ mr.pk }} - {{ mr.version.content }} ({{ mr.version.content_type }})
      • {% endfor %}
      diff --git a/djangocms_moderation/templates/djangocms_moderation/emails/moderation-request/request.txt b/djangocms_moderation/templates/djangocms_moderation/emails/moderation-request/request.txt index f3767b0c..47138c72 100644 --- a/djangocms_moderation/templates/djangocms_moderation/emails/moderation-request/request.txt +++ b/djangocms_moderation/templates/djangocms_moderation/emails/moderation-request/request.txt @@ -7,15 +7,15 @@ changes in the collection {{ collection_name }}.

      {% trans 'Items included in this request' %}:

      +
        -{% for moderation_request in moderation_requests %} +{% for mr in moderation_requests %}
      • - {{ moderation_request.pk }} - {{ moderation_request.content_object }} ({{ moderation_request.content_type }}) + {{ mr.pk }} - {{ mr.version.content }} ({{ mr.version.content_type }})
      • {% endfor %}
      - {% if comment %} {% trans 'Comment' %}:
      {{ comment }}
      diff --git a/djangocms_moderation/templates/djangocms_moderation/item_to_collection.html b/djangocms_moderation/templates/djangocms_moderation/item_to_collection.html index 9f752c07..1979b133 100644 --- a/djangocms_moderation/templates/djangocms_moderation/item_to_collection.html +++ b/djangocms_moderation/templates/djangocms_moderation/item_to_collection.html @@ -11,10 +11,8 @@

      {% trans "Add to existing collection" %}

      {% endif %}
      @@ -23,8 +21,7 @@

      {% trans "Add to existing collection" %}

      - - + @@ -34,8 +31,7 @@

      {% trans "Add to existing collection" %}

      {% for content_object in content_object_list %} - - + diff --git a/djangocms_moderation/views.py b/djangocms_moderation/views.py index 5ac66c96..40319abc 100644 --- a/djangocms_moderation/views.py +++ b/djangocms_moderation/views.py @@ -1,14 +1,12 @@ from __future__ import unicode_literals from django.contrib import admin, messages -from django.contrib.contenttypes.models import ContentType from django.core.urlresolvers import reverse from django.http import HttpResponseRedirect from django.shortcuts import get_object_or_404, render from django.utils.translation import ugettext_lazy as _ from django.views.generic import FormView -from cms.models import Page from cms.utils.urlutils import add_url_parameters from .forms import CollectionItemForm, SubmitCollectionForModerationForm @@ -26,10 +24,8 @@ class CollectionItemView(FormView): def get_form_kwargs(self): kwargs = super(CollectionItemView, self).get_form_kwargs() - # TODO: replace page object with Version object kwargs['initial'].update({ - 'content_object_id': self.request.GET.get('content_object_id'), - 'content_type': ContentType.objects.get_for_model(Page).pk, + 'version': self.request.GET.get('version_id'), }) collection_id = self.request.GET.get('collection_id') @@ -38,9 +34,9 @@ def get_form_kwargs(self): return kwargs def form_valid(self, form): - content_object = form.cleaned_data['content_object'] + version = form.cleaned_data['version'] collection = form.cleaned_data['collection'] - collection.add_object(content_object) + collection.add_version(version) messages.success(self.request, _('Item successfully added to moderation collection')) return render(self.request, self.success_template_name, {}) diff --git a/tests/requirements.txt b/tests/requirements.txt index e31ee985..1e0cfb79 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -3,6 +3,8 @@ tox coverage flake8 isort +factory-boy +freezegun -e git+git://github.com/aldryn/aldryn-forms.git@master#egg=aldryn-forms mock -e git+git://github.com/divio/djangocms-versioning.git@master#egg=djangocms-versioning diff --git a/tests/settings.py b/tests/settings.py index 770a6acd..a6a91918 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -9,7 +9,23 @@ 'aldryn_forms', 'captcha', 'emailit', + 'djangocms_text_ckeditor', + 'djangocms_versioning.test_utils.polls', + 'djangocms_versioning.test_utils.blogpost', + 'djangocms_versioning.test_utils.people', + 'djangocms_versioning.test_utils.text', ], + # As adviced, we can disable migrations in tests. This will improve + # test performance and removes the need for test apps to provide migrations + 'MIGRATION_MODULES': { + 'auth': None, + 'cms': None, + 'menus': None, + 'djangocms_versioning': None, + 'filer': None, + 'djangocms_moderation': None, + 'aldryn_forms': None, + }, } diff --git a/tests/test_admin.py b/tests/test_admin.py index 65d6ea1b..42ae0c35 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -1,7 +1,7 @@ from django.contrib import admin from django.urls import reverse -from cms.api import create_page +from djangocms_versioning.test_utils.factories import PageVersionFactory from djangocms_moderation import conf, constants from djangocms_moderation.admin import ( @@ -29,11 +29,11 @@ def setUp(self): author=self.user, name='Collection Admin Actions', workflow=self.wf, status=constants.IN_REVIEW ) - pg1 = create_page(title='Page 1', template='page.html', language='en',) - pg2 = create_page(title='Page 2', template='page.html', language='en',) + pg1_version = PageVersionFactory() + pg2_version = PageVersionFactory() self.mr1 = ModerationRequest.objects.create( - content_object=pg1, language='en', collection=self.collection, is_active=True,) + version=pg1_version, language='en', collection=self.collection, is_active=True,) self.wfst = self.wf.steps.create(role=self.role2, is_required=True, order=1,) @@ -48,7 +48,7 @@ def setUp(self): # this moderation request is not approved self.mr2 = ModerationRequest.objects.create( - content_object=pg2, language='en', collection=self.collection, is_active=True,) + version=pg2_version, language='en', collection=self.collection, is_active=True,) self.mr2.actions.create(by_user=self.user, action=constants.ACTION_STARTED,) self.url = reverse('admin:djangocms_moderation_moderationrequest_changelist') diff --git a/tests/test_admin_actions.py b/tests/test_admin_actions.py index 125cf6c6..e682c9bb 100644 --- a/tests/test_admin_actions.py +++ b/tests/test_admin_actions.py @@ -3,7 +3,7 @@ from django.contrib.admin import ACTION_CHECKBOX_NAME from django.urls import reverse -from cms.api import create_page +from djangocms_versioning.test_utils.factories import PageVersionFactory from djangocms_moderation import constants from djangocms_moderation.admin import ModerationRequestAdmin @@ -29,11 +29,11 @@ def setUp(self): status=constants.IN_REVIEW, ) - pg1 = create_page(title='Page 1', template='page.html', language='en',) - pg2 = create_page(title='Page 2', template='page.html', language='en',) + pg1_version = PageVersionFactory() + pg2_version = PageVersionFactory() self.mr1 = ModerationRequest.objects.create( - content_object=pg1, language='en', collection=self.collection, is_active=True,) + version=pg1_version, language='en', collection=self.collection, is_active=True,) self.wfst1 = self.wf.steps.create(role=self.role1, is_required=True, order=1,) self.wfst2 = self.wf.steps.create(role=self.role2, is_required=True, order=1,) @@ -45,7 +45,7 @@ def setUp(self): # this moderation request is not approved self.mr2 = ModerationRequest.objects.create( - content_object=pg2, language='en', collection=self.collection, is_active=True,) + version=pg2_version, language='en', collection=self.collection, is_active=True,) self.mr2.actions.create(by_user=self.user, action=constants.ACTION_STARTED,) self.url = reverse('admin:djangocms_moderation_moderationrequest_changelist') @@ -103,9 +103,8 @@ def test_delete_selected(self, notify_author_mock, notify_moderators_mock, mock_ self.collection.refresh_from_db() self.assertEqual(self.collection.status, constants.ARCHIVED) - @mock.patch('djangocms_moderation.admin_actions.publish_content_object') - def test_publish_selected(self, mock_publish_content_object): - + @mock.patch('djangocms_moderation.admin_actions.publish_version') + def test_publish_selected(self, mock_publish_version): fixtures = [self.mr1, self.mr2] data = { 'action': 'publish_selected', @@ -113,9 +112,9 @@ def test_publish_selected(self, mock_publish_content_object): } self.client.post(self.url_with_filter, data, follow=True) - assert mock_publish_content_object.called + assert mock_publish_version.called # check it has been called only once, i.e. with the approved mr1 - mock_publish_content_object.assert_called_once_with(self.mr1.content_object) + mock_publish_version.assert_called_once_with(self.mr1.version) @mock.patch('djangocms_moderation.admin_actions.notify_collection_moderators') @mock.patch('djangocms_moderation.admin_actions.notify_collection_author') @@ -237,9 +236,9 @@ def test_approve_selected_sends_correct_emails(self, notify_moderators_mock): self.user.groups.add(self.group) # Add one more, partially approved request - pg3 = create_page(title='Page 3', template='page.html', language='en', ) + pg3_version = PageVersionFactory() self.mr3 = ModerationRequest.objects.create( - content_object=pg3, language='en', collection=self.collection, is_active=True,) + version=pg3_version, language='en', collection=self.collection, is_active=True,) self.mr3.actions.create(by_user=self.user, action=constants.ACTION_STARTED,) self.mr3.update_status(by_user=self.user, action=constants.ACTION_APPROVED,) self.mr3.update_status(by_user=self.user2, action=constants.ACTION_APPROVED,) diff --git a/tests/test_cms_toolbars.py b/tests/test_cms_toolbars.py index b40f9b7b..9f1b1414 100644 --- a/tests/test_cms_toolbars.py +++ b/tests/test_cms_toolbars.py @@ -1,3 +1,5 @@ +from unittest import skip + from django.contrib.auth.models import AnonymousUser from django.test.client import RequestFactory @@ -11,8 +13,8 @@ from .utils.base import BaseTestCase +@skip("To be fixed in the upcoming ticket") class TestCMSToolbars(BaseTestCase): - def get_page_request(self, page, user, path=None, edit=False, preview=False, structure=False, lang_code='en', disable=False): if not path: @@ -47,7 +49,7 @@ def get_page_request(self, page, user, path=None, edit=False, def test_submit_for_moderation(self): ModerationRequest.objects.all().delete() - request = self.get_page_request(self.pg1, AnonymousUser(), '/') + request = self.get_page_request(self.pg1_version, AnonymousUser(), '/') toolbar = CMSToolbar(request) toolbar = ModerationToolbar(request, toolbar=toolbar, is_current_app=True, app_path='/') toolbar.populate() @@ -59,7 +61,7 @@ def test_submit_for_moderation(self): ) def test_page_in_moderation(self): - request = self.get_page_request(self.pg1, AnonymousUser(), '/') + request = self.get_page_request(self.pg1_version, AnonymousUser(), '/') toolbar = CMSToolbar(request) toolbar = ModerationToolbar(request, toolbar=toolbar, is_current_app=True, app_path='/') toolbar.populate() diff --git a/tests/test_forms.py b/tests/test_forms.py index e7243c14..44687b0e 100644 --- a/tests/test_forms.py +++ b/tests/test_forms.py @@ -19,7 +19,7 @@ def test_form_init_approved_action(self): form = UpdateModerationRequestForm( action=constants.ACTION_APPROVED, language='en', - page=self.pg1, + page=self.pg1_version, user=self.user, workflow=self.wf1, active_request=self.moderation_request1, @@ -37,7 +37,7 @@ def test_form_init_cancelled_action(self): form = UpdateModerationRequestForm( action=constants.ACTION_CANCELLED, language='en', - page=self.pg1, + page=self.pg1_version, user=self.user, workflow=self.wf1, active_request=self.moderation_request1, @@ -50,7 +50,7 @@ def test_form_init_rejected_action(self): form = UpdateModerationRequestForm( action=constants.ACTION_REJECTED, language='en', - page=self.pg1, + page=self.pg1_version, user=self.user, workflow=self.wf1, active_request=self.moderation_request1, @@ -68,7 +68,7 @@ def test_form_save(self): data, action=constants.ACTION_APPROVED, language='en', - page=self.pg1, + page=self.pg1_version, user=self.user, workflow=self.wf1, active_request=self.moderation_request1, diff --git a/tests/test_handlers.py b/tests/test_handlers.py index cb240cb4..ae4e1d47 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -1,3 +1,5 @@ +from unittest import skip + from djangocms_moderation.contrib.moderation_forms.cms_plugins import ( ModerationFormPlugin, ) @@ -12,6 +14,7 @@ from .utils.base import BaseTestCase +@skip("Confirmation page feature doesn't to support 1.0.x yet.") class ModerationConfirmationFormSubmissionTest(BaseTestCase): def setUp(self): @@ -25,7 +28,7 @@ def test_throws_exception_when_form_data_is_invalid(self): with self.assertRaises(ValueError) as context: moderation_confirmation_form_submission( sender=ModerationFormPlugin, - page=self.pg1, + page=self.pg1_version, language='en', user=self.user, form_data=[{'label': 'Question 1', 'answer': 'Yes'}] @@ -35,7 +38,7 @@ def test_throws_exception_when_form_data_is_invalid(self): def test_creates_new_form_submission_when_form_data_is_valid(self): moderation_confirmation_form_submission( sender=ModerationFormPlugin, - page=self.pg1, + page=self.pg1_version, language='en', user=self.user, form_data=[{'label': 'Question 1', 'value': 'Yes'}], diff --git a/tests/test_helpers.py b/tests/test_helpers.py index f7e7210e..09525805 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -1,7 +1,7 @@ import json +from unittest import skip from djangocms_moderation.helpers import ( - get_active_moderation_request, get_form_submission_for_step, get_page_or_404, ) @@ -13,21 +13,10 @@ from .utils.base import BaseTestCase -class GetCurrentModerationRequestTest(BaseTestCase): - - def test_existing_moderation_request(self): - active_request = get_active_moderation_request(self.pg1, 'en') - self.assertEqual(active_request, self.moderation_request1) - - def test_no_moderation_request(self): - active_request = get_active_moderation_request(self.pg2, 'en') - self.assertIsNone(active_request) - - +@skip("Confirmation page feature doesn't to support 1.0.x yet.") class GetPageOr404Test(BaseTestCase): - def test_returns_page(self): - self.assertEqual(get_page_or_404(self.pg1.pk, 'en'), self.pg1) + self.assertEqual(get_page_or_404(self.pg1_version.pk, 'en'), self.pg1_version) class GetFormSubmissions(BaseTestCase): diff --git a/tests/test_models.py b/tests/test_models.py index 8eecdecc..0b38713b 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -2,12 +2,9 @@ from mock import patch from django.contrib.auth.models import User -from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.core.urlresolvers import reverse -from cms.api import create_page - from djangocms_moderation import constants from djangocms_moderation.models import ( ConfirmationFormSubmission, @@ -334,7 +331,7 @@ def test_compliance_number(self, mock_uuid): mock_uuid.return_value = 'abc123' request = ModerationRequest.objects.create( - content_object=self.pg4, + version=self.pg4_version, language='en', is_active=True, collection=self.collection1, @@ -348,7 +345,7 @@ def test_compliance_number(self, mock_uuid): def test_compliance_number_sequential_number_backend(self): self.wf2.compliance_number_backend = 'djangocms_moderation.backends.sequential_number_backend' request = ModerationRequest.objects.create( - content_object=self.pg1, + version=self.pg1_version, language='en', collection=self.collection2, ) @@ -367,7 +364,7 @@ def test_compliance_number_sequential_number_with_identifier_prefix_backend(self self.wf2.identifier = 'SSO' request = ModerationRequest.objects.create( - content_object=self.pg1, + version=self.pg1_version, language='en', collection=self.collection2, ) @@ -400,7 +397,7 @@ def test_save_when_to_user_passed(self): def test_save_when_to_user_not_passed_and_action_started(self): new_request = ModerationRequest.objects.create( - content_object=self.pg2, + version=self.pg2_version, language='en', collection=self.collection1, is_active=True, @@ -481,9 +478,6 @@ def setUp(self): author=self.user, name='My collection 2', workflow=self.wf1 ) - self.page1 = create_page(title='My page 1', template='page.html', language='en',) - self.page2 = create_page(title='My page 2', template='page.html', language='en',) - def test_job_id(self): self.assertEqual(str(self.collection1.pk), self.collection1.job_id) self.assertEqual(str(self.collection2.pk), self.collection2.job_id) @@ -503,7 +497,7 @@ def test_should_be_archived(self, is_approved_mock): self.assertTrue(self.collection1.should_be_archived()) ModerationRequest.objects.create( - content_object=self.pg1, collection=self.collection1, is_active=True + version=self.pg1_version, collection=self.collection1, is_active=True ) is_approved_mock.return_value = False self.assertFalse(self.collection1.should_be_archived()) @@ -518,7 +512,7 @@ def test_allow_submit_for_review(self): self.assertFalse(self.collection1.allow_submit_for_review(user=self.user)) ModerationRequest.objects.create( - content_object=self.pg1, collection=self.collection1, is_active=True + version=self.pg1_version, collection=self.collection1, is_active=True ) self.assertTrue(self.collection1.allow_submit_for_review(user=self.user)) # Only collection author can submit @@ -531,10 +525,10 @@ def test_allow_submit_for_review(self): @patch('djangocms_moderation.models.notify_collection_moderators') def test_submit_for_review(self, mock_ncm): ModerationRequest.objects.create( - content_object=self.pg1, language='en', collection=self.collection1 + version=self.pg1_version, language='en', collection=self.collection1 ) ModerationRequest.objects.create( - content_object=self.pg3, language='en', collection=self.collection1 + version=self.pg3_version, language='en', collection=self.collection1 ) self.assertFalse( @@ -558,17 +552,3 @@ def test_submit_for_review(self, mock_ncm): request__collection=self.collection1, action=constants.ACTION_STARTED ).count() ) - - def _moderation_requests_count(self, obj, collection=None): - """ - How many moderation requests are there [for a given collection] - :return: - """ - content_type = ContentType.objects.get_for_model(obj) - queryset = ModerationRequest.objects.filter( - content_type=content_type, - object_id=obj.pk, - ) - if collection: - queryset = queryset.filter(collection=collection) - return queryset.count() diff --git a/tests/test_moderation_flows.py b/tests/test_moderation_flows.py index ee5bf221..8df99f8d 100644 --- a/tests/test_moderation_flows.py +++ b/tests/test_moderation_flows.py @@ -69,7 +69,7 @@ def _resubmit_moderation_request(self, user, message='Test message - resubmit'): def _cancel_moderation_request(self, user, message='Test message - cancel'): return self._process_moderation_request(user, 'cancel', message) - @skip('4.0 rework TBC') + @skip('1.0.x rework TBC') def test_approve_moderation_workflow(self): """ This case tests the following workflow: @@ -129,7 +129,7 @@ def test_approve_moderation_workflow(self): self.assertTrue(last_action.action, constants.ACTION_FINISHED) self.assertEqual(moderation_request.compliance_number, compliance_number) - @skip('4.0 rework TBC') + @skip('1.0.x rework TBC') def test_reject_moderation_workflow(self): """ This case tests the following workflow: diff --git a/tests/test_views.py b/tests/test_views.py index af099a19..25e3c9a6 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -7,6 +7,8 @@ from cms.utils.urlutils import add_url_parameters +from djangocms_versioning.test_utils.factories import PageVersionFactory + from djangocms_moderation import constants from djangocms_moderation.forms import CollectionItemForm from djangocms_moderation.models import ModerationCollection, ModerationRequest @@ -30,7 +32,8 @@ def setUp(self): author=self.user, name='My collection 2', workflow=self.wf1 ) - self.content_type = ContentType.objects.get_for_model(self.pg1) + self.content_type = ContentType.objects.get_for_model(self.pg1_version) + self.pg_version = PageVersionFactory() def _assert_render(self, response): form = response.context_data['form'] @@ -50,25 +53,22 @@ def test_add_object_to_collections(self): language='en', args=() ), {'collection': self.collection_1.pk, - 'content_type': self.content_type.pk, - 'content_object_id': self.pg1.pk + 'version': self.pg_version.pk, } ) self.assertEqual(response.status_code, 200) # self.assertContains(response, 'reloadBrowser') - content_type = ContentType.objects.get_for_model(self.pg1) moderation_request = ModerationRequest.objects.filter( - content_type=content_type, - object_id=self.pg1.pk, + version=self.pg_version, )[0] self.assertEqual(moderation_request.collection, self.collection_1) - def test_invalid_content_already_in_collection(self): + def test_invalid_version_already_in_collection(self): # add object - self.collection_1.add_object(self.pg1) + self.collection_1.add_version(self.pg_version) self.client.force_login(self.user) @@ -78,8 +78,7 @@ def test_invalid_content_already_in_collection(self): language='en', args=() ), {'collection': self.collection_1.pk, - 'content_type': self.content_type.pk, - 'content_object_id': self.pg1.pk}) + 'version': self.pg_version.pk}) self.assertEqual(response.status_code, 200) self.assertIn( @@ -87,23 +86,18 @@ def test_invalid_content_already_in_collection(self): response.context_data['form'].errors['__all__'][0] ) - def test_non_existing_content_object(self): + def test_non_existing_version(self): self.client.force_login(self.user) - content_type = ContentType.objects.get_for_model(self.pg1) response = self.client.post( get_admin_url( name='cms_moderation_item_to_collection', language='en', args=() ), {'collection': self.collection_1.pk, - 'content_type': content_type.pk, - 'content_object_id': 9000}) + 'version': 9000}) self.assertEqual(response.status_code, 200) - self.assertIn( - 'Invalid content_object_id, does not exist', - response.context_data['form'].errors['__all__'][0] - ) + self.assertIn('version', response.context_data['form'].errors) def test_prevent_locked_collections(self): """ @@ -118,7 +112,7 @@ def test_prevent_locked_collections(self): name='cms_moderation_item_to_collection', language='en', args=() - ), {'collection': self.collection_1.pk, 'content_object_id': self.pg1.pk}) + ), {'collection': self.collection_1.pk, 'version': self.pg1_version.pk}) # locked collection are not part of the list self.assertEqual( @@ -126,11 +120,12 @@ def test_prevent_locked_collections(self): response.context_data['form'].errors['collection'][0] ) - def test_list_content_objects_from_collection_id_param(self): + def test_list_versions_from_collection_id_param(self): ModerationRequest.objects.all().delete() + pg2_version = PageVersionFactory() - self.collection_1.add_object(self.pg1) - self.collection_2.add_object(self.pg2) + self.collection_1.add_version(self.pg_version) + self.collection_2.add_version(pg2_version) self.client.force_login(self.user) response = self.client.get( @@ -148,7 +143,7 @@ def test_list_content_objects_from_collection_id_param(self): for mod_request in moderation_requests: self.assertTrue(mod_request in response.context_data['content_object_list']) - def test_content_object_id_from_params(self): + def test_version_id_from_params(self): self.client.force_login(self.user) response = self.client.get( add_url_parameters( @@ -156,12 +151,12 @@ def test_content_object_id_from_params(self): name='cms_moderation_item_to_collection', language='en', args=() - ), content_object_id=self.pg1.pk + ), version_id=self.pg1_version.pk ) ) form = response.context_data['form'] - self.assertEqual(self.pg1.pk, int(form.initial['content_object_id'])) + self.assertEqual(self.pg1_version.pk, int(form.initial['version'])) def test_authenticated_users_only(self): response = self.client.get( diff --git a/tests/utils/base.py b/tests/utils/base.py index 170ba3bd..72a01de0 100644 --- a/tests/utils/base.py +++ b/tests/utils/base.py @@ -1,7 +1,7 @@ from django.contrib.auth.models import Group, User from django.test import TestCase -from cms.api import create_page +from djangocms_versioning.test_utils.factories import PageVersionFactory from djangocms_moderation import constants from djangocms_moderation.models import ( @@ -22,13 +22,13 @@ def setUpTestData(cls): cls.wf3 = Workflow.objects.create(pk=3, name='Workflow 3',) cls.wf4 = Workflow.objects.create(pk=4, name='Workflow 4',) - # create pages - cls.pg1 = create_page(title='Page 1', template='page.html', language='en',) - cls.pg2 = create_page(title='Page 2', template='page.html', language='en',) - cls.pg3 = create_page(title='Page 3', template='page.html', language='en',) - cls.pg4 = create_page(title='Page 4', template='page.html', language='en',) - cls.pg5 = create_page(title='Page 5', template='page.html', language='en',) - cls.pg6 = create_page(title='Page 6', template='page.html', language='en',) + # create page versions + cls.pg1_version = PageVersionFactory() + cls.pg2_version = PageVersionFactory() + cls.pg3_version = PageVersionFactory() + cls.pg4_version = PageVersionFactory() + cls.pg5_version = PageVersionFactory() + cls.pg6_version = PageVersionFactory() # create users, groups and roles cls.user = User.objects.create_superuser( @@ -74,16 +74,16 @@ def setUpTestData(cls): ) cls.moderation_request1 = ModerationRequest.objects.create( - content_object=cls.pg1, language='en', collection=cls.collection1, is_active=True,) + version=cls.pg1_version, language='en', collection=cls.collection1, is_active=True,) cls.moderation_request1.actions.create(by_user=cls.user, action=constants.ACTION_STARTED,) ModerationRequest.objects.create( - content_object=cls.pg3, language='en', collection=cls.collection1, is_active=False,) + version=cls.pg3_version, language='en', collection=cls.collection1, is_active=False,) ModerationRequest.objects.create( - content_object=cls.pg2, language='en', collection=cls.collection2, is_active=False,) + version=cls.pg2_version, language='en', collection=cls.collection2, is_active=False,) cls.moderation_request2 = ModerationRequest.objects.create( - content_object=cls.pg3, language='en', collection=cls.collection2, is_active=True,) + version=cls.pg3_version, language='en', collection=cls.collection2, is_active=True,) cls.moderation_request2.actions.create( by_user=cls.user, action=constants.ACTION_STARTED,) cls.moderation_request2.actions.create( @@ -92,7 +92,7 @@ def setUpTestData(cls): by_user=cls.user, action=constants.ACTION_APPROVED, step_approved=cls.wf2st2,) cls.moderation_request3 = ModerationRequest.objects.create( - content_object=cls.pg4, language='en', collection=cls.collection3, is_active=True,) + version=cls.pg4_version, language='en', collection=cls.collection3, is_active=True,) cls.moderation_request3.actions.create(by_user=cls.user, action=constants.ACTION_STARTED,) cls.moderation_request3.actions.create( by_user=cls.user, @@ -102,12 +102,12 @@ def setUpTestData(cls): ) # This request will be rejected cls.moderation_request4 = ModerationRequest.objects.create( - content_object=cls.pg5, language='en', collection=cls.collection3, is_active=True,) + version=cls.pg5_version, language='en', collection=cls.collection3, is_active=True,) cls.moderation_request4.actions.create(by_user=cls.user, action=constants.ACTION_STARTED,) cls.moderation_request4.actions.create(by_user=cls.user2, action=constants.ACTION_REJECTED) cls.moderation_request5 = ModerationRequest.objects.create( - content_object=cls.pg6, language='en', collection=cls.collection4, is_active=True,) + version=cls.pg6_version, language='en', collection=cls.collection4, is_active=True,) cls.moderation_request5.actions.create(by_user=cls.user, action=constants.ACTION_STARTED,) From 3b16a3d5fba93c57b57882d9447d1404d15bf987 Mon Sep 17 00:00:00 2001 From: Matus Moravcik Date: Tue, 11 Sep 2018 16:16:19 +0100 Subject: [PATCH 024/147] Preview link (#56) --- djangocms_moderation/admin.py | 20 ++++++++++++++++--- .../moderation_request_change_list.html | 12 ++++++++++- 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/djangocms_moderation/admin.py b/djangocms_moderation/admin.py index dc6c7bd6..2d905e2a 100644 --- a/djangocms_moderation/admin.py +++ b/djangocms_moderation/admin.py @@ -8,6 +8,7 @@ from django.utils.translation import ugettext, ugettext_lazy as _ from cms.admin.placeholderadmin import PlaceholderAdminMixin +from cms.toolbar.utils import get_object_preview_url from adminsortable2.admin import SortableInlineAdminMixin @@ -102,18 +103,31 @@ def has_module_permission(self, request): return False def get_list_display(self, request): - list_display = ['id', 'version', 'get_title', 'get_content_author', 'get_preview_link', 'get_status'] + list_display = [ + 'id', + 'get_content_type', + 'get_title', + 'get_content_author', + 'get_preview_link', + 'get_status', + ] if conf.REQUEST_COMMENTS_ENABLED: list_display.append('get_comments_link') return list_display + def get_content_type(self, obj): + return obj.version.content_type + get_content_type.short_description = _('Content type') + def get_title(self, obj): return obj.version.content get_title.short_description = _('Title') def get_preview_link(self, obj): - # TODO this will return Version object preview link once implemented - return "Link placeholder" + return format_html( + '', + get_object_preview_url(obj.version.content), + ) get_preview_link.short_description = _('Preview') def get_comments_link(self, obj): diff --git a/djangocms_moderation/templates/djangocms_moderation/moderation_request_change_list.html b/djangocms_moderation/templates/djangocms_moderation/moderation_request_change_list.html index 97a7d72b..251e7bdb 100644 --- a/djangocms_moderation/templates/djangocms_moderation/moderation_request_change_list.html +++ b/djangocms_moderation/templates/djangocms_moderation/moderation_request_change_list.html @@ -1,5 +1,15 @@ {% extends "admin/change_list.html" %} -{% load i18n %} +{% load i18n cms_static %} + +{% block extrahead %} + {{ block.super }} + {# + INFO: we need to add styles here instead of "extrastyle" to avoid + conflicts with adminstyle. We are adding cms.base.css to gain the + icon support, e.g. `` + #} + +{% endblock extrahead %} {% block content_title %} {% if collection %} From 4e9f0d543c9fd2587e62fe81ceab212e32fd3cb0 Mon Sep 17 00:00:00 2001 From: Matus Moravcik Date: Tue, 11 Sep 2018 17:22:56 +0100 Subject: [PATCH 025/147] Call publish on moderation finished (#55) --- djangocms_moderation/admin.py | 11 ++++--- djangocms_moderation/admin_actions.py | 25 +++++++++++----- djangocms_moderation/cms_toolbars.py | 2 +- .../migrations/0004_auto_20180907_1206.py | 26 +++++++++++++++++ djangocms_moderation/models.py | 5 +++- tests/test_admin.py | 15 ++++++++++ tests/test_admin_actions.py | 29 +++++++++++++++---- tests/test_models.py | 6 ++++ tests/utils/base.py | 3 +- 9 files changed, 101 insertions(+), 21 deletions(-) create mode 100644 djangocms_moderation/migrations/0004_auto_20180907_1206.py diff --git a/djangocms_moderation/admin.py b/djangocms_moderation/admin.py index 2d905e2a..c25015be 100644 --- a/djangocms_moderation/admin.py +++ b/djangocms_moderation/admin.py @@ -177,11 +177,11 @@ def get_actions(self, request): # `publish_selected` is possible. _max_to_keep = 1 # publish_selected - for mr in collection.moderation_requests.all(): + for mr in collection.moderation_requests.all().select_related('version'): if len(actions_to_keep) == _max_to_keep: break # We have found all the actions, so no need to loop anymore if 'publish_selected' not in actions_to_keep: - if mr.is_approved() and request.user == collection.author: + if request.user == collection.author and mr.version_can_be_published(): actions_to_keep.append('publish_selected') if collection.status == IN_REVIEW and 'approve_selected' not in actions_to_keep: if mr.user_can_take_moderation_action(request.user): @@ -230,17 +230,16 @@ def get_status(self, obj): last_action = obj.get_last_action() if last_action: - if obj.is_approved(): + if obj.version_can_be_published(): status = ugettext('Ready for publishing') - # TODO: consider published status for version e.g.: - # elif obj.content_object.is_published(): - # status = ugettext('Published') elif obj.is_rejected(): status = ugettext('Pending author rework') elif obj.is_active and obj.has_pending_step(): next_step = obj.get_next_required() role = next_step.role.name status = ugettext('Pending %(role)s approval') % {'role': role} + elif not obj.version.can_be_published(): + status = obj.version.get_state_display() else: user_name = last_action.get_by_user_name() message_data = { diff --git a/djangocms_moderation/admin_actions.py b/djangocms_moderation/admin_actions.py index 9488ade0..3c900249 100644 --- a/djangocms_moderation/admin_actions.py +++ b/djangocms_moderation/admin_actions.py @@ -2,6 +2,8 @@ from django.core.exceptions import PermissionDenied from django.utils.translation import ugettext_lazy as _, ungettext +from django_fsm import TransitionNotAllowed + from djangocms_moderation import constants from djangocms_moderation.emails import ( notify_collection_author, @@ -192,12 +194,18 @@ def publish_selected(modeladmin, request, queryset): raise PermissionDenied num_published_requests = 0 - for moderation_request in queryset.all(): - if moderation_request.is_approved(): - num_published_requests += 1 - publish_version(moderation_request.version) + for mr in queryset.all(): + if mr.version_can_be_published(): + if publish_version(mr.version, request.user): + num_published_requests += 1 + mr.update_status( + action=constants.ACTION_FINISHED, + by_user=request.user, + ) + else: + # TODO provide some feedback back to the user? + pass - # notify the UI of the action results messages.success( request, ungettext( @@ -219,6 +227,9 @@ def post_bulk_actions(collection): collection.save(update_fields=['status']) -def publish_version(version): - # TODO: e.g.moderation_request.version.publish(request.user) +def publish_version(version, user): + try: + version.publish(user) + except TransitionNotAllowed: + return False return True diff --git a/djangocms_moderation/cms_toolbars.py b/djangocms_moderation/cms_toolbars.py index 134eb133..19556a2e 100644 --- a/djangocms_moderation/cms_toolbars.py +++ b/djangocms_moderation/cms_toolbars.py @@ -38,7 +38,7 @@ def post_template_populate(self): disabled=True, side=self.toolbar.RIGHT, ) - except Version.DoesNotExist: + except (ModerationRequest.DoesNotExist, Version.DoesNotExist): url = add_url_parameters( get_admin_url( name='cms_moderation_item_to_collection', diff --git a/djangocms_moderation/migrations/0004_auto_20180907_1206.py b/djangocms_moderation/migrations/0004_auto_20180907_1206.py new file mode 100644 index 00000000..afef5b6c --- /dev/null +++ b/djangocms_moderation/migrations/0004_auto_20180907_1206.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.13 on 2018-09-07 11:06 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('djangocms_moderation', '0003_auto_20180903_1206'), + ] + + operations = [ + migrations.AlterField( + model_name='moderationrequest', + name='is_active', + field=models.BooleanField(db_index=True, default=False, verbose_name='is active'), + ), + migrations.AlterField( + model_name='moderationrequest', + name='version', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='djangocms_versioning.Version', verbose_name='version'), + ), + ] diff --git a/djangocms_moderation/models.py b/djangocms_moderation/models.py index c6f23615..66f89bbc 100644 --- a/djangocms_moderation/models.py +++ b/djangocms_moderation/models.py @@ -383,7 +383,7 @@ class Meta: def __str__(self): return "{} {}".format( self.pk, - self.content_object.pk + self.version.pk ) @cached_property @@ -406,6 +406,9 @@ def has_required_pending_steps(self): def is_approved(self): return self.is_active and not self.has_required_pending_steps() + def version_can_be_published(self): + return self.is_approved() and self.version.can_be_published() + def is_rejected(self): return self.get_last_action().action == constants.ACTION_REJECTED diff --git a/tests/test_admin.py b/tests/test_admin.py index 42ae0c35..fbc2bd1b 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -71,6 +71,21 @@ def test_delete_selected_action_visibility(self): actions = self.mra.get_actions(request=mock_request) self.assertNotIn('delete_selected', actions) + def test_publish_selected_action_visibility_when_version_is_published(self): + mock_request = MockRequest() + mock_request.user = self.user + mock_request._collection = self.collection + + actions = self.mra.get_actions(request=mock_request) + # mr1 request is approved so user can see the publish_selected action + self.assertIn('publish_selected', actions) + + # Now, when version becomes published, they shouldn't see it + self.mr1.version._set_publish(self.user) + self.mr1.version.save() + actions = self.mra.get_actions(request=mock_request) + self.assertNotIn('publish_selected', actions) + def test_publish_selected_action_visibility(self): mock_request = MockRequest() mock_request.user = self.user diff --git a/tests/test_admin_actions.py b/tests/test_admin_actions.py index e682c9bb..b015d0ed 100644 --- a/tests/test_admin_actions.py +++ b/tests/test_admin_actions.py @@ -3,6 +3,8 @@ from django.contrib.admin import ACTION_CHECKBOX_NAME from django.urls import reverse +from djangocms_versioning.constants import DRAFT, PUBLISHED +from djangocms_versioning.models import Version from djangocms_versioning.test_utils.factories import PageVersionFactory from djangocms_moderation import constants @@ -103,18 +105,35 @@ def test_delete_selected(self, notify_author_mock, notify_moderators_mock, mock_ self.collection.refresh_from_db() self.assertEqual(self.collection.status, constants.ARCHIVED) - @mock.patch('djangocms_moderation.admin_actions.publish_version') - def test_publish_selected(self, mock_publish_version): + def test_publish_selected(self): fixtures = [self.mr1, self.mr2] + + # Pre-checks + version1 = self.mr1.version + version2 = self.mr2.version + self.assertTrue(self.mr1.is_active) + self.assertTrue(self.mr2.is_active) + self.assertEqual(version1.state, DRAFT) + self.assertEqual(version2.state, DRAFT) + data = { 'action': 'publish_selected', ACTION_CHECKBOX_NAME: [str(f.pk) for f in fixtures] } self.client.post(self.url_with_filter, data, follow=True) - assert mock_publish_version.called - # check it has been called only once, i.e. with the approved mr1 - mock_publish_version.assert_called_once_with(self.mr1.version) + # After-checks + # We can't do refresh_from_db() for Version, as it complains about + # `state` field being changed directly + version1 = Version.objects.get(pk=version1.pk) + version2 = Version.objects.get(pk=version2.pk) + self.mr1.refresh_from_db() + self.mr2.refresh_from_db() + + self.assertEqual(version1.state, PUBLISHED) + self.assertEqual(version2.state, DRAFT) + self.assertFalse(self.mr1.is_active) + self.assertTrue(self.mr2.is_active) @mock.patch('djangocms_moderation.admin_actions.notify_collection_moderators') @mock.patch('djangocms_moderation.admin_actions.notify_collection_author') diff --git a/tests/test_models.py b/tests/test_models.py index 0b38713b..ad449323 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -94,6 +94,12 @@ def test_is_approved(self): self.assertTrue(self.moderation_request2.is_approved()) self.assertTrue(self.moderation_request3.is_approved()) + def test_version_can_be_published(self): + self.assertFalse(self.moderation_request1.version_can_be_published()) + self.assertTrue(self.moderation_request2.version_can_be_published()) + # moderation_request3.version is already in published state + self.assertFalse(self.moderation_request3.version_can_be_published()) + def test_is_rejected(self): self.assertFalse(self.moderation_request1.is_rejected()) self.assertFalse(self.moderation_request2.is_rejected()) diff --git a/tests/utils/base.py b/tests/utils/base.py index 72a01de0..bc75e95e 100644 --- a/tests/utils/base.py +++ b/tests/utils/base.py @@ -1,6 +1,7 @@ from django.contrib.auth.models import Group, User from django.test import TestCase +from djangocms_versioning.constants import PUBLISHED from djangocms_versioning.test_utils.factories import PageVersionFactory from djangocms_moderation import constants @@ -26,7 +27,7 @@ def setUpTestData(cls): cls.pg1_version = PageVersionFactory() cls.pg2_version = PageVersionFactory() cls.pg3_version = PageVersionFactory() - cls.pg4_version = PageVersionFactory() + cls.pg4_version = PageVersionFactory(state=PUBLISHED) cls.pg5_version = PageVersionFactory() cls.pg6_version = PageVersionFactory() From c211074dbdef51cfbe865f060441ac507a9c9911 Mon Sep 17 00:00:00 2001 From: Damilare Onajole Date: Thu, 13 Sep 2018 12:04:00 +0100 Subject: [PATCH 026/147] Fix/add item to collection (#57) --- djangocms_moderation/cms_toolbars.py | 79 ++++++++++--------- .../item_to_collection.html | 14 ++-- djangocms_moderation/views.py | 6 +- tests/test_cms_toolbars.py | 51 +++++++++--- tests/test_views.py | 4 +- 5 files changed, 94 insertions(+), 60 deletions(-) diff --git a/djangocms_moderation/cms_toolbars.py b/djangocms_moderation/cms_toolbars.py index 19556a2e..38817553 100644 --- a/djangocms_moderation/cms_toolbars.py +++ b/djangocms_moderation/cms_toolbars.py @@ -1,58 +1,63 @@ # -*- coding: utf-8 -*- from django.utils.translation import ugettext_lazy as _ -from cms.toolbar_base import CMSToolbar from cms.toolbar_pool import toolbar_pool from cms.utils.urlutils import add_url_parameters +from djangocms_versioning.cms_toolbars import VersioningToolbar from djangocms_versioning.models import Version from .models import ModerationRequest from .utils import get_admin_url -class ModerationToolbar(CMSToolbar): +class ModerationToolbar(VersioningToolbar): class Media: - js = ('djangocms_moderation/js/dist/bundle.moderation.min.js',) + js = ( + 'djangocms_moderation/js/dist/bundle.moderation.min.js', + 'djangocms_versioning/js/actions.js', + ) css = { 'all': ('djangocms_moderation/css/moderation.css',) } + def _add_publish_button(self): + """ + Disable djangocms_versioning publish button + """ + pass + def post_template_populate(self): super().post_template_populate() - # TODO replace page object with generic content object - page = self.request.current_page - - if not page: - return None - - try: - # TODO Make this work with the correct version - version = Version.objects.get(pk=9999) - moderation_request = ModerationRequest.objects.get( - version=version - ) - self.toolbar.add_modal_button( - name=_('In Moderation "%s"' % moderation_request.collection.name), - url='#', - disabled=True, - side=self.toolbar.RIGHT, - ) - except (ModerationRequest.DoesNotExist, Version.DoesNotExist): - url = add_url_parameters( - get_admin_url( - name='cms_moderation_item_to_collection', - language=self.current_lang, - args=() - ), - content_object_id=page.pk - ) - - self.toolbar.add_modal_button( - name=_('Submit for moderation'), - url=url, - side=self.toolbar.RIGHT, - ) - + if self._is_versioned(): + version = Version.objects.get_for_content(self.toolbar.obj) + try: + moderation_request = ModerationRequest.objects.get( + version=version + ) + self.toolbar.add_modal_button( + name="%s %s" % (_('In Moderation'), moderation_request.collection.name), + url='#', + disabled=True, + side=self.toolbar.RIGHT, + ) + except ModerationRequest.DoesNotExist: + url = add_url_parameters( + get_admin_url( + name='cms_moderation_item_to_collection', + language=self.current_lang, + args=() + ), + version_id=version.pk + ) + + self.toolbar.add_modal_button( + name=_('Submit for moderation'), + url=url, + side=self.toolbar.RIGHT, + ) + + +toolbar_pool.unregister(VersioningToolbar) toolbar_pool.register(ModerationToolbar) diff --git a/djangocms_moderation/templates/djangocms_moderation/item_to_collection.html b/djangocms_moderation/templates/djangocms_moderation/item_to_collection.html index 1979b133..65e9ae34 100644 --- a/djangocms_moderation/templates/djangocms_moderation/item_to_collection.html +++ b/djangocms_moderation/templates/djangocms_moderation/item_to_collection.html @@ -21,7 +21,8 @@

      {% trans "Add to existing collection" %}

      {% trans "Content Type" %}{% trans "Identifier" %}{% trans "Version" %} {% trans "Author" %} {% trans "Edit Date" %} {% trans "Version" %}
      {{ content_object.content_type }}{{ content_object.content_object }}{{ version }} Stub Author [versioning] Stub Date [versioning] Stub VNumber [versioning]
      - + + @@ -29,13 +30,14 @@

      {% trans "Add to existing collection" %}

      - {% for content_object in content_object_list %} + {% for mrl in moderation_request_list %} - - - + + + + - + {% endfor %} diff --git a/djangocms_moderation/views.py b/djangocms_moderation/views.py index 40319abc..53186af5 100644 --- a/djangocms_moderation/views.py +++ b/djangocms_moderation/views.py @@ -58,13 +58,13 @@ def get_context_data(self, **kwargs): if collection_id: collection = ModerationCollection.objects.get(pk=collection_id) - content_object_list = collection.moderation_requests.all() + moderation_request_list = collection.moderation_requests.all() else: - content_object_list = [] + moderation_request_list = [] model_admin = admin.site._registry[ModerationCollection] context.update({ - 'content_object_list': content_object_list, + 'moderation_request_list': moderation_request_list, 'opts': opts_meta, 'title': _('Add to collection'), 'form': self.get_form(), diff --git a/tests/test_cms_toolbars.py b/tests/test_cms_toolbars.py index 9f1b1414..3dd3382e 100644 --- a/tests/test_cms_toolbars.py +++ b/tests/test_cms_toolbars.py @@ -1,20 +1,22 @@ -from unittest import skip - -from django.contrib.auth.models import AnonymousUser from django.test.client import RequestFactory from cms.middleware.toolbar import ToolbarMiddleware from cms.toolbar.toolbar import CMSToolbar from cms.utils.conf import get_cms_setting +from djangocms_versioning.test_utils.factories import ( + PageVersionFactory, + UserFactory, +) + from djangocms_moderation.cms_toolbars import ModerationToolbar from djangocms_moderation.models import ModerationRequest from .utils.base import BaseTestCase -@skip("To be fixed in the upcoming ticket") class TestCMSToolbars(BaseTestCase): + def get_page_request(self, page, user, path=None, edit=False, preview=False, structure=False, lang_code='en', disable=False): if not path: @@ -46,12 +48,34 @@ def get_page_request(self, page, user, path=None, edit=False, request.toolbar.populate() return request + def _get_toolbar(self, content_obj, **kwargs): + """Helper method to set up the toolbar + """ + request = RequestFactory().get('/') + request.user = UserFactory() + request.session = {} + cms_toolbar = CMSToolbar(request) + toolbar = ModerationToolbar( + request, toolbar=cms_toolbar, is_current_app=True, app_path='/') + toolbar.toolbar.obj = content_obj + if kwargs.get('edit_mode', False): + toolbar.toolbar.edit_mode_active = True + toolbar.toolbar.content_mode_active = False + toolbar.toolbar.structure_mode_active = False + elif kwargs.get('preview_mode', False): + toolbar.toolbar.edit_mode_active = False + toolbar.toolbar.content_mode_active = True + toolbar.toolbar.structure_mode_active = False + elif kwargs.get('structure_mode', False): + toolbar.toolbar.edit_mode_active = False + toolbar.toolbar.content_mode_active = False + toolbar.toolbar.structure_mode_active = True + return toolbar + def test_submit_for_moderation(self): ModerationRequest.objects.all().delete() - - request = self.get_page_request(self.pg1_version, AnonymousUser(), '/') - toolbar = CMSToolbar(request) - toolbar = ModerationToolbar(request, toolbar=toolbar, is_current_app=True, app_path='/') + version = PageVersionFactory() + toolbar = self._get_toolbar(version.content, edit_mode=True) toolbar.populate() toolbar.post_template_populate() @@ -61,13 +85,16 @@ def test_submit_for_moderation(self): ) def test_page_in_moderation(self): - request = self.get_page_request(self.pg1_version, AnonymousUser(), '/') - toolbar = CMSToolbar(request) - toolbar = ModerationToolbar(request, toolbar=toolbar, is_current_app=True, app_path='/') + ModerationRequest.objects.all().delete() + version = PageVersionFactory() + self.collection1.add_version( + version=version + ) + toolbar = self._get_toolbar(version.content, edit_mode=True) toolbar.populate() toolbar.post_template_populate() self.assertEquals( toolbar.toolbar.get_right_items()[0].buttons[0].name, - 'In Moderation "%s"' % self.collection1.name + 'In Moderation %s' % self.collection1.name ) diff --git a/tests/test_views.py b/tests/test_views.py index 25e3c9a6..59f4e07c 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -44,7 +44,7 @@ def _assert_render(self, response): self.assertEqual(response.context_data['title'], _('Add to collection')) - def test_add_object_to_collections(self): + def test_version_object_to_collections(self): ModerationRequest.objects.all().delete() self.client.force_login(self.user) response = self.client.post( @@ -141,7 +141,7 @@ def test_list_versions_from_collection_id_param(self): moderation_requests = ModerationRequest.objects.filter(collection=self.collection_2) # moderation request is content_object for mod_request in moderation_requests: - self.assertTrue(mod_request in response.context_data['content_object_list']) + self.assertTrue(mod_request in response.context_data['moderation_request_list']) def test_version_id_from_params(self): self.client.force_login(self.user) From 2af3b9319482a9e948200dc6b98291d92e084e6e Mon Sep 17 00:00:00 2001 From: Rizwan Date: Fri, 14 Sep 2018 12:09:32 +0100 Subject: [PATCH 027/147] Added filter for moderation collection admin UI (#58) --- djangocms_moderation/admin.py | 11 ++++++----- djangocms_moderation/models.py | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/djangocms_moderation/admin.py b/djangocms_moderation/admin.py index c25015be..91014981 100644 --- a/djangocms_moderation/admin.py +++ b/djangocms_moderation/admin.py @@ -392,12 +392,17 @@ class ModerationCollectionAdmin(EditAndAddOnlyFieldsMixin, admin.ModelAdmin): actions = None # remove `delete_selected` for now, it will be handled later editonly_fields = ('status',) # fields editable only on EDIT addonly_fields = ('workflow',) # fields editable only on CREATE + list_filter = [ + 'author', + 'status', + 'date_created', + ] def get_list_display(self, request): list_display = [ 'id', 'name', - 'get_moderator', + 'author', 'workflow', 'status', 'date_created', @@ -429,10 +434,6 @@ def get_comments_link(self, obj): ) get_comments_link.short_description = _('Comments') - def get_moderator(self, obj): - return obj.author - get_moderator.short_description = _('Moderator') - def get_urls(self): def _url(regex, fn, name, **kwargs): return url(regex, self.admin_site.admin_view(fn), kwargs=kwargs, name=name) diff --git a/djangocms_moderation/models.py b/djangocms_moderation/models.py index 66f89bbc..4055b12a 100644 --- a/djangocms_moderation/models.py +++ b/djangocms_moderation/models.py @@ -252,7 +252,7 @@ class ModerationCollection(models.Model): name = models.CharField(verbose_name=_('name'), max_length=128) author = models.ForeignKey( to=settings.AUTH_USER_MODEL, - verbose_name=_('author'), + verbose_name=_('moderator'), related_name='+', on_delete=models.CASCADE, ) From fa32f92ceacd37d6a18f0f54dbd98e6f7c512edd Mon Sep 17 00:00:00 2001 From: Matus Moravcik Date: Fri, 14 Sep 2018 12:26:33 +0100 Subject: [PATCH 028/147] Add version # to the list (#59) --- .../templates/djangocms_moderation/item_to_collection.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/djangocms_moderation/templates/djangocms_moderation/item_to_collection.html b/djangocms_moderation/templates/djangocms_moderation/item_to_collection.html index 65e9ae34..c50efeda 100644 --- a/djangocms_moderation/templates/djangocms_moderation/item_to_collection.html +++ b/djangocms_moderation/templates/djangocms_moderation/item_to_collection.html @@ -25,7 +25,7 @@

      {% trans "Add to existing collection" %}

      - + @@ -36,7 +36,7 @@

      {% trans "Add to existing collection" %}

      - + {% endfor %} From a527a4b6e595d791e32f0b7dca70d01a78934a09 Mon Sep 17 00:00:00 2001 From: Matus Moravcik Date: Fri, 14 Sep 2018 14:50:30 +0100 Subject: [PATCH 029/147] Review lock - FE (#60) --- djangocms_moderation/cms_toolbars.py | 38 +++++++++++--- djangocms_moderation/models.py | 6 ++- djangocms_moderation/utils.py | 27 ++++++++++ tests/test_cms_toolbars.py | 78 ++++++++++++++-------------- tests/test_handlers.py | 2 +- tests/test_helpers.py | 2 +- tests/test_utils.py | 34 ++++++++++++ 7 files changed, 138 insertions(+), 49 deletions(-) diff --git a/djangocms_moderation/cms_toolbars.py b/djangocms_moderation/cms_toolbars.py index 38817553..6512b865 100644 --- a/djangocms_moderation/cms_toolbars.py +++ b/djangocms_moderation/cms_toolbars.py @@ -8,14 +8,16 @@ from djangocms_versioning.models import Version from .models import ModerationRequest -from .utils import get_admin_url +from .utils import get_admin_url, is_obj_review_locked class ModerationToolbar(VersioningToolbar): class Media: + # Media keeps all the settings from the parent class, so we only need + # to add moderation media here + # https://docs.djangoproject.com/en/2.1/topics/forms/media/#extend js = ( 'djangocms_moderation/js/dist/bundle.moderation.min.js', - 'djangocms_versioning/js/actions.js', ) css = { 'all': ('djangocms_moderation/css/moderation.css',) @@ -23,21 +25,39 @@ class Media: def _add_publish_button(self): """ - Disable djangocms_versioning publish button + Disable djangocms_versioning publish button as it needs to go through + the moderation first """ pass - def post_template_populate(self): - super().post_template_populate() + def _add_edit_button(self): + """ + We need to check if the object is not 'Review locked', and only allow + Edit button if it isn't + """ + if is_obj_review_locked(self.toolbar.obj, self.request.user): + # Don't display edit button as the item is Review locked + # TODO alternatively we could add the edit button using super + # and mark it as disabled, instead of adding another -disabled one + self.toolbar.add_modal_button( + _('Edit'), + url='#', + disabled=True, + side=self.toolbar.RIGHT, + ) + return super()._add_edit_button() - if self._is_versioned(): + def _add_moderation_buttons(self): + if self._is_versioned() and self.toolbar.edit_mode_active: version = Version.objects.get_for_content(self.toolbar.obj) try: moderation_request = ModerationRequest.objects.get( version=version ) self.toolbar.add_modal_button( - name="%s %s" % (_('In Moderation'), moderation_request.collection.name), + name=_('In Moderation "%(collection_name)s"') % { + 'collection_name': moderation_request.collection.name + }, url='#', disabled=True, side=self.toolbar.RIGHT, @@ -58,6 +78,10 @@ def post_template_populate(self): side=self.toolbar.RIGHT, ) + def post_template_populate(self): + super().post_template_populate() + self._add_moderation_buttons() + toolbar_pool.unregister(VersioningToolbar) toolbar_pool.register(ModerationToolbar) diff --git a/djangocms_moderation/models.py b/djangocms_moderation/models.py index 4055b12a..c0ed4875 100644 --- a/djangocms_moderation/models.py +++ b/djangocms_moderation/models.py @@ -391,7 +391,8 @@ def author(self): """ Author of this request is the user who created the first action """ - return self.get_first_action().by_user + first_action = self.get_first_action() + return first_action.by_user if first_action else None @cached_property def workflow(self): @@ -410,7 +411,8 @@ def version_can_be_published(self): return self.is_approved() and self.version.can_be_published() def is_rejected(self): - return self.get_last_action().action == constants.ACTION_REJECTED + last_action = self.get_last_action() + return last_action and last_action.action == constants.ACTION_REJECTED @transaction.atomic def update_status(self, action, by_user, message='', to_user=None): diff --git a/djangocms_moderation/utils.py b/djangocms_moderation/utils.py index b9bd11d4..26d0d3e6 100644 --- a/djangocms_moderation/utils.py +++ b/djangocms_moderation/utils.py @@ -9,6 +9,8 @@ from cms.utils.urlutils import admin_reverse +from djangocms_versioning.models import Version + def get_absolute_url(location, site=None): if not site: @@ -44,3 +46,28 @@ def extract_filter_param_from_changelist_url(request, keyname, parametername): changelist_filters = request.GET.get(keyname) parameter_value = parse_qs(changelist_filters).get(parametername) return parameter_value[0] + + +def is_obj_review_locked(obj, user): + """ + Util function which determines if the `obj` is Review locked. + It is the equivalent of "Can `user` edit the version of object `obj`"? + """ + version = Version.objects.get_for_content(obj) + + try: + from djangocms_moderation.models import ModerationRequest # noqa + moderation_request = ModerationRequest.objects.get( + version=version + ) + except ModerationRequest.DoesNotExist: + # If there is no moderation request with this version yet, then + # the `obj` is not Review locked + return False + + # If `user` can resubmit the moderation request, it means they can edit + # the version to submit the changes. Review lock should be lifted for them + if moderation_request.user_can_resubmit(user): + return False + + return True diff --git a/tests/test_cms_toolbars.py b/tests/test_cms_toolbars.py index 3dd3382e..a236e22e 100644 --- a/tests/test_cms_toolbars.py +++ b/tests/test_cms_toolbars.py @@ -2,7 +2,6 @@ from cms.middleware.toolbar import ToolbarMiddleware from cms.toolbar.toolbar import CMSToolbar -from cms.utils.conf import get_cms_setting from djangocms_versioning.test_utils.factories import ( PageVersionFactory, @@ -16,31 +15,10 @@ class TestCMSToolbars(BaseTestCase): - - def get_page_request(self, page, user, path=None, edit=False, - preview=False, structure=False, lang_code='en', disable=False): - if not path: - path = page.get_absolute_url() - - if edit: - path += '?%s' % get_cms_setting('CMS_TOOLBAR_URL__EDIT_ON') - - if structure: - path += '?%s' % get_cms_setting('CMS_TOOLBAR_URL__BUILD') - - if preview: - path += '?preview' - - request = RequestFactory().get(path) + def _get_page_request(self, page, user): + request = RequestFactory().get('/') request.session = {} request.user = user - request.LANGUAGE_CODE = lang_code - if edit: - request.GET = {'edit': None} - else: - request.GET = {'edit_off': None} - if disable: - request.GET[get_cms_setting('CMS_TOOLBAR_URL__DISABLE')] = None request.current_page = page mid = ToolbarMiddleware() mid.process_request(request) @@ -48,28 +26,21 @@ def get_page_request(self, page, user, path=None, edit=False, request.toolbar.populate() return request - def _get_toolbar(self, content_obj, **kwargs): + def _get_toolbar(self, content_obj, edit_mode=False): """Helper method to set up the toolbar """ - request = RequestFactory().get('/') - request.user = UserFactory() - request.session = {} + page = PageVersionFactory().content.page + request = self._get_page_request( + page=page, user=UserFactory(is_staff=True) + ) cms_toolbar = CMSToolbar(request) toolbar = ModerationToolbar( request, toolbar=cms_toolbar, is_current_app=True, app_path='/') toolbar.toolbar.obj = content_obj - if kwargs.get('edit_mode', False): + if edit_mode: toolbar.toolbar.edit_mode_active = True toolbar.toolbar.content_mode_active = False toolbar.toolbar.structure_mode_active = False - elif kwargs.get('preview_mode', False): - toolbar.toolbar.edit_mode_active = False - toolbar.toolbar.content_mode_active = True - toolbar.toolbar.structure_mode_active = False - elif kwargs.get('structure_mode', False): - toolbar.toolbar.edit_mode_active = False - toolbar.toolbar.content_mode_active = False - toolbar.toolbar.structure_mode_active = True return toolbar def test_submit_for_moderation(self): @@ -93,8 +64,39 @@ def test_page_in_moderation(self): toolbar = self._get_toolbar(version.content, edit_mode=True) toolbar.populate() toolbar.post_template_populate() + self.assertEquals( + toolbar.toolbar.get_right_items()[1].buttons[0].name, + 'In Moderation "%s"' % self.collection1.name + ) + + def test_add_edit_button(self): + ModerationRequest.objects.all().delete() + version = PageVersionFactory() + toolbar = self._get_toolbar(version.content) + toolbar.populate() + toolbar.post_template_populate() + # We can see the Edit button, as the version hasn't been submitted + # to the moderation (collection) yet + self.assertEquals( + toolbar.toolbar.get_right_items()[0].buttons[0].name, + 'Edit', + ) + self.assertFalse( + toolbar.toolbar.get_right_items()[0].buttons[0].disabled + ) + # Lets add the version to moderation, the Edit should no longer be + # clickable + self.collection1.add_version( + version=version + ) + toolbar = self._get_toolbar(version.content) + toolbar.populate() + toolbar.post_template_populate() self.assertEquals( toolbar.toolbar.get_right_items()[0].buttons[0].name, - 'In Moderation %s' % self.collection1.name + 'Edit', + ) + self.assertTrue( + toolbar.toolbar.get_right_items()[0].buttons[0].disabled ) diff --git a/tests/test_handlers.py b/tests/test_handlers.py index ae4e1d47..2f6d0e68 100644 --- a/tests/test_handlers.py +++ b/tests/test_handlers.py @@ -14,7 +14,7 @@ from .utils.base import BaseTestCase -@skip("Confirmation page feature doesn't to support 1.0.x yet.") +@skip("Confirmation page feature doesn't support 1.0.x yet") class ModerationConfirmationFormSubmissionTest(BaseTestCase): def setUp(self): diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 09525805..54db4d0d 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -13,7 +13,7 @@ from .utils.base import BaseTestCase -@skip("Confirmation page feature doesn't to support 1.0.x yet.") +@skip("Confirmation page feature doesn't support 1.0.x yet") class GetPageOr404Test(BaseTestCase): def test_returns_page(self): self.assertEqual(get_page_or_404(self.pg1_version.pk, 'en'), self.pg1_version) diff --git a/tests/test_utils.py b/tests/test_utils.py index c1e0919f..b92312be 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -1,6 +1,11 @@ from django.test.client import RequestFactory +from djangocms_versioning.test_utils.factories import PageVersionFactory + from djangocms_moderation import utils +from djangocms_moderation.constants import ACTION_REJECTED, ACTION_STARTED +from djangocms_moderation.models import ModerationCollection, ModerationRequest +from djangocms_moderation.utils import is_obj_review_locked from .utils.base import BaseTestCase @@ -41,3 +46,32 @@ def test_extract_filter_param_from_changelist_url(self): mock_request, '_changelist_filters', 'moderation_request__id__exact' ) self.assertEquals(action_id, '2') + + +class TestReviewLock(BaseTestCase): + def test_is_obj_review_locked(self): + page_version = PageVersionFactory() + + page_content = page_version.content + self.assertFalse(is_obj_review_locked(page_content, self.user)) + self.assertFalse(is_obj_review_locked(page_content, self.user2)) + self.assertFalse(is_obj_review_locked(page_content, self.user3)) + + collection = ModerationCollection.objects.create( + author=self.user, name='My collection 1', workflow=self.wf1 + ) + collection.add_version(page_version) + # Now the version is part of the collection so it is review locked + self.assertTrue(is_obj_review_locked(page_content, self.user)) + self.assertTrue(is_obj_review_locked(page_content, self.user2)) + self.assertTrue(is_obj_review_locked(page_content, self.user3)) + + mr = ModerationRequest.objects.get(collection=collection) + mr.actions.create(by_user=self.user, action=ACTION_STARTED,) + + # Now we reject the moderation request, which means that `user` can + # resubmit the changes, the review lock is lifted for them + mr.actions.create(by_user=self.user2, action=ACTION_REJECTED) + self.assertFalse(is_obj_review_locked(page_content, self.user)) + self.assertTrue(is_obj_review_locked(page_content, self.user2)) + self.assertTrue(is_obj_review_locked(page_content, self.user3)) From c26a5f87fe2a7d21a4a4e177db45f4c1512808c5 Mon Sep 17 00:00:00 2001 From: Mateusz Kamycki Date: Mon, 17 Sep 2018 13:43:20 +0200 Subject: [PATCH 030/147] Fix `add_edit_button` in toolbar (#62) --- djangocms_moderation/cms_toolbars.py | 2 +- tests/test_cms_toolbars.py | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/djangocms_moderation/cms_toolbars.py b/djangocms_moderation/cms_toolbars.py index 6512b865..d3178a6b 100644 --- a/djangocms_moderation/cms_toolbars.py +++ b/djangocms_moderation/cms_toolbars.py @@ -35,7 +35,7 @@ def _add_edit_button(self): We need to check if the object is not 'Review locked', and only allow Edit button if it isn't """ - if is_obj_review_locked(self.toolbar.obj, self.request.user): + if self.toolbar.obj and is_obj_review_locked(self.toolbar.obj, self.request.user): # Don't display edit button as the item is Review locked # TODO alternatively we could add the edit button using super # and mark it as disabled, instead of adding another -disabled one diff --git a/tests/test_cms_toolbars.py b/tests/test_cms_toolbars.py index a236e22e..c6c892b6 100644 --- a/tests/test_cms_toolbars.py +++ b/tests/test_cms_toolbars.py @@ -100,3 +100,12 @@ def test_add_edit_button(self): self.assertTrue( toolbar.toolbar.get_right_items()[0].buttons[0].disabled ) + + def test_add_edit_button_without_toolbar_object(self): + ModerationRequest.objects.all().delete() + toolbar = self._get_toolbar(None) + toolbar.populate() + toolbar.post_template_populate() + # We shouldnt see Edit button when there is no toolbar object set. + # Some of the custom views in some apps dont have toolbar.obj + self.assertEquals(toolbar.toolbar.get_right_items(), []) From 71f9c6975ece310cf14dcecd0f49e14cfa8e7dbd Mon Sep 17 00:00:00 2001 From: Rizwan Date: Mon, 17 Sep 2018 15:08:40 +0100 Subject: [PATCH 031/147] Added logic so user should not be able to change other user's comments (#61) --- djangocms_moderation/admin.py | 38 ++++++++++++++++++- djangocms_moderation/templatetags/__init__.py | 0 .../templatetags/admin_modify.py | 23 +++++++++++ 3 files changed, 59 insertions(+), 2 deletions(-) create mode 100644 djangocms_moderation/templatetags/__init__.py create mode 100644 djangocms_moderation/templatetags/admin_modify.py diff --git a/djangocms_moderation/admin.py b/djangocms_moderation/admin.py index 91014981..ff70c951 100644 --- a/djangocms_moderation/admin.py +++ b/djangocms_moderation/admin.py @@ -4,6 +4,7 @@ from django.contrib import admin from django.core.urlresolvers import reverse from django.http import Http404 +from django.shortcuts import get_object_or_404 from django.utils.html import format_html, format_html_join from django.utils.translation import ugettext, ugettext_lazy as _ @@ -48,7 +49,6 @@ class ModerationRequestActionInline(admin.TabularInline): model = ModerationRequestAction fields = ['show_user', 'message', 'date_taken', 'form_submission'] - readonly_fields = ['show_user', 'date_taken', 'form_submission'] verbose_name = _('Action') verbose_name_plural = _('Actions') @@ -81,6 +81,11 @@ def form_submission(self, obj): ) form_submission.short_description = _('Form Submission') + def get_readonly_fields(self, request, obj=None): + if obj and request.user == obj.author: + return ['show_user', 'date_taken', 'form_submission'] + return self.fields + class ModerationRequestAdmin(admin.ModelAdmin): actions = [ # filtered out in `self.get_actions` @@ -304,6 +309,21 @@ def changelist_view(self, request, extra_context=None): return super().changelist_view(request, extra_context) + def change_view(self, request, object_id, form_url='', extra_context=None): + extra_context = extra_context or {} + collection_comment = get_object_or_404(CollectionComment, pk=int(object_id)) + if request.user != collection_comment.author: + extra_context['readonly'] = True + return super().change_view(request, object_id, + form_url, extra_context=extra_context) + + def has_delete_permission(self, request, obj=None): + return request.user == getattr(obj, 'author', None) + + def get_readonly_fields(self, request, obj=None): + if obj and request.user != obj.author: + return self.list_display + class RequestCommentAdmin(admin.ModelAdmin): list_display = ['message', 'get_request_link', 'author', 'date_created'] @@ -362,9 +382,23 @@ def changelist_view(self, request, extra_context=None): # If no collection id, then don't show all requests # as each collection's actions, buttons and privileges may differ raise Http404 - return super().changelist_view(request, extra_context) + def change_view(self, request, object_id, form_url='', extra_context=None): + extra_context = extra_context or {} + request_comment = get_object_or_404(RequestComment, pk=int(object_id)) + if request.user != request_comment.author: + extra_context['readonly'] = True + return super().change_view(request, object_id, + form_url, extra_context=extra_context) + + def has_delete_permission(self, request, obj=None): + return request.user == getattr(obj, 'author', None) + + def get_readonly_fields(self, request, obj=None): + if obj and request.user != obj.author: + return self.list_display + class WorkflowStepInline(SortableInlineAdminMixin, admin.TabularInline): formset = WorkflowStepInlineFormSet diff --git a/djangocms_moderation/templatetags/__init__.py b/djangocms_moderation/templatetags/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/djangocms_moderation/templatetags/admin_modify.py b/djangocms_moderation/templatetags/admin_modify.py new file mode 100644 index 00000000..9971977d --- /dev/null +++ b/djangocms_moderation/templatetags/admin_modify.py @@ -0,0 +1,23 @@ +from django import template +from django.contrib.admin.templatetags.admin_modify import ( + prepopulated_fields_js as original_prepopulated_fields_js, + submit_row as original_submit_row, +) + + +register = template.Library() + + +@register.inclusion_tag('admin/prepopulated_fields_js.html', takes_context=True) +def prepopulated_fields_js(context): + return original_prepopulated_fields_js(context) + + +@register.inclusion_tag('admin/submit_line.html', takes_context=True) +def submit_row(context): + """ + Hide the row of buttons for delete and save if readonly otherwise display. + """ + if context.get('readonly'): + return dict() + return original_submit_row(context) From f982a9d03d8b3bed4f8508f71ea242edd2a50904 Mon Sep 17 00:00:00 2001 From: Matus Moravcik Date: Tue, 18 Sep 2018 17:16:42 +0100 Subject: [PATCH 032/147] Don't add normal edit button if in moderation (#66) --- djangocms_moderation/cms_toolbars.py | 3 ++- tests/test_cms_toolbars.py | 1 + 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/djangocms_moderation/cms_toolbars.py b/djangocms_moderation/cms_toolbars.py index d3178a6b..9664ce50 100644 --- a/djangocms_moderation/cms_toolbars.py +++ b/djangocms_moderation/cms_toolbars.py @@ -45,7 +45,8 @@ def _add_edit_button(self): disabled=True, side=self.toolbar.RIGHT, ) - return super()._add_edit_button() + else: + return super()._add_edit_button() def _add_moderation_buttons(self): if self._is_versioned() and self.toolbar.edit_mode_active: diff --git a/tests/test_cms_toolbars.py b/tests/test_cms_toolbars.py index c6c892b6..b77711cf 100644 --- a/tests/test_cms_toolbars.py +++ b/tests/test_cms_toolbars.py @@ -93,6 +93,7 @@ def test_add_edit_button(self): toolbar = self._get_toolbar(version.content) toolbar.populate() toolbar.post_template_populate() + self.assertEqual(1, len(toolbar.toolbar.get_right_items())) self.assertEquals( toolbar.toolbar.get_right_items()[0].buttons[0].name, 'Edit', From ca5479dfd4f5ea909ed85c5d79c650aacbfcee2c Mon Sep 17 00:00:00 2001 From: Noel da Costa Date: Wed, 19 Sep 2018 12:22:14 +0100 Subject: [PATCH 033/147] Feature/fix comments breadcrumb trails (#64) --- .gitignore | 2 + djangocms_moderation/admin.py | 56 ++++++++++++++----- .../collectioncomment/change_form.html | 17 ++++++ .../requestcomment/change_form.html | 18 ++++++ .../templatetags/admin_modify.py | 23 -------- .../templatetags/comments_extras.py | 25 +++++++++ 6 files changed, 103 insertions(+), 38 deletions(-) create mode 100644 djangocms_moderation/templates/admin/djangocms_moderation/collectioncomment/change_form.html create mode 100644 djangocms_moderation/templates/admin/djangocms_moderation/requestcomment/change_form.html delete mode 100644 djangocms_moderation/templatetags/admin_modify.py create mode 100644 djangocms_moderation/templatetags/comments_extras.py diff --git a/.gitignore b/.gitignore index 1274a3dc..c3b1fe07 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,5 @@ build/ *.sqlite .coverage .python-version +node_modules/ +yarn.lock diff --git a/djangocms_moderation/admin.py b/djangocms_moderation/admin.py index ff70c951..5f0308a9 100644 --- a/djangocms_moderation/admin.py +++ b/djangocms_moderation/admin.py @@ -306,16 +306,28 @@ def changelist_view(self, request, extra_context=None): ) else: raise Http404 - return super().changelist_view(request, extra_context) - def change_view(self, request, object_id, form_url='', extra_context=None): - extra_context = extra_context or {} - collection_comment = get_object_or_404(CollectionComment, pk=int(object_id)) - if request.user != collection_comment.author: - extra_context['readonly'] = True - return super().change_view(request, object_id, - form_url, extra_context=extra_context) + def changeform_view(self, request, object_id=None, form_url='', extra_context=None): + # get the collection for the breadcrumb trail + collection_id = utils.extract_filter_param_from_changelist_url( + request, '_changelist_filters', 'collection__id__exact' + ) + extra_context = extra_context or dict( + show_save_and_add_another=False, + show_save_and_continue=False, + ) + if object_id: + try: + collection_comment = get_object_or_404(CollectionComment, pk=int(object_id)) + except ValueError: + raise Http404 + if request.user != collection_comment.author: + extra_context['readonly'] = True + + if collection_id: + extra_context['collection_id'] = collection_id + return super().changeform_view(request, object_id, form_url, extra_context) def has_delete_permission(self, request, obj=None): return request.user == getattr(obj, 'author', None) @@ -384,13 +396,27 @@ def changelist_view(self, request, extra_context=None): raise Http404 return super().changelist_view(request, extra_context) - def change_view(self, request, object_id, form_url='', extra_context=None): - extra_context = extra_context or {} - request_comment = get_object_or_404(RequestComment, pk=int(object_id)) - if request.user != request_comment.author: - extra_context['readonly'] = True - return super().change_view(request, object_id, - form_url, extra_context=extra_context) + def changeform_view(self, request, object_id=None, form_url='', extra_context=None): + extra_context = extra_context or dict( + show_save_and_add_another=False, + show_save_and_continue=False, + ) + if object_id: + try: + request_comment = get_object_or_404(RequestComment, pk=int(object_id)) + except ValueError: + raise Http404 + if request.user != request_comment.author: + extra_context['readonly'] = True + + # for breadcrumb trail + moderation_request_id = utils.extract_filter_param_from_changelist_url( + request, '_changelist_filters', 'moderation_request__id__exact' + ) + if moderation_request_id: + extra_context['moderation_request_id'] = moderation_request_id + + return super().changeform_view(request, object_id, form_url, extra_context) def has_delete_permission(self, request, obj=None): return request.user == getattr(obj, 'author', None) diff --git a/djangocms_moderation/templates/admin/djangocms_moderation/collectioncomment/change_form.html b/djangocms_moderation/templates/admin/djangocms_moderation/collectioncomment/change_form.html new file mode 100644 index 00000000..6882c08e --- /dev/null +++ b/djangocms_moderation/templates/admin/djangocms_moderation/collectioncomment/change_form.html @@ -0,0 +1,17 @@ +{% extends "admin/change_form.html" %} +{% load i18n admin_urls static admin_modify comments_extras l10n %} + + +{% block breadcrumbs %} + +{% endblock %} + + +{% block submit_buttons_bottom %}{% comments_submit_row %}{% endblock %} + diff --git a/djangocms_moderation/templates/admin/djangocms_moderation/requestcomment/change_form.html b/djangocms_moderation/templates/admin/djangocms_moderation/requestcomment/change_form.html new file mode 100644 index 00000000..c6f48582 --- /dev/null +++ b/djangocms_moderation/templates/admin/djangocms_moderation/requestcomment/change_form.html @@ -0,0 +1,18 @@ +{% extends "admin/change_form.html" %} +{% load i18n admin_urls static admin_modify comments_extras l10n %} + + +{% block breadcrumbs %} + +{% endblock %} + + +{% block submit_buttons_bottom %}{% comments_submit_row %}{% endblock %} + diff --git a/djangocms_moderation/templatetags/admin_modify.py b/djangocms_moderation/templatetags/admin_modify.py deleted file mode 100644 index 9971977d..00000000 --- a/djangocms_moderation/templatetags/admin_modify.py +++ /dev/null @@ -1,23 +0,0 @@ -from django import template -from django.contrib.admin.templatetags.admin_modify import ( - prepopulated_fields_js as original_prepopulated_fields_js, - submit_row as original_submit_row, -) - - -register = template.Library() - - -@register.inclusion_tag('admin/prepopulated_fields_js.html', takes_context=True) -def prepopulated_fields_js(context): - return original_prepopulated_fields_js(context) - - -@register.inclusion_tag('admin/submit_line.html', takes_context=True) -def submit_row(context): - """ - Hide the row of buttons for delete and save if readonly otherwise display. - """ - if context.get('readonly'): - return dict() - return original_submit_row(context) diff --git a/djangocms_moderation/templatetags/comments_extras.py b/djangocms_moderation/templatetags/comments_extras.py new file mode 100644 index 00000000..c4cc47f7 --- /dev/null +++ b/djangocms_moderation/templatetags/comments_extras.py @@ -0,0 +1,25 @@ +from django import template +from django.contrib.admin.templatetags.admin_modify import ( + submit_row as original_submit_row, +) + + +register = template.Library() + + +@register.inclusion_tag('admin/submit_line.html', takes_context=True) +def comments_submit_row(context): + """ + Displays the row of buttons for delete and save. + """ + if context.get('readonly'): + return dict() + ctx = original_submit_row(context) + ctx.update({ + 'show_save_and_add_another': ( + context.get('show_save_and_add_another', True) and + context['has_add_permission'] and not context['is_popup'] and + (not context['save_as'] or context['add']) + ), + }) + return ctx From e13838772df0e580c83702ac3d9c45a5fff56385 Mon Sep 17 00:00:00 2001 From: Vadim Sikora Date: Mon, 24 Sep 2018 13:03:58 +0200 Subject: [PATCH 034/147] Bugfix for sideframe (#68) --- .eslintrc.js | 1 + djangocms_moderation/admin.py | 7 +- .../static/djangocms_moderation/js/actions.js | 14 + package-lock.json | 8459 +++++++++++++++++ tests/test_moderation_flows.py | 9 +- 5 files changed, 8485 insertions(+), 5 deletions(-) create mode 100644 djangocms_moderation/static/djangocms_moderation/js/actions.js create mode 100644 package-lock.json diff --git a/.eslintrc.js b/.eslintrc.js index 19dbd3dc..e2014bfb 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -8,6 +8,7 @@ module.exports = { }, "globals": { "CMS": true, + "django": true, "Promise": true }, "root": true, diff --git a/djangocms_moderation/admin.py b/djangocms_moderation/admin.py index 5f0308a9..3746666d 100644 --- a/djangocms_moderation/admin.py +++ b/djangocms_moderation/admin.py @@ -88,6 +88,9 @@ def get_readonly_fields(self, request, obj=None): class ModerationRequestAdmin(admin.ModelAdmin): + class Media: + js = ('djangocms_moderation/js/actions.js',) + actions = [ # filtered out in `self.get_actions` delete_selected, publish_selected, @@ -130,7 +133,9 @@ def get_title(self, obj): def get_preview_link(self, obj): return format_html( - '', + '' + '' + '', get_object_preview_url(obj.version.content), ) get_preview_link.short_description = _('Preview') diff --git a/djangocms_moderation/static/djangocms_moderation/js/actions.js b/djangocms_moderation/static/djangocms_moderation/js/actions.js new file mode 100644 index 00000000..8441cea8 --- /dev/null +++ b/djangocms_moderation/static/djangocms_moderation/js/actions.js @@ -0,0 +1,14 @@ +(function($) { + if (!$) { + return; + } + + $(function() { + $('.js-moderation-close-sideframe').on('click', function () { + try { + window.top.CMS.API.Sideframe.close(); + } catch (e) {} + }); + }); + +})((typeof django !== 'undefined' && django.jQuery) || (typeof CMS !== 'undefined' && CMS.$) || false); diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 00000000..a811c70a --- /dev/null +++ b/package-lock.json @@ -0,0 +1,8459 @@ +{ + "name": "djangoCMS", + "requires": true, + "lockfileVersion": 1, + "dependencies": { + "@babel/helper-module-imports": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.0.0.tgz", + "integrity": "sha512-aP/hlLq01DWNEiDg4Jn23i+CXxW/owM4WpDLFUbpjxe4NS3BhLVZQ5i7E0ZrxuQ/vwekIeciyamgB1UIYxxM6A==", + "dev": true, + "requires": { + "@babel/types": "^7.0.0" + } + }, + "@babel/types": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.0.0.tgz", + "integrity": "sha512-5tPDap4bGKTLPtci2SUl/B7Gv8RnuJFuQoWx26RJobS0fFrz4reUA3JnwIM+HVHEmWE0C1mzKhDtTp8NsWY02Q==", + "dev": true, + "requires": { + "esutils": "^2.0.2", + "lodash": "^4.17.10", + "to-fast-properties": "^2.0.0" + }, + "dependencies": { + "to-fast-properties": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-2.0.0.tgz", + "integrity": "sha1-3F5pjL0HkmW8c+A3doGk5Og/YW4=", + "dev": true + } + } + }, + "abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "dev": true + }, + "acorn": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-5.7.3.tgz", + "integrity": "sha512-T/zvzYRfbVojPWahDsE5evJdHb3oJoQfFbsrKM7w5Zcs++Tr257tia3BmMP8XYVjp1S9RZXQMh7gao96BlqZOw==", + "dev": true + }, + "acorn-dynamic-import": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/acorn-dynamic-import/-/acorn-dynamic-import-2.0.2.tgz", + "integrity": "sha1-x1K9IQvvZ5UBtsbLf8hPj0cVjMQ=", + "dev": true, + "requires": { + "acorn": "^4.0.3" + }, + "dependencies": { + "acorn": { + "version": "4.0.13", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-4.0.13.tgz", + "integrity": "sha1-EFSVrlNh1pe9GVyCUZLhrX8lN4c=", + "dev": true + } + } + }, + "acorn-jsx": { + "version": "3.0.1", + "resolved": "http://registry.npmjs.org/acorn-jsx/-/acorn-jsx-3.0.1.tgz", + "integrity": "sha1-r9+UiPsezvyDSPb7IvRk4ypYs2s=", + "dev": true, + "requires": { + "acorn": "^3.0.4" + }, + "dependencies": { + "acorn": { + "version": "3.3.0", + "resolved": "http://registry.npmjs.org/acorn/-/acorn-3.3.0.tgz", + "integrity": "sha1-ReN/s56No/JbruP/U2niu18iAXo=", + "dev": true + } + } + }, + "ajv": { + "version": "5.5.2", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-5.5.2.tgz", + "integrity": "sha1-c7Xuyj+rZT49P5Qis0GtQiBdyWU=", + "dev": true, + "requires": { + "co": "^4.6.0", + "fast-deep-equal": "^1.0.0", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.3.0" + } + }, + "ajv-keywords": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-2.1.1.tgz", + "integrity": "sha1-YXmX/F9gV2iUxDX5QNgZ4TW4B2I=", + "dev": true + }, + "align-text": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/align-text/-/align-text-0.1.4.tgz", + "integrity": "sha1-DNkKVhCT810KmSVsIrcGlDP60Rc=", + "dev": true, + "requires": { + "kind-of": "^3.0.2", + "longest": "^1.0.1", + "repeat-string": "^1.5.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "amdefine": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/amdefine/-/amdefine-1.0.1.tgz", + "integrity": "sha1-SlKCrBZHKek2Gbz9OtFR+BfOkfU=", + "dev": true + }, + "ansi-colors": { + "version": "1.1.0", + "resolved": "http://registry.npmjs.org/ansi-colors/-/ansi-colors-1.1.0.tgz", + "integrity": "sha512-SFKX67auSNoVR38N3L+nvsPjOE0bybKTYbkf5tRvushrAPQ9V75huw0ZxBkKVeRU9kqH3d6HA4xTckbwZ4ixmA==", + "dev": true, + "requires": { + "ansi-wrap": "^0.1.0" + } + }, + "ansi-cyan": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/ansi-cyan/-/ansi-cyan-0.1.1.tgz", + "integrity": "sha1-U4rlKK+JgvKK4w2G8vF0VtJgmHM=", + "dev": true, + "requires": { + "ansi-wrap": "0.1.0" + } + }, + "ansi-escapes": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-3.1.0.tgz", + "integrity": "sha512-UgAb8H9D41AQnu/PbWlCofQVcnV4Gs2bBJi9eZPxfU/hgglFh3SMDMENRIqdr7H6XFnXdoknctFByVsCOotTVw==", + "dev": true + }, + "ansi-gray": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/ansi-gray/-/ansi-gray-0.1.1.tgz", + "integrity": "sha1-KWLPVOyXksSFEKPetSRDaGHvclE=", + "dev": true, + "requires": { + "ansi-wrap": "0.1.0" + } + }, + "ansi-red": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/ansi-red/-/ansi-red-0.1.1.tgz", + "integrity": "sha1-jGOPnRCAgAo1PJwoyKgcpHBdlGw=", + "dev": true, + "requires": { + "ansi-wrap": "0.1.0" + } + }, + "ansi-regex": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-2.1.1.tgz", + "integrity": "sha1-w7M6te42DYbg5ijwRorn7yfWVN8=", + "dev": true + }, + "ansi-styles": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-2.2.1.tgz", + "integrity": "sha1-tDLdM1i2NM914eRmQ2gkBTPB3b4=", + "dev": true + }, + "ansi-wrap": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/ansi-wrap/-/ansi-wrap-0.1.0.tgz", + "integrity": "sha1-qCJQ3bABXponyoLoLqYDu/pF768=", + "dev": true + }, + "anymatch": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-2.0.0.tgz", + "integrity": "sha512-5teOsQWABXHHBFP9y3skS5P3d/WfWXpv3FUpy+LorMrNYaT9pI4oLMQX7jzQ2KklNpGpWHzdCXTDT2Y3XGlZBw==", + "dev": true, + "requires": { + "micromatch": "^3.1.4", + "normalize-path": "^2.1.1" + } + }, + "aproba": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-1.2.0.tgz", + "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==", + "dev": true + }, + "archy": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/archy/-/archy-1.0.0.tgz", + "integrity": "sha1-+cjBN1fMHde8N5rHeyxipcKGjEA=", + "dev": true + }, + "are-we-there-yet": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-1.1.5.tgz", + "integrity": "sha512-5hYdAkZlcG8tOLujVDTgCT+uPX0VnpAH28gWsLfzpXYm7wP6mp5Q/gYyR7YQ0cKVJcXJnl3j2kpBan13PtQf6w==", + "dev": true, + "requires": { + "delegates": "^1.0.0", + "readable-stream": "^2.0.6" + } + }, + "argparse": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", + "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", + "dev": true, + "requires": { + "sprintf-js": "~1.0.2" + } + }, + "arr-diff": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-4.0.0.tgz", + "integrity": "sha1-1kYQdP6/7HHn4VI1dhoyml3HxSA=", + "dev": true + }, + "arr-flatten": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/arr-flatten/-/arr-flatten-1.1.0.tgz", + "integrity": "sha512-L3hKV5R/p5o81R7O02IGnwpDmkp6E982XhtbuwSe3O4qOtMMMtodicASA1Cny2U+aCXcNpml+m4dPsvsJ3jatg==", + "dev": true + }, + "arr-union": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/arr-union/-/arr-union-3.1.0.tgz", + "integrity": "sha1-45sJrqne+Gao8gbiiK9jkZuuOcQ=", + "dev": true + }, + "array-differ": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/array-differ/-/array-differ-1.0.0.tgz", + "integrity": "sha1-7/UuN1gknTO+QCuLuOVkuytdQDE=", + "dev": true + }, + "array-each": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/array-each/-/array-each-1.0.1.tgz", + "integrity": "sha1-p5SvDAWrF1KEbudTofIRoFugxE8=", + "dev": true + }, + "array-find-index": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-find-index/-/array-find-index-1.0.2.tgz", + "integrity": "sha1-3wEKoSh+Fku9pvlyOwqWoexBh6E=", + "dev": true + }, + "array-slice": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/array-slice/-/array-slice-1.1.0.tgz", + "integrity": "sha512-B1qMD3RBP7O8o0H2KbrXDyB0IccejMF15+87Lvlor12ONPRHP6gTjXMNkt/d3ZuOGbAe66hFmaCfECI24Ufp6w==", + "dev": true + }, + "array-union": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-union/-/array-union-1.0.2.tgz", + "integrity": "sha1-mjRBDk9OPaI96jdb5b5w8kd47Dk=", + "dev": true, + "requires": { + "array-uniq": "^1.0.1" + } + }, + "array-uniq": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/array-uniq/-/array-uniq-1.0.3.tgz", + "integrity": "sha1-r2rId6Jcx/dOBYiUdThY39sk/bY=", + "dev": true + }, + "array-unique": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/array-unique/-/array-unique-0.3.2.tgz", + "integrity": "sha1-qJS3XUvE9s1nnvMkSp/Y9Gri1Cg=", + "dev": true + }, + "arrify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/arrify/-/arrify-1.0.1.tgz", + "integrity": "sha1-iYUI2iIm84DfkEcoRWhJwVAaSw0=", + "dev": true + }, + "asn1": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.4.tgz", + "integrity": "sha512-jxwzQpLQjSmWXgwaCZE9Nz+glAG01yF1QnWgbhGwHI5A6FRIEY6IVqtHhIepHqI7/kyEyQEagBC5mBEFlIYvdg==", + "dev": true, + "requires": { + "safer-buffer": "~2.1.0" + } + }, + "asn1.js": { + "version": "4.10.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-4.10.1.tgz", + "integrity": "sha512-p32cOF5q0Zqs9uBiONKYLm6BClCoBCM5O9JfeUSlnQLBTxYdTK+pW+nXflm8UkKd2UYlEbYz5qEi0JuZR9ckSw==", + "dev": true, + "requires": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0" + } + }, + "assert": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/assert/-/assert-1.4.1.tgz", + "integrity": "sha1-mZEtWRg2tab1s0XA8H7vwI/GXZE=", + "dev": true, + "requires": { + "util": "0.10.3" + }, + "dependencies": { + "inherits": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.1.tgz", + "integrity": "sha1-sX0I0ya0Qj5Wjv9xn5GwscvfafE=", + "dev": true + }, + "util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/util/-/util-0.10.3.tgz", + "integrity": "sha1-evsa/lCAUkZInj23/g7TeTNqwPk=", + "dev": true, + "requires": { + "inherits": "2.0.1" + } + } + } + }, + "assert-plus": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", + "integrity": "sha1-8S4PPF13sLHN2RRpQuTpbB5N1SU=", + "dev": true + }, + "assign-symbols": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/assign-symbols/-/assign-symbols-1.0.0.tgz", + "integrity": "sha1-WWZ/QfrdTyDMvCu5a41Pf3jsA2c=", + "dev": true + }, + "async": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/async/-/async-2.6.1.tgz", + "integrity": "sha512-fNEiL2+AZt6AlAw/29Cr0UDe4sRAHCpEHh54WMz+Bb7QfNcFw4h3loofyJpLeQs4Yx7yuqu/2dLgM5hKOs6HlQ==", + "dev": true, + "requires": { + "lodash": "^4.17.10" + } + }, + "async-each": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/async-each/-/async-each-1.0.1.tgz", + "integrity": "sha1-GdOGodntxufByF04iu28xW0zYC0=", + "dev": true + }, + "async-foreach": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/async-foreach/-/async-foreach-0.1.3.tgz", + "integrity": "sha1-NhIfhFwFeBct5Bmpfb6x0W7DRUI=", + "dev": true + }, + "asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha1-x57Zf380y48robyXkLzDZkdLS3k=", + "dev": true + }, + "atob": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/atob/-/atob-2.1.2.tgz", + "integrity": "sha512-Wm6ukoaOGJi/73p/cl2GvLjTI5JM1k/O14isD73YML8StrH/7/lRFgmg8nICZgD3bZZvjwCGxtMOD3wWNAu8cg==", + "dev": true + }, + "autoprefixer": { + "version": "6.7.7", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-6.7.7.tgz", + "integrity": "sha1-Hb0cg1ZY41zj+ZhAmdsAWFx4IBQ=", + "dev": true, + "requires": { + "browserslist": "^1.7.6", + "caniuse-db": "^1.0.30000634", + "normalize-range": "^0.1.2", + "num2fraction": "^1.2.2", + "postcss": "^5.2.16", + "postcss-value-parser": "^3.2.3" + } + }, + "aws-sign2": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/aws-sign2/-/aws-sign2-0.7.0.tgz", + "integrity": "sha1-tG6JCTSpWR8tL2+G1+ap8bP+dqg=", + "dev": true + }, + "aws4": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.8.0.tgz", + "integrity": "sha512-ReZxvNHIOv88FlT7rxcXIIC0fPt4KZqZbOlivyWtXLt8ESx84zd3kMC6iK5jVeS2qt+g7ftS7ye4fi06X5rtRQ==", + "dev": true + }, + "babel-code-frame": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-code-frame/-/babel-code-frame-6.26.0.tgz", + "integrity": "sha1-Y/1D99weO7fONZR9uP42mj9Yx0s=", + "dev": true, + "requires": { + "chalk": "^1.1.3", + "esutils": "^2.0.2", + "js-tokens": "^3.0.2" + } + }, + "babel-core": { + "version": "6.26.3", + "resolved": "https://registry.npmjs.org/babel-core/-/babel-core-6.26.3.tgz", + "integrity": "sha512-6jyFLuDmeidKmUEb3NM+/yawG0M2bDZ9Z1qbZP59cyHLz8kYGKYwpJP0UwUKKUiTRNvxfLesJnTedqczP7cTDA==", + "dev": true, + "requires": { + "babel-code-frame": "^6.26.0", + "babel-generator": "^6.26.0", + "babel-helpers": "^6.24.1", + "babel-messages": "^6.23.0", + "babel-register": "^6.26.0", + "babel-runtime": "^6.26.0", + "babel-template": "^6.26.0", + "babel-traverse": "^6.26.0", + "babel-types": "^6.26.0", + "babylon": "^6.18.0", + "convert-source-map": "^1.5.1", + "debug": "^2.6.9", + "json5": "^0.5.1", + "lodash": "^4.17.4", + "minimatch": "^3.0.4", + "path-is-absolute": "^1.0.1", + "private": "^0.1.8", + "slash": "^1.0.0", + "source-map": "^0.5.7" + } + }, + "babel-eslint": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/babel-eslint/-/babel-eslint-7.2.3.tgz", + "integrity": "sha1-sv4tgBJkcPXBlELcdXJTqJdxCCc=", + "dev": true, + "requires": { + "babel-code-frame": "^6.22.0", + "babel-traverse": "^6.23.1", + "babel-types": "^6.23.0", + "babylon": "^6.17.0" + } + }, + "babel-generator": { + "version": "6.26.1", + "resolved": "https://registry.npmjs.org/babel-generator/-/babel-generator-6.26.1.tgz", + "integrity": "sha512-HyfwY6ApZj7BYTcJURpM5tznulaBvyio7/0d4zFOeMPUmfxkCjHocCuoLa2SAGzBI8AREcH3eP3758F672DppA==", + "dev": true, + "requires": { + "babel-messages": "^6.23.0", + "babel-runtime": "^6.26.0", + "babel-types": "^6.26.0", + "detect-indent": "^4.0.0", + "jsesc": "^1.3.0", + "lodash": "^4.17.4", + "source-map": "^0.5.7", + "trim-right": "^1.0.1" + } + }, + "babel-helper-builder-binary-assignment-operator-visitor": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-helper-builder-binary-assignment-operator-visitor/-/babel-helper-builder-binary-assignment-operator-visitor-6.24.1.tgz", + "integrity": "sha1-zORReto1b0IgvK6KAsKzRvmlZmQ=", + "dev": true, + "requires": { + "babel-helper-explode-assignable-expression": "^6.24.1", + "babel-runtime": "^6.22.0", + "babel-types": "^6.24.1" + } + }, + "babel-helper-call-delegate": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-helper-call-delegate/-/babel-helper-call-delegate-6.24.1.tgz", + "integrity": "sha1-7Oaqzdx25Bw0YfiL/Fdb0Nqi340=", + "dev": true, + "requires": { + "babel-helper-hoist-variables": "^6.24.1", + "babel-runtime": "^6.22.0", + "babel-traverse": "^6.24.1", + "babel-types": "^6.24.1" + } + }, + "babel-helper-define-map": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-helper-define-map/-/babel-helper-define-map-6.26.0.tgz", + "integrity": "sha1-pfVtq0GiX5fstJjH66ypgZ+Vvl8=", + "dev": true, + "requires": { + "babel-helper-function-name": "^6.24.1", + "babel-runtime": "^6.26.0", + "babel-types": "^6.26.0", + "lodash": "^4.17.4" + } + }, + "babel-helper-explode-assignable-expression": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-helper-explode-assignable-expression/-/babel-helper-explode-assignable-expression-6.24.1.tgz", + "integrity": "sha1-8luCz33BBDPFX3BZLVdGQArCLKo=", + "dev": true, + "requires": { + "babel-runtime": "^6.22.0", + "babel-traverse": "^6.24.1", + "babel-types": "^6.24.1" + } + }, + "babel-helper-function-name": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-helper-function-name/-/babel-helper-function-name-6.24.1.tgz", + "integrity": "sha1-00dbjAPtmCQqJbSDUasYOZ01gKk=", + "dev": true, + "requires": { + "babel-helper-get-function-arity": "^6.24.1", + "babel-runtime": "^6.22.0", + "babel-template": "^6.24.1", + "babel-traverse": "^6.24.1", + "babel-types": "^6.24.1" + } + }, + "babel-helper-get-function-arity": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-helper-get-function-arity/-/babel-helper-get-function-arity-6.24.1.tgz", + "integrity": "sha1-j3eCqpNAfEHTqlCQj4mwMbG2hT0=", + "dev": true, + "requires": { + "babel-runtime": "^6.22.0", + "babel-types": "^6.24.1" + } + }, + "babel-helper-hoist-variables": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-helper-hoist-variables/-/babel-helper-hoist-variables-6.24.1.tgz", + "integrity": "sha1-HssnaJydJVE+rbyZFKc/VAi+enY=", + "dev": true, + "requires": { + "babel-runtime": "^6.22.0", + "babel-types": "^6.24.1" + } + }, + "babel-helper-optimise-call-expression": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-helper-optimise-call-expression/-/babel-helper-optimise-call-expression-6.24.1.tgz", + "integrity": "sha1-96E0J7qfc/j0+pk8VKl4gtEkQlc=", + "dev": true, + "requires": { + "babel-runtime": "^6.22.0", + "babel-types": "^6.24.1" + } + }, + "babel-helper-regex": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-helper-regex/-/babel-helper-regex-6.26.0.tgz", + "integrity": "sha1-MlxZ+QL4LyS3T6zu0DY5VPZJXnI=", + "dev": true, + "requires": { + "babel-runtime": "^6.26.0", + "babel-types": "^6.26.0", + "lodash": "^4.17.4" + } + }, + "babel-helper-remap-async-to-generator": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-helper-remap-async-to-generator/-/babel-helper-remap-async-to-generator-6.24.1.tgz", + "integrity": "sha1-XsWBgnrXI/7N04HxySg5BnbkVRs=", + "dev": true, + "requires": { + "babel-helper-function-name": "^6.24.1", + "babel-runtime": "^6.22.0", + "babel-template": "^6.24.1", + "babel-traverse": "^6.24.1", + "babel-types": "^6.24.1" + } + }, + "babel-helper-replace-supers": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-helper-replace-supers/-/babel-helper-replace-supers-6.24.1.tgz", + "integrity": "sha1-v22/5Dk40XNpohPKiov3S2qQqxo=", + "dev": true, + "requires": { + "babel-helper-optimise-call-expression": "^6.24.1", + "babel-messages": "^6.23.0", + "babel-runtime": "^6.22.0", + "babel-template": "^6.24.1", + "babel-traverse": "^6.24.1", + "babel-types": "^6.24.1" + } + }, + "babel-helpers": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-helpers/-/babel-helpers-6.24.1.tgz", + "integrity": "sha1-NHHenK7DiOXIUOWX5Yom3fN2ArI=", + "dev": true, + "requires": { + "babel-runtime": "^6.22.0", + "babel-template": "^6.24.1" + } + }, + "babel-loader": { + "version": "7.1.5", + "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-7.1.5.tgz", + "integrity": "sha512-iCHfbieL5d1LfOQeeVJEUyD9rTwBcP/fcEbRCfempxTDuqrKpu0AZjLAQHEQa3Yqyj9ORKe2iHfoj4rHLf7xpw==", + "dev": true, + "requires": { + "find-cache-dir": "^1.0.0", + "loader-utils": "^1.0.2", + "mkdirp": "^0.5.1" + } + }, + "babel-messages": { + "version": "6.23.0", + "resolved": "https://registry.npmjs.org/babel-messages/-/babel-messages-6.23.0.tgz", + "integrity": "sha1-8830cDhYA1sqKVHG7F7fbGLyYw4=", + "dev": true, + "requires": { + "babel-runtime": "^6.22.0" + } + }, + "babel-plugin-check-es2015-constants": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/babel-plugin-check-es2015-constants/-/babel-plugin-check-es2015-constants-6.22.0.tgz", + "integrity": "sha1-NRV7EBQm/S/9PaP3XH0ekYNbv4o=", + "dev": true, + "requires": { + "babel-runtime": "^6.22.0" + } + }, + "babel-plugin-lodash": { + "version": "3.3.4", + "resolved": "https://registry.npmjs.org/babel-plugin-lodash/-/babel-plugin-lodash-3.3.4.tgz", + "integrity": "sha512-yDZLjK7TCkWl1gpBeBGmuaDIFhZKmkoL+Cu2MUUjv5VxUZx/z7tBGBCBcQs5RI1Bkz5LLmNdjx7paOyQtMovyg==", + "dev": true, + "requires": { + "@babel/helper-module-imports": "^7.0.0-beta.49", + "@babel/types": "^7.0.0-beta.49", + "glob": "^7.1.1", + "lodash": "^4.17.10", + "require-package-name": "^2.0.1" + } + }, + "babel-plugin-rewire": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/babel-plugin-rewire/-/babel-plugin-rewire-1.2.0.tgz", + "integrity": "sha512-JBZxczHw3tScS+djy6JPLMjblchGhLI89ep15H3SyjujIzlxo5nr6Yjo7AXotdeVczeBmWs0tF8PgJWDdgzAkQ==", + "dev": true + }, + "babel-plugin-syntax-async-functions": { + "version": "6.13.0", + "resolved": "http://registry.npmjs.org/babel-plugin-syntax-async-functions/-/babel-plugin-syntax-async-functions-6.13.0.tgz", + "integrity": "sha1-ytnK0RkbWtY0vzCuCHI5HgZHvpU=", + "dev": true + }, + "babel-plugin-syntax-dynamic-import": { + "version": "6.18.0", + "resolved": "http://registry.npmjs.org/babel-plugin-syntax-dynamic-import/-/babel-plugin-syntax-dynamic-import-6.18.0.tgz", + "integrity": "sha1-jWomIpyDdFqZgqRBBRVyyqF5sdo=" + }, + "babel-plugin-syntax-exponentiation-operator": { + "version": "6.13.0", + "resolved": "http://registry.npmjs.org/babel-plugin-syntax-exponentiation-operator/-/babel-plugin-syntax-exponentiation-operator-6.13.0.tgz", + "integrity": "sha1-nufoM3KQ2pUoggGmpX9BcDF4MN4=", + "dev": true + }, + "babel-plugin-syntax-trailing-function-commas": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/babel-plugin-syntax-trailing-function-commas/-/babel-plugin-syntax-trailing-function-commas-6.22.0.tgz", + "integrity": "sha1-ugNgk3+NBuQBgKQ/4NVhb/9TLPM=", + "dev": true + }, + "babel-plugin-transform-async-to-generator": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-async-to-generator/-/babel-plugin-transform-async-to-generator-6.24.1.tgz", + "integrity": "sha1-ZTbjeK/2yx1VF6wOQOs+n8jQh2E=", + "dev": true, + "requires": { + "babel-helper-remap-async-to-generator": "^6.24.1", + "babel-plugin-syntax-async-functions": "^6.8.0", + "babel-runtime": "^6.22.0" + } + }, + "babel-plugin-transform-es2015-arrow-functions": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-arrow-functions/-/babel-plugin-transform-es2015-arrow-functions-6.22.0.tgz", + "integrity": "sha1-RSaSy3EdX3ncf4XkQM5BufJE0iE=", + "dev": true, + "requires": { + "babel-runtime": "^6.22.0" + } + }, + "babel-plugin-transform-es2015-block-scoped-functions": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-block-scoped-functions/-/babel-plugin-transform-es2015-block-scoped-functions-6.22.0.tgz", + "integrity": "sha1-u8UbSflk1wy42OC5ToICRs46YUE=", + "dev": true, + "requires": { + "babel-runtime": "^6.22.0" + } + }, + "babel-plugin-transform-es2015-block-scoping": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-block-scoping/-/babel-plugin-transform-es2015-block-scoping-6.26.0.tgz", + "integrity": "sha1-1w9SmcEwjQXBL0Y4E7CgnnOxiV8=", + "dev": true, + "requires": { + "babel-runtime": "^6.26.0", + "babel-template": "^6.26.0", + "babel-traverse": "^6.26.0", + "babel-types": "^6.26.0", + "lodash": "^4.17.4" + } + }, + "babel-plugin-transform-es2015-classes": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-classes/-/babel-plugin-transform-es2015-classes-6.24.1.tgz", + "integrity": "sha1-WkxYpQyclGHlZLSyo7+ryXolhNs=", + "dev": true, + "requires": { + "babel-helper-define-map": "^6.24.1", + "babel-helper-function-name": "^6.24.1", + "babel-helper-optimise-call-expression": "^6.24.1", + "babel-helper-replace-supers": "^6.24.1", + "babel-messages": "^6.23.0", + "babel-runtime": "^6.22.0", + "babel-template": "^6.24.1", + "babel-traverse": "^6.24.1", + "babel-types": "^6.24.1" + } + }, + "babel-plugin-transform-es2015-computed-properties": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-computed-properties/-/babel-plugin-transform-es2015-computed-properties-6.24.1.tgz", + "integrity": "sha1-b+Ko0WiV1WNPTNmZttNICjCBWbM=", + "dev": true, + "requires": { + "babel-runtime": "^6.22.0", + "babel-template": "^6.24.1" + } + }, + "babel-plugin-transform-es2015-destructuring": { + "version": "6.23.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-destructuring/-/babel-plugin-transform-es2015-destructuring-6.23.0.tgz", + "integrity": "sha1-mXux8auWf2gtKwh2/jWNYOdlxW0=", + "dev": true, + "requires": { + "babel-runtime": "^6.22.0" + } + }, + "babel-plugin-transform-es2015-duplicate-keys": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-duplicate-keys/-/babel-plugin-transform-es2015-duplicate-keys-6.24.1.tgz", + "integrity": "sha1-c+s9MQypaePvnskcU3QabxV2Qj4=", + "dev": true, + "requires": { + "babel-runtime": "^6.22.0", + "babel-types": "^6.24.1" + } + }, + "babel-plugin-transform-es2015-for-of": { + "version": "6.23.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-for-of/-/babel-plugin-transform-es2015-for-of-6.23.0.tgz", + "integrity": "sha1-9HyVsrYT3x0+zC/bdXNiPHUkhpE=", + "dev": true, + "requires": { + "babel-runtime": "^6.22.0" + } + }, + "babel-plugin-transform-es2015-function-name": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-function-name/-/babel-plugin-transform-es2015-function-name-6.24.1.tgz", + "integrity": "sha1-g0yJhTvDaxrw86TF26qU/Y6sqos=", + "dev": true, + "requires": { + "babel-helper-function-name": "^6.24.1", + "babel-runtime": "^6.22.0", + "babel-types": "^6.24.1" + } + }, + "babel-plugin-transform-es2015-literals": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-literals/-/babel-plugin-transform-es2015-literals-6.22.0.tgz", + "integrity": "sha1-T1SgLWzWbPkVKAAZox0xklN3yi4=", + "dev": true, + "requires": { + "babel-runtime": "^6.22.0" + } + }, + "babel-plugin-transform-es2015-modules-amd": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-modules-amd/-/babel-plugin-transform-es2015-modules-amd-6.24.1.tgz", + "integrity": "sha1-Oz5UAXI5hC1tGcMBHEvS8AoA0VQ=", + "dev": true, + "requires": { + "babel-plugin-transform-es2015-modules-commonjs": "^6.24.1", + "babel-runtime": "^6.22.0", + "babel-template": "^6.24.1" + } + }, + "babel-plugin-transform-es2015-modules-commonjs": { + "version": "6.26.2", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-modules-commonjs/-/babel-plugin-transform-es2015-modules-commonjs-6.26.2.tgz", + "integrity": "sha512-CV9ROOHEdrjcwhIaJNBGMBCodN+1cfkwtM1SbUHmvyy35KGT7fohbpOxkE2uLz1o6odKK2Ck/tz47z+VqQfi9Q==", + "dev": true, + "requires": { + "babel-plugin-transform-strict-mode": "^6.24.1", + "babel-runtime": "^6.26.0", + "babel-template": "^6.26.0", + "babel-types": "^6.26.0" + } + }, + "babel-plugin-transform-es2015-modules-systemjs": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-modules-systemjs/-/babel-plugin-transform-es2015-modules-systemjs-6.24.1.tgz", + "integrity": "sha1-/4mhQrkRmpBhlfXxBuzzBdlAfSM=", + "dev": true, + "requires": { + "babel-helper-hoist-variables": "^6.24.1", + "babel-runtime": "^6.22.0", + "babel-template": "^6.24.1" + } + }, + "babel-plugin-transform-es2015-modules-umd": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-modules-umd/-/babel-plugin-transform-es2015-modules-umd-6.24.1.tgz", + "integrity": "sha1-rJl+YoXNGO1hdq22B9YCNErThGg=", + "dev": true, + "requires": { + "babel-plugin-transform-es2015-modules-amd": "^6.24.1", + "babel-runtime": "^6.22.0", + "babel-template": "^6.24.1" + } + }, + "babel-plugin-transform-es2015-object-super": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-object-super/-/babel-plugin-transform-es2015-object-super-6.24.1.tgz", + "integrity": "sha1-JM72muIcuDp/hgPa0CH1cusnj40=", + "dev": true, + "requires": { + "babel-helper-replace-supers": "^6.24.1", + "babel-runtime": "^6.22.0" + } + }, + "babel-plugin-transform-es2015-parameters": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-parameters/-/babel-plugin-transform-es2015-parameters-6.24.1.tgz", + "integrity": "sha1-V6w1GrScrxSpfNE7CfZv3wpiXys=", + "dev": true, + "requires": { + "babel-helper-call-delegate": "^6.24.1", + "babel-helper-get-function-arity": "^6.24.1", + "babel-runtime": "^6.22.0", + "babel-template": "^6.24.1", + "babel-traverse": "^6.24.1", + "babel-types": "^6.24.1" + } + }, + "babel-plugin-transform-es2015-shorthand-properties": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-shorthand-properties/-/babel-plugin-transform-es2015-shorthand-properties-6.24.1.tgz", + "integrity": "sha1-JPh11nIch2YbvZmkYi5R8U3jiqA=", + "dev": true, + "requires": { + "babel-runtime": "^6.22.0", + "babel-types": "^6.24.1" + } + }, + "babel-plugin-transform-es2015-spread": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-spread/-/babel-plugin-transform-es2015-spread-6.22.0.tgz", + "integrity": "sha1-1taKmfia7cRTbIGlQujdnxdG+NE=", + "dev": true, + "requires": { + "babel-runtime": "^6.22.0" + } + }, + "babel-plugin-transform-es2015-sticky-regex": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-sticky-regex/-/babel-plugin-transform-es2015-sticky-regex-6.24.1.tgz", + "integrity": "sha1-AMHNsaynERLN8M9hJsLta0V8zbw=", + "dev": true, + "requires": { + "babel-helper-regex": "^6.24.1", + "babel-runtime": "^6.22.0", + "babel-types": "^6.24.1" + } + }, + "babel-plugin-transform-es2015-template-literals": { + "version": "6.22.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-template-literals/-/babel-plugin-transform-es2015-template-literals-6.22.0.tgz", + "integrity": "sha1-qEs0UPfp+PH2g51taH2oS7EjbY0=", + "dev": true, + "requires": { + "babel-runtime": "^6.22.0" + } + }, + "babel-plugin-transform-es2015-typeof-symbol": { + "version": "6.23.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-typeof-symbol/-/babel-plugin-transform-es2015-typeof-symbol-6.23.0.tgz", + "integrity": "sha1-3sCfHN3/lLUqxz1QXITfWdzOs3I=", + "dev": true, + "requires": { + "babel-runtime": "^6.22.0" + } + }, + "babel-plugin-transform-es2015-unicode-regex": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-es2015-unicode-regex/-/babel-plugin-transform-es2015-unicode-regex-6.24.1.tgz", + "integrity": "sha1-04sS9C6nMj9yk4fxinxa4frrNek=", + "dev": true, + "requires": { + "babel-helper-regex": "^6.24.1", + "babel-runtime": "^6.22.0", + "regexpu-core": "^2.0.0" + } + }, + "babel-plugin-transform-exponentiation-operator": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-exponentiation-operator/-/babel-plugin-transform-exponentiation-operator-6.24.1.tgz", + "integrity": "sha1-KrDJx/MJj6SJB3cruBP+QejeOg4=", + "dev": true, + "requires": { + "babel-helper-builder-binary-assignment-operator-visitor": "^6.24.1", + "babel-plugin-syntax-exponentiation-operator": "^6.8.0", + "babel-runtime": "^6.22.0" + } + }, + "babel-plugin-transform-regenerator": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-regenerator/-/babel-plugin-transform-regenerator-6.26.0.tgz", + "integrity": "sha1-4HA2lvveJ/Cj78rPi03KL3s6jy8=", + "dev": true, + "requires": { + "regenerator-transform": "^0.10.0" + } + }, + "babel-plugin-transform-runtime": { + "version": "6.23.0", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-runtime/-/babel-plugin-transform-runtime-6.23.0.tgz", + "integrity": "sha1-iEkNRGUC6puOfvsP4J7E2ZR5se4=", + "dev": true, + "requires": { + "babel-runtime": "^6.22.0" + } + }, + "babel-plugin-transform-strict-mode": { + "version": "6.24.1", + "resolved": "https://registry.npmjs.org/babel-plugin-transform-strict-mode/-/babel-plugin-transform-strict-mode-6.24.1.tgz", + "integrity": "sha1-1fr3qleKZbvlkc9e2uBKDGcCB1g=", + "dev": true, + "requires": { + "babel-runtime": "^6.22.0", + "babel-types": "^6.24.1" + } + }, + "babel-preset-env": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/babel-preset-env/-/babel-preset-env-1.7.0.tgz", + "integrity": "sha512-9OR2afuKDneX2/q2EurSftUYM0xGu4O2D9adAhVfADDhrYDaxXV0rBbevVYoY9n6nyX1PmQW/0jtpJvUNr9CHg==", + "dev": true, + "requires": { + "babel-plugin-check-es2015-constants": "^6.22.0", + "babel-plugin-syntax-trailing-function-commas": "^6.22.0", + "babel-plugin-transform-async-to-generator": "^6.22.0", + "babel-plugin-transform-es2015-arrow-functions": "^6.22.0", + "babel-plugin-transform-es2015-block-scoped-functions": "^6.22.0", + "babel-plugin-transform-es2015-block-scoping": "^6.23.0", + "babel-plugin-transform-es2015-classes": "^6.23.0", + "babel-plugin-transform-es2015-computed-properties": "^6.22.0", + "babel-plugin-transform-es2015-destructuring": "^6.23.0", + "babel-plugin-transform-es2015-duplicate-keys": "^6.22.0", + "babel-plugin-transform-es2015-for-of": "^6.23.0", + "babel-plugin-transform-es2015-function-name": "^6.22.0", + "babel-plugin-transform-es2015-literals": "^6.22.0", + "babel-plugin-transform-es2015-modules-amd": "^6.22.0", + "babel-plugin-transform-es2015-modules-commonjs": "^6.23.0", + "babel-plugin-transform-es2015-modules-systemjs": "^6.23.0", + "babel-plugin-transform-es2015-modules-umd": "^6.23.0", + "babel-plugin-transform-es2015-object-super": "^6.22.0", + "babel-plugin-transform-es2015-parameters": "^6.23.0", + "babel-plugin-transform-es2015-shorthand-properties": "^6.22.0", + "babel-plugin-transform-es2015-spread": "^6.22.0", + "babel-plugin-transform-es2015-sticky-regex": "^6.22.0", + "babel-plugin-transform-es2015-template-literals": "^6.22.0", + "babel-plugin-transform-es2015-typeof-symbol": "^6.23.0", + "babel-plugin-transform-es2015-unicode-regex": "^6.22.0", + "babel-plugin-transform-exponentiation-operator": "^6.22.0", + "babel-plugin-transform-regenerator": "^6.22.0", + "browserslist": "^3.2.6", + "invariant": "^2.2.2", + "semver": "^5.3.0" + }, + "dependencies": { + "browserslist": { + "version": "3.2.8", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-3.2.8.tgz", + "integrity": "sha512-WHVocJYavUwVgVViC0ORikPHQquXwVh939TaelZ4WDqpWgTX/FsGhl/+P4qBUAGcRvtOgDgC+xftNWWp2RUTAQ==", + "dev": true, + "requires": { + "caniuse-lite": "^1.0.30000844", + "electron-to-chromium": "^1.3.47" + } + } + } + }, + "babel-register": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-register/-/babel-register-6.26.0.tgz", + "integrity": "sha1-btAhFz4vy0htestFxgCahW9kcHE=", + "dev": true, + "requires": { + "babel-core": "^6.26.0", + "babel-runtime": "^6.26.0", + "core-js": "^2.5.0", + "home-or-tmp": "^2.0.0", + "lodash": "^4.17.4", + "mkdirp": "^0.5.1", + "source-map-support": "^0.4.15" + } + }, + "babel-runtime": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz", + "integrity": "sha1-llxwWGaOgrVde/4E/yM3vItWR/4=", + "requires": { + "core-js": "^2.4.0", + "regenerator-runtime": "^0.11.0" + } + }, + "babel-template": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-template/-/babel-template-6.26.0.tgz", + "integrity": "sha1-3gPi0WOWsGn0bdn/+FIfsaDjXgI=", + "dev": true, + "requires": { + "babel-runtime": "^6.26.0", + "babel-traverse": "^6.26.0", + "babel-types": "^6.26.0", + "babylon": "^6.18.0", + "lodash": "^4.17.4" + } + }, + "babel-traverse": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-traverse/-/babel-traverse-6.26.0.tgz", + "integrity": "sha1-RqnL1+3MYsjlwGTi0tjQ9ANXZu4=", + "dev": true, + "requires": { + "babel-code-frame": "^6.26.0", + "babel-messages": "^6.23.0", + "babel-runtime": "^6.26.0", + "babel-types": "^6.26.0", + "babylon": "^6.18.0", + "debug": "^2.6.8", + "globals": "^9.18.0", + "invariant": "^2.2.2", + "lodash": "^4.17.4" + } + }, + "babel-types": { + "version": "6.26.0", + "resolved": "https://registry.npmjs.org/babel-types/-/babel-types-6.26.0.tgz", + "integrity": "sha1-o7Bz+Uq0nrb6Vc1lInozQ4BjJJc=", + "dev": true, + "requires": { + "babel-runtime": "^6.26.0", + "esutils": "^2.0.2", + "lodash": "^4.17.4", + "to-fast-properties": "^1.0.3" + } + }, + "babylon": { + "version": "6.18.0", + "resolved": "https://registry.npmjs.org/babylon/-/babylon-6.18.0.tgz", + "integrity": "sha512-q/UEjfGJ2Cm3oKV71DJz9d25TPnq5rhBVL2Q4fA5wcC3jcrdn7+SssEybFIxwAvvP+YCsCYNKughoF33GxgycQ==", + "dev": true + }, + "balanced-match": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.0.tgz", + "integrity": "sha1-ibTRmasr7kneFk6gK4nORi1xt2c=", + "dev": true + }, + "base": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/base/-/base-0.11.2.tgz", + "integrity": "sha512-5T6P4xPgpp0YDFvSWwEZ4NoE3aM4QBQXDzmVbraCkFj8zHM+mba8SyqB5DbZWyR7mYHo6Y7BdQo3MoA4m0TeQg==", + "dev": true, + "requires": { + "cache-base": "^1.0.1", + "class-utils": "^0.3.5", + "component-emitter": "^1.2.1", + "define-property": "^1.0.0", + "isobject": "^3.0.1", + "mixin-deep": "^1.2.0", + "pascalcase": "^0.1.1" + }, + "dependencies": { + "define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", + "integrity": "sha1-dp66rz9KY6rTr56NMEybvnm/sOY=", + "dev": true, + "requires": { + "is-descriptor": "^1.0.0" + } + }, + "is-accessor-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", + "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-data-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", + "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-descriptor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", + "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "^1.0.0", + "is-data-descriptor": "^1.0.0", + "kind-of": "^6.0.2" + } + } + } + }, + "base64-js": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.0.tgz", + "integrity": "sha512-ccav/yGvoa80BQDljCxsmmQ3Xvx60/UpBIij5QN21W3wBi/hhIC9OoO+KLpu9IJTS9j4DRVJ3aDDF9cMSoa2lw==", + "dev": true + }, + "bcrypt-pbkdf": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", + "integrity": "sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=", + "dev": true, + "optional": true, + "requires": { + "tweetnacl": "^0.14.3" + } + }, + "beeper": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/beeper/-/beeper-1.1.1.tgz", + "integrity": "sha1-5tXqjF2tABMEpwsiY4RH9pyy+Ak=", + "dev": true + }, + "big.js": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/big.js/-/big.js-3.2.0.tgz", + "integrity": "sha512-+hN/Zh2D08Mx65pZ/4g5bsmNiZUuChDiQfTUQ7qJr4/kuopCr88xZsAXv6mBoZEsUI4OuGHlX59qE94K2mMW8Q==", + "dev": true + }, + "binary-extensions": { + "version": "1.12.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-1.12.0.tgz", + "integrity": "sha512-DYWGk01lDcxeS/K9IHPGWfT8PsJmbXRtRd2Sx72Tnb8pcYZQFF1oSDb8hJtS1vhp212q1Rzi5dUf9+nq0o9UIg==", + "dev": true + }, + "block-stream": { + "version": "0.0.9", + "resolved": "https://registry.npmjs.org/block-stream/-/block-stream-0.0.9.tgz", + "integrity": "sha1-E+v+d4oDIFz+A3UUgeu0szAMEmo=", + "dev": true, + "requires": { + "inherits": "~2.0.0" + } + }, + "bn.js": { + "version": "4.11.8", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.11.8.tgz", + "integrity": "sha512-ItfYfPLkWHUjckQCk8xC+LwxgK8NYcXywGigJgSwOP8Y2iyWT4f2vsZnoOXTTbo+o5yXmIUJ4gn5538SO5S3gA==", + "dev": true + }, + "brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "braces": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/braces/-/braces-2.3.2.tgz", + "integrity": "sha512-aNdbnj9P8PjdXU4ybaWLK2IF3jc/EoDYbC7AazW6to3TRsfXxscC9UXOB5iDiEQrkyIbWp2SLQda4+QAa7nc3w==", + "dev": true, + "requires": { + "arr-flatten": "^1.1.0", + "array-unique": "^0.3.2", + "extend-shallow": "^2.0.1", + "fill-range": "^4.0.0", + "isobject": "^3.0.1", + "repeat-element": "^1.1.2", + "snapdragon": "^0.8.1", + "snapdragon-node": "^2.0.1", + "split-string": "^3.0.2", + "to-regex": "^3.0.1" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "brorand": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/brorand/-/brorand-1.1.0.tgz", + "integrity": "sha1-EsJe/kCkXjwyPrhnWgoM5XsiNx8=", + "dev": true + }, + "browserify-aes": { + "version": "1.2.0", + "resolved": "http://registry.npmjs.org/browserify-aes/-/browserify-aes-1.2.0.tgz", + "integrity": "sha512-+7CHXqGuspUn/Sl5aO7Ea0xWGAtETPXNSAjHo48JfLdPWcMng33Xe4znFvQweqc/uzk5zSOI3H52CYnjCfb5hA==", + "dev": true, + "requires": { + "buffer-xor": "^1.0.3", + "cipher-base": "^1.0.0", + "create-hash": "^1.1.0", + "evp_bytestokey": "^1.0.3", + "inherits": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "browserify-cipher": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/browserify-cipher/-/browserify-cipher-1.0.1.tgz", + "integrity": "sha512-sPhkz0ARKbf4rRQt2hTpAHqn47X3llLkUGn+xEJzLjwY8LRs2p0v7ljvI5EyoRO/mexrNunNECisZs+gw2zz1w==", + "dev": true, + "requires": { + "browserify-aes": "^1.0.4", + "browserify-des": "^1.0.0", + "evp_bytestokey": "^1.0.0" + } + }, + "browserify-des": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/browserify-des/-/browserify-des-1.0.2.tgz", + "integrity": "sha512-BioO1xf3hFwz4kc6iBhI3ieDFompMhrMlnDFC4/0/vd5MokpuAc3R+LYbwTA9A5Yc9pq9UYPqffKpW2ObuwX5A==", + "dev": true, + "requires": { + "cipher-base": "^1.0.1", + "des.js": "^1.0.0", + "inherits": "^2.0.1", + "safe-buffer": "^5.1.2" + } + }, + "browserify-rsa": { + "version": "4.0.1", + "resolved": "http://registry.npmjs.org/browserify-rsa/-/browserify-rsa-4.0.1.tgz", + "integrity": "sha1-IeCr+vbyApzy+vsTNWenAdQTVSQ=", + "dev": true, + "requires": { + "bn.js": "^4.1.0", + "randombytes": "^2.0.1" + } + }, + "browserify-sign": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/browserify-sign/-/browserify-sign-4.0.4.tgz", + "integrity": "sha1-qk62jl17ZYuqa/alfmMMvXqT0pg=", + "dev": true, + "requires": { + "bn.js": "^4.1.1", + "browserify-rsa": "^4.0.0", + "create-hash": "^1.1.0", + "create-hmac": "^1.1.2", + "elliptic": "^6.0.0", + "inherits": "^2.0.1", + "parse-asn1": "^5.0.0" + } + }, + "browserify-zlib": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/browserify-zlib/-/browserify-zlib-0.2.0.tgz", + "integrity": "sha512-Z942RysHXmJrhqk88FmKBVq/v5tqmSkDz7p54G/MGyjMnCFFnC79XWNbg+Vta8W6Wb2qtSZTSxIGkJrRpCFEiA==", + "dev": true, + "requires": { + "pako": "~1.0.5" + } + }, + "browserslist": { + "version": "1.7.7", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-1.7.7.tgz", + "integrity": "sha1-C9dnBCWL6CmyOYu1Dkti0aFmsLk=", + "dev": true, + "requires": { + "caniuse-db": "^1.0.30000639", + "electron-to-chromium": "^1.2.7" + } + }, + "browserslist-saucelabs": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/browserslist-saucelabs/-/browserslist-saucelabs-0.2.5.tgz", + "integrity": "sha1-C1GkMVq37yqudyEFiT6IUZp+gio=", + "dev": true, + "requires": { + "browserslist": "^1.0.1", + "lodash": "^3.10.1" + }, + "dependencies": { + "lodash": { + "version": "3.10.1", + "resolved": "http://registry.npmjs.org/lodash/-/lodash-3.10.1.tgz", + "integrity": "sha1-W/Rejkm6QYnhfUgnid/RW9FAt7Y=", + "dev": true + } + } + }, + "buffer": { + "version": "4.9.1", + "resolved": "http://registry.npmjs.org/buffer/-/buffer-4.9.1.tgz", + "integrity": "sha1-bRu2AbB6TvztlwlBMgkwJ8lbwpg=", + "dev": true, + "requires": { + "base64-js": "^1.0.2", + "ieee754": "^1.1.4", + "isarray": "^1.0.0" + } + }, + "buffer-from": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.1.tgz", + "integrity": "sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==", + "dev": true + }, + "buffer-xor": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/buffer-xor/-/buffer-xor-1.0.3.tgz", + "integrity": "sha1-JuYe0UIvtw3ULm42cp7VHYVf6Nk=", + "dev": true + }, + "bufferstreams": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/bufferstreams/-/bufferstreams-1.1.3.tgz", + "integrity": "sha512-HaJnVuslRF4g2kSDeyl++AaVizoitCpL9PglzCYwy0uHHyvWerfvEb8jWmYbF1z4kiVFolGomnxSGl+GUQp2jg==", + "dev": true, + "requires": { + "readable-stream": "^2.0.2" + } + }, + "builtin-modules": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/builtin-modules/-/builtin-modules-1.1.1.tgz", + "integrity": "sha1-Jw8HbFpywC9bZaR9+Uxf46J4iS8=", + "dev": true + }, + "builtin-status-codes": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/builtin-status-codes/-/builtin-status-codes-3.0.0.tgz", + "integrity": "sha1-hZgoeOIbmOHGZCXgPQF0eI9Wnug=", + "dev": true + }, + "cache-base": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/cache-base/-/cache-base-1.0.1.tgz", + "integrity": "sha512-AKcdTnFSWATd5/GCPRxr2ChwIJ85CeyrEyjRHlKxQ56d4XJMGym0uAiKn0xbLOGOl3+yRpOTi484dVCEc5AUzQ==", + "dev": true, + "requires": { + "collection-visit": "^1.0.0", + "component-emitter": "^1.2.1", + "get-value": "^2.0.6", + "has-value": "^1.0.0", + "isobject": "^3.0.1", + "set-value": "^2.0.0", + "to-object-path": "^0.3.0", + "union-value": "^1.0.0", + "unset-value": "^1.0.0" + } + }, + "caller-path": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/caller-path/-/caller-path-0.1.0.tgz", + "integrity": "sha1-lAhe9jWB7NPaqSREqP6U6CV3dR8=", + "dev": true, + "requires": { + "callsites": "^0.2.0" + } + }, + "callsites": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-0.2.0.tgz", + "integrity": "sha1-r6uWJikQp/M8GaV3WCXGnzTjUMo=", + "dev": true + }, + "camelcase": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-2.1.1.tgz", + "integrity": "sha1-fB0W1nmhu+WcoCys7PsBHiAfWh8=", + "dev": true + }, + "camelcase-keys": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/camelcase-keys/-/camelcase-keys-2.1.0.tgz", + "integrity": "sha1-MIvur/3ygRkFHvodkyITyRuPkuc=", + "dev": true, + "requires": { + "camelcase": "^2.0.0", + "map-obj": "^1.0.0" + } + }, + "caniuse-db": { + "version": "1.0.30000887", + "resolved": "https://registry.npmjs.org/caniuse-db/-/caniuse-db-1.0.30000887.tgz", + "integrity": "sha512-yOScC1WJ6ihxxPNeWSqYc2nKHqeHzXMY382yvC0mZdi+kWBrlEdCFeR/T1s5Abe5n51HoD6IA/Gho2T8vnRT2g==", + "dev": true + }, + "caniuse-lite": { + "version": "1.0.30000887", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30000887.tgz", + "integrity": "sha512-AHpONWuGFWO8yY9igdXH94tikM6ERS84286r0cAMAXYFtJBk76lhiMhtCxBJNBZsD6hzlvpWZ2AtbVFEkf4JQA==", + "dev": true + }, + "caseless": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", + "integrity": "sha1-G2gcIf+EAzyCZUMJBolCDRhxUdw=", + "dev": true + }, + "center-align": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/center-align/-/center-align-0.1.3.tgz", + "integrity": "sha1-qg0yYptu6XIgBBHL1EYckHvCt60=", + "dev": true, + "requires": { + "align-text": "^0.1.3", + "lazy-cache": "^1.0.3" + } + }, + "chalk": { + "version": "1.1.3", + "resolved": "http://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", + "dev": true, + "requires": { + "ansi-styles": "^2.2.1", + "escape-string-regexp": "^1.0.2", + "has-ansi": "^2.0.0", + "strip-ansi": "^3.0.0", + "supports-color": "^2.0.0" + }, + "dependencies": { + "supports-color": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-2.0.0.tgz", + "integrity": "sha1-U10EXOa2Nj+kARcIRimZXp3zJMc=", + "dev": true + } + } + }, + "chardet": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/chardet/-/chardet-0.4.2.tgz", + "integrity": "sha1-tUc7M9yXxCTl2Y3IfVXU2KKci/I=", + "dev": true + }, + "chokidar": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-2.0.4.tgz", + "integrity": "sha512-z9n7yt9rOvIJrMhvDtDictKrkFHeihkNl6uWMmZlmL6tJtX9Cs+87oK+teBx+JIgzvbX3yZHT3eF8vpbDxHJXQ==", + "dev": true, + "requires": { + "anymatch": "^2.0.0", + "async-each": "^1.0.0", + "braces": "^2.3.0", + "fsevents": "^1.2.2", + "glob-parent": "^3.1.0", + "inherits": "^2.0.1", + "is-binary-path": "^1.0.0", + "is-glob": "^4.0.0", + "lodash.debounce": "^4.0.8", + "normalize-path": "^2.1.1", + "path-is-absolute": "^1.0.0", + "readdirp": "^2.0.0", + "upath": "^1.0.5" + }, + "dependencies": { + "is-glob": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.0.tgz", + "integrity": "sha1-lSHHaEXMJhCoUgPd8ICpWML/q8A=", + "dev": true, + "requires": { + "is-extglob": "^2.1.1" + } + } + } + }, + "cipher-base": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/cipher-base/-/cipher-base-1.0.4.tgz", + "integrity": "sha512-Kkht5ye6ZGmwv40uUDZztayT2ThLQGfnj/T71N/XzeZeo3nf8foyW7zGTsPYkEya3m5f3cAypH+qe7YOrM1U2Q==", + "dev": true, + "requires": { + "inherits": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "circular-json": { + "version": "0.3.3", + "resolved": "https://registry.npmjs.org/circular-json/-/circular-json-0.3.3.tgz", + "integrity": "sha512-UZK3NBx2Mca+b5LsG7bY183pHWt5Y1xts4P3Pz7ENTwGVnJOUWbRb3ocjvX7hx9tq/yTAdclXm9sZ38gNuem4A==", + "dev": true + }, + "class-utils": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/class-utils/-/class-utils-0.3.6.tgz", + "integrity": "sha512-qOhPa/Fj7s6TY8H8esGu5QNpMMQxz79h+urzrNYN6mn+9BnxlDGf5QZ+XeCDsxSjPqsSR56XOZOJmpeurnLMeg==", + "dev": true, + "requires": { + "arr-union": "^3.1.0", + "define-property": "^0.2.5", + "isobject": "^3.0.0", + "static-extend": "^0.1.1" + }, + "dependencies": { + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "requires": { + "is-descriptor": "^0.1.0" + } + } + } + }, + "clean-css": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/clean-css/-/clean-css-4.2.1.tgz", + "integrity": "sha512-4ZxI6dy4lrY6FHzfiy1aEOXgu4LIsW2MhwG0VBKdcoGoH/XLFgaHSdLTGr4O8Be6A8r3MOphEiI8Gc1n0ecf3g==", + "dev": true, + "requires": { + "source-map": "~0.6.0" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "cli-cursor": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-2.1.0.tgz", + "integrity": "sha1-s12sN2R5+sw+lHR9QdDQ9SOP/LU=", + "dev": true, + "requires": { + "restore-cursor": "^2.0.0" + } + }, + "cli-width": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cli-width/-/cli-width-2.2.0.tgz", + "integrity": "sha1-/xnt6Kml5XkyQUewwR8PvLq+1jk=", + "dev": true + }, + "cliui": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-3.2.0.tgz", + "integrity": "sha1-EgYBU3qRbSmUD5NNo7SNWFo5IT0=", + "dev": true, + "requires": { + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1", + "wrap-ansi": "^2.0.0" + }, + "dependencies": { + "is-fullwidth-code-point": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", + "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", + "dev": true, + "requires": { + "number-is-nan": "^1.0.0" + } + }, + "string-width": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", + "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", + "dev": true, + "requires": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "strip-ansi": "^3.0.0" + } + } + } + }, + "clone": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/clone/-/clone-0.2.0.tgz", + "integrity": "sha1-xhJqkK1Pctv1rNskPMN3JP6T/B8=", + "dev": true + }, + "clone-stats": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/clone-stats/-/clone-stats-0.0.1.tgz", + "integrity": "sha1-uI+UqCzzi4eR1YBG6kAprYjKmdE=", + "dev": true + }, + "co": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", + "integrity": "sha1-bqa989hTrlTMuOR7+gvz+QMfsYQ=", + "dev": true + }, + "code-point-at": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", + "integrity": "sha1-DQcLTQQ6W+ozovGkDi7bPZpMz3c=", + "dev": true + }, + "collection-visit": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/collection-visit/-/collection-visit-1.0.0.tgz", + "integrity": "sha1-S8A3PBZLwykbTTaMgpzxqApZ3KA=", + "dev": true, + "requires": { + "map-visit": "^1.0.0", + "object-visit": "^1.0.0" + } + }, + "color-convert": { + "version": "1.9.3", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", + "integrity": "sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==", + "dev": true, + "requires": { + "color-name": "1.1.3" + } + }, + "color-name": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz", + "integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU=", + "dev": true + }, + "color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "dev": true + }, + "combined-stream": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.7.tgz", + "integrity": "sha512-brWl9y6vOB1xYPZcpZde3N9zDByXTosAeMDo4p1wzo6UMOX4vumB+TP1RZ76sfE6Md68Q0NJSrE/gbezd4Ul+w==", + "dev": true, + "requires": { + "delayed-stream": "~1.0.0" + } + }, + "commondir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz", + "integrity": "sha1-3dgA2gxmEnOTzKWVDqloo6rxJTs=", + "dev": true + }, + "component-emitter": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.2.1.tgz", + "integrity": "sha1-E3kY1teCg/ffemt8WmPhQOaUJeY=", + "dev": true + }, + "concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=", + "dev": true + }, + "concat-stream": { + "version": "1.6.2", + "resolved": "https://registry.npmjs.org/concat-stream/-/concat-stream-1.6.2.tgz", + "integrity": "sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==", + "dev": true, + "requires": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^2.2.2", + "typedarray": "^0.0.6" + } + }, + "console-browserify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-browserify/-/console-browserify-1.1.0.tgz", + "integrity": "sha1-8CQcRXMKn8YyOyBtvzjtx0HQuxA=", + "dev": true, + "requires": { + "date-now": "^0.1.4" + } + }, + "console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha1-PXz0Rk22RG6mRL9LOVB/mFEAjo4=", + "dev": true + }, + "constants-browserify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/constants-browserify/-/constants-browserify-1.0.0.tgz", + "integrity": "sha1-wguW2MYXdIqvHBYCF2DNJ/y4y3U=", + "dev": true + }, + "convert-source-map": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.6.0.tgz", + "integrity": "sha512-eFu7XigvxdZ1ETfbgPBohgyQ/Z++C0eEhTor0qRwBw9unw+L0/6V8wkSuGgzdThkiS5lSpdptOQPD8Ak40a+7A==", + "dev": true, + "requires": { + "safe-buffer": "~5.1.1" + } + }, + "copy-descriptor": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/copy-descriptor/-/copy-descriptor-0.1.1.tgz", + "integrity": "sha1-Z29us8OZl8LuGsOpJP1hJHSPV40=", + "dev": true + }, + "core-js": { + "version": "2.5.7", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.5.7.tgz", + "integrity": "sha512-RszJCAxg/PP6uzXVXL6BsxSXx/B05oJAQ2vkJRjyjrEcNVycaqOmNb5OTxZPE3xa5gwZduqza6L9JOCenh/Ecw==" + }, + "core-util-is": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", + "integrity": "sha1-tf1UIgqivFq1eqtxQMlAdUUDwac=", + "dev": true + }, + "cosmiconfig": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-2.2.2.tgz", + "integrity": "sha512-GiNXLwAFPYHy25XmTPpafYvn3CLAkJ8FLsscq78MQd1Kh0OU6Yzhn4eV2MVF4G9WEQZoWEGltatdR+ntGPMl5A==", + "dev": true, + "requires": { + "is-directory": "^0.3.1", + "js-yaml": "^3.4.3", + "minimist": "^1.2.0", + "object-assign": "^4.1.0", + "os-homedir": "^1.0.1", + "parse-json": "^2.2.0", + "require-from-string": "^1.1.0" + } + }, + "create-ecdh": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/create-ecdh/-/create-ecdh-4.0.3.tgz", + "integrity": "sha512-GbEHQPMOswGpKXM9kCWVrremUcBmjteUaQ01T9rkKCPDXfUHX0IoP9LpHYo2NPFampa4e+/pFDc3jQdxrxQLaw==", + "dev": true, + "requires": { + "bn.js": "^4.1.0", + "elliptic": "^6.0.0" + } + }, + "create-hash": { + "version": "1.2.0", + "resolved": "http://registry.npmjs.org/create-hash/-/create-hash-1.2.0.tgz", + "integrity": "sha512-z00bCGNHDG8mHAkP7CtT1qVu+bFQUPjYq/4Iv3C3kWjTFV10zIjfSoeqXo9Asws8gwSHDGj/hl2u4OGIjapeCg==", + "dev": true, + "requires": { + "cipher-base": "^1.0.1", + "inherits": "^2.0.1", + "md5.js": "^1.3.4", + "ripemd160": "^2.0.1", + "sha.js": "^2.4.0" + } + }, + "create-hmac": { + "version": "1.1.7", + "resolved": "http://registry.npmjs.org/create-hmac/-/create-hmac-1.1.7.tgz", + "integrity": "sha512-MJG9liiZ+ogc4TzUwuvbER1JRdgvUFSB5+VR/g5h82fGaIRWMWddtKBHi7/sVhfjQZ6SehlyhvQYrcYkaUIpLg==", + "dev": true, + "requires": { + "cipher-base": "^1.0.3", + "create-hash": "^1.1.0", + "inherits": "^2.0.1", + "ripemd160": "^2.0.0", + "safe-buffer": "^5.0.1", + "sha.js": "^2.4.8" + } + }, + "cross-spawn": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-5.1.0.tgz", + "integrity": "sha1-6L0O/uWPz/b4+UUQoKVUu/ojVEk=", + "dev": true, + "requires": { + "lru-cache": "^4.0.1", + "shebang-command": "^1.2.0", + "which": "^1.2.9" + } + }, + "crypto-browserify": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/crypto-browserify/-/crypto-browserify-3.12.0.tgz", + "integrity": "sha512-fz4spIh+znjO2VjL+IdhEpRJ3YN6sMzITSBijk6FK2UvTqruSQW+/cCZTSNsMiZNvUeq0CqurF+dAbyiGOY6Wg==", + "dev": true, + "requires": { + "browserify-cipher": "^1.0.0", + "browserify-sign": "^4.0.0", + "create-ecdh": "^4.0.0", + "create-hash": "^1.1.0", + "create-hmac": "^1.1.0", + "diffie-hellman": "^5.0.0", + "inherits": "^2.0.1", + "pbkdf2": "^3.0.3", + "public-encrypt": "^4.0.0", + "randombytes": "^2.0.0", + "randomfill": "^1.0.3" + } + }, + "css": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/css/-/css-2.2.4.tgz", + "integrity": "sha512-oUnjmWpy0niI3x/mPL8dVEI1l7MnG3+HHyRPHf+YFSbK+svOhXpmSOcDURUh2aOCgl2grzrOPt1nHLuCVFULLw==", + "dev": true, + "requires": { + "inherits": "^2.0.3", + "source-map": "^0.6.1", + "source-map-resolve": "^0.5.2", + "urix": "^0.1.0" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "currently-unhandled": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/currently-unhandled/-/currently-unhandled-0.4.1.tgz", + "integrity": "sha1-mI3zP+qxke95mmE2nddsF635V+o=", + "dev": true, + "requires": { + "array-find-index": "^1.0.1" + } + }, + "d": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/d/-/d-1.0.0.tgz", + "integrity": "sha1-dUu1v+VUUdpppYuU1F9MWwRi1Y8=", + "dev": true, + "requires": { + "es5-ext": "^0.10.9" + } + }, + "dashdash": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", + "integrity": "sha1-hTz6D3y+L+1d4gMmuN1YEDX24vA=", + "dev": true, + "requires": { + "assert-plus": "^1.0.0" + } + }, + "date-now": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/date-now/-/date-now-0.1.4.tgz", + "integrity": "sha1-6vQ5/U1ISK105cx9vvIAZyueNFs=", + "dev": true + }, + "dateformat": { + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-1.0.12.tgz", + "integrity": "sha1-nxJLZ1lMk3/3BpMuSmQsyo27/uk=", + "dev": true, + "requires": { + "get-stdin": "^4.0.1", + "meow": "^3.3.0" + } + }, + "debug": { + "version": "2.6.9", + "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", + "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==", + "dev": true, + "requires": { + "ms": "2.0.0" + } + }, + "debug-fabulous": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/debug-fabulous/-/debug-fabulous-0.0.4.tgz", + "integrity": "sha1-+gccXYdIRoVCSAdCHKSxawsaB2M=", + "dev": true, + "requires": { + "debug": "2.X", + "lazy-debug-legacy": "0.0.X", + "object-assign": "4.1.0" + }, + "dependencies": { + "object-assign": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.0.tgz", + "integrity": "sha1-ejs9DpgGPUP0wD8uiubNUahog6A=", + "dev": true + } + } + }, + "decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha1-9lNNFRSCabIDUue+4m9QH5oZEpA=", + "dev": true + }, + "decode-uri-component": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.0.tgz", + "integrity": "sha1-6zkTMzRYd1y4TNGh+uBiEGu4dUU=", + "dev": true + }, + "deep-is": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.3.tgz", + "integrity": "sha1-s2nW+128E+7PUk+RsHD+7cNXzzQ=", + "dev": true + }, + "defaults": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/defaults/-/defaults-1.0.3.tgz", + "integrity": "sha1-xlYFHpgX2f8I7YgUd/P+QBnz730=", + "dev": true, + "requires": { + "clone": "^1.0.2" + }, + "dependencies": { + "clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha1-2jCcwmPfFZlMaIypAheco8fNfH4=", + "dev": true + } + } + }, + "define-property": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-2.0.2.tgz", + "integrity": "sha512-jwK2UV4cnPpbcG7+VRARKTZPUWowwXA8bzH5NP6ud0oeAxyYPuGZUAC7hMugpCdz4BeSZl2Dl9k66CHJ/46ZYQ==", + "dev": true, + "requires": { + "is-descriptor": "^1.0.2", + "isobject": "^3.0.1" + }, + "dependencies": { + "is-accessor-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", + "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-data-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", + "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-descriptor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", + "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "^1.0.0", + "is-data-descriptor": "^1.0.0", + "kind-of": "^6.0.2" + } + } + } + }, + "del": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/del/-/del-2.2.2.tgz", + "integrity": "sha1-wSyYHQZ4RshLyvhiz/kw2Qf/0ag=", + "dev": true, + "requires": { + "globby": "^5.0.0", + "is-path-cwd": "^1.0.0", + "is-path-in-cwd": "^1.0.0", + "object-assign": "^4.0.1", + "pify": "^2.0.0", + "pinkie-promise": "^2.0.0", + "rimraf": "^2.2.8" + }, + "dependencies": { + "pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", + "dev": true + } + } + }, + "delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha1-3zrhmayt+31ECqrgsp4icrJOxhk=", + "dev": true + }, + "delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha1-hMbhWbgZBP3KWaDvRM2HDTElD5o=", + "dev": true + }, + "deprecated": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/deprecated/-/deprecated-0.0.1.tgz", + "integrity": "sha1-+cmvVGSvoeepcUWKi97yqpTVuxk=", + "dev": true + }, + "des.js": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/des.js/-/des.js-1.0.0.tgz", + "integrity": "sha1-wHTS4qpqipoH29YfmhXCzYPsjsw=", + "dev": true, + "requires": { + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0" + } + }, + "detect-file": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/detect-file/-/detect-file-1.0.0.tgz", + "integrity": "sha1-8NZtA2cqglyxtzvbP+YjEMjlUrc=", + "dev": true + }, + "detect-indent": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/detect-indent/-/detect-indent-4.0.0.tgz", + "integrity": "sha1-920GQ1LN9Docts5hnE7jqUdd4gg=", + "dev": true, + "requires": { + "repeating": "^2.0.0" + } + }, + "detect-newline": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-2.1.0.tgz", + "integrity": "sha1-9B8cEL5LAOh7XxPaaAdZ8sW/0+I=", + "dev": true + }, + "diffie-hellman": { + "version": "5.0.3", + "resolved": "http://registry.npmjs.org/diffie-hellman/-/diffie-hellman-5.0.3.tgz", + "integrity": "sha512-kqag/Nl+f3GwyK25fhUMYj81BUOrZ9IuJsjIcDE5icNM9FJHAVm3VcUDxdLPoQtTuUylWm6ZIknYJwwaPxsUzg==", + "dev": true, + "requires": { + "bn.js": "^4.1.0", + "miller-rabin": "^4.0.0", + "randombytes": "^2.0.0" + } + }, + "doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "requires": { + "esutils": "^2.0.2" + } + }, + "domain-browser": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/domain-browser/-/domain-browser-1.2.0.tgz", + "integrity": "sha512-jnjyiM6eRyZl2H+W8Q/zLMA481hzi0eszAaBUzIVnmYVDBbnLxVNnfu1HgEBvCbL+71FrxMl3E6lpKH7Ge3OXA==", + "dev": true + }, + "duplexer2": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/duplexer2/-/duplexer2-0.0.2.tgz", + "integrity": "sha1-xhTc9n4vsUmVqRcR5aYX6KYKMds=", + "dev": true, + "requires": { + "readable-stream": "~1.1.9" + }, + "dependencies": { + "isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=", + "dev": true + }, + "readable-stream": { + "version": "1.1.14", + "resolved": "http://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz", + "integrity": "sha1-fPTFTvZI44EwhMY23SB54WbAgdk=", + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.1", + "isarray": "0.0.1", + "string_decoder": "~0.10.x" + } + }, + "string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=", + "dev": true + } + } + }, + "ecc-jsbn": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz", + "integrity": "sha1-OoOpBOVDUyh4dMVkt1SThoSamMk=", + "dev": true, + "optional": true, + "requires": { + "jsbn": "~0.1.0", + "safer-buffer": "^2.1.0" + } + }, + "electron-to-chromium": { + "version": "1.3.70", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.70.tgz", + "integrity": "sha512-WYMjqCnPVS5JA+XvwEnpwucJpVi2+q9cdCFpbhxgWGsCtforFBEkuP9+nCyy/wnU/0SyLcLRIeZct9ayMGcXoQ==", + "dev": true + }, + "elliptic": { + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.4.1.tgz", + "integrity": "sha512-BsXLz5sqX8OHcsh7CqBMztyXARmGQ3LWPtGjJi6DiJHq5C/qvi9P3OqgswKSDftbu8+IoI/QDTAm2fFnQ9SZSQ==", + "dev": true, + "requires": { + "bn.js": "^4.4.0", + "brorand": "^1.0.1", + "hash.js": "^1.0.0", + "hmac-drbg": "^1.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "minimalistic-crypto-utils": "^1.0.0" + } + }, + "emojis-list": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-2.1.0.tgz", + "integrity": "sha1-TapNnbAPmBmIDHn6RXrlsJof04k=", + "dev": true + }, + "end-of-stream": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-0.1.5.tgz", + "integrity": "sha1-jhdyBsPICDfYVjLouTWd/osvbq8=", + "dev": true, + "requires": { + "once": "~1.3.0" + }, + "dependencies": { + "once": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/once/-/once-1.3.3.tgz", + "integrity": "sha1-suJhVXzkwxTsgwTz+oJmPkKXyiA=", + "dev": true, + "requires": { + "wrappy": "1" + } + } + } + }, + "enhanced-resolve": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-3.4.1.tgz", + "integrity": "sha1-BCHjOf1xQZs9oT0Smzl5BAIwR24=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "memory-fs": "^0.4.0", + "object-assign": "^4.0.1", + "tapable": "^0.2.7" + } + }, + "errno": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.7.tgz", + "integrity": "sha512-MfrRBDWzIWifgq6tJj60gkAwtLNb6sQPlcFrSOflcP1aFmmruKQ2wRnze/8V6kgyz7H3FF8Npzv78mZ7XLLflg==", + "dev": true, + "requires": { + "prr": "~1.0.1" + } + }, + "error-ex": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", + "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "dev": true, + "requires": { + "is-arrayish": "^0.2.1" + } + }, + "es5-ext": { + "version": "0.10.46", + "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.46.tgz", + "integrity": "sha512-24XxRvJXNFwEMpJb3nOkiRJKRoupmjYmOPVlI65Qy2SrtxwOTB+g6ODjBKOtwEHbYrhWRty9xxOWLNdClT2djw==", + "dev": true, + "requires": { + "es6-iterator": "~2.0.3", + "es6-symbol": "~3.1.1", + "next-tick": "1" + } + }, + "es6-iterator": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", + "integrity": "sha1-p96IkUGgWpSwhUQDstCg+/qY87c=", + "dev": true, + "requires": { + "d": "1", + "es5-ext": "^0.10.35", + "es6-symbol": "^3.1.1" + } + }, + "es6-map": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/es6-map/-/es6-map-0.1.5.tgz", + "integrity": "sha1-kTbgUD3MBqMBaQ8LsU/042TpSfA=", + "dev": true, + "requires": { + "d": "1", + "es5-ext": "~0.10.14", + "es6-iterator": "~2.0.1", + "es6-set": "~0.1.5", + "es6-symbol": "~3.1.1", + "event-emitter": "~0.3.5" + } + }, + "es6-set": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/es6-set/-/es6-set-0.1.5.tgz", + "integrity": "sha1-0rPsXU2ADO2BjbU40ol02wpzzLE=", + "dev": true, + "requires": { + "d": "1", + "es5-ext": "~0.10.14", + "es6-iterator": "~2.0.1", + "es6-symbol": "3.1.1", + "event-emitter": "~0.3.5" + } + }, + "es6-symbol": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.1.tgz", + "integrity": "sha1-vwDvT9q2uhtG7Le2KbTH7VcVzHc=", + "dev": true, + "requires": { + "d": "1", + "es5-ext": "~0.10.14" + } + }, + "es6-weak-map": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/es6-weak-map/-/es6-weak-map-2.0.2.tgz", + "integrity": "sha1-XjqzIlH/0VOKH45f+hNXdy+S2W8=", + "dev": true, + "requires": { + "d": "1", + "es5-ext": "^0.10.14", + "es6-iterator": "^2.0.1", + "es6-symbol": "^3.1.1" + } + }, + "escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha1-Aljq5NPQwJdN4cFpGI7wBR0dGYg=" + }, + "escape-string-regexp": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz", + "integrity": "sha1-G2HAViGQqN/2rjuyzwIAyhMLhtQ=", + "dev": true + }, + "escope": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/escope/-/escope-3.6.0.tgz", + "integrity": "sha1-4Bl16BJ4GhY6ba392AOY3GTIicM=", + "dev": true, + "requires": { + "es6-map": "^0.1.3", + "es6-weak-map": "^2.0.1", + "esrecurse": "^4.1.0", + "estraverse": "^4.1.1" + } + }, + "eslint": { + "version": "4.19.1", + "resolved": "http://registry.npmjs.org/eslint/-/eslint-4.19.1.tgz", + "integrity": "sha512-bT3/1x1EbZB7phzYu7vCr1v3ONuzDtX8WjuM9c0iYxe+cq+pwcKEoQjl7zd3RpC6YOLgnSy3cTN58M2jcoPDIQ==", + "dev": true, + "requires": { + "ajv": "^5.3.0", + "babel-code-frame": "^6.22.0", + "chalk": "^2.1.0", + "concat-stream": "^1.6.0", + "cross-spawn": "^5.1.0", + "debug": "^3.1.0", + "doctrine": "^2.1.0", + "eslint-scope": "^3.7.1", + "eslint-visitor-keys": "^1.0.0", + "espree": "^3.5.4", + "esquery": "^1.0.0", + "esutils": "^2.0.2", + "file-entry-cache": "^2.0.0", + "functional-red-black-tree": "^1.0.1", + "glob": "^7.1.2", + "globals": "^11.0.1", + "ignore": "^3.3.3", + "imurmurhash": "^0.1.4", + "inquirer": "^3.0.6", + "is-resolvable": "^1.0.0", + "js-yaml": "^3.9.1", + "json-stable-stringify-without-jsonify": "^1.0.1", + "levn": "^0.3.0", + "lodash": "^4.17.4", + "minimatch": "^3.0.2", + "mkdirp": "^0.5.1", + "natural-compare": "^1.4.0", + "optionator": "^0.8.2", + "path-is-inside": "^1.0.2", + "pluralize": "^7.0.0", + "progress": "^2.0.0", + "regexpp": "^1.0.1", + "require-uncached": "^1.0.3", + "semver": "^5.3.0", + "strip-ansi": "^4.0.0", + "strip-json-comments": "~2.0.1", + "table": "4.0.2", + "text-table": "~0.2.0" + }, + "dependencies": { + "ansi-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", + "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=", + "dev": true + }, + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "chalk": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.1.tgz", + "integrity": "sha512-ObN6h1v2fTJSmUXoS3nMQ92LbDK9be4TV+6G+omQlGJFdcUX5heKi1LZ1YnRMIgwTLEj3E24bT6tYni50rlCfQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "debug": { + "version": "3.2.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.5.tgz", + "integrity": "sha512-D61LaDQPQkxJ5AUM2mbSJRbPkNs/TmdmOeLAi1hgDkpDfIfetSrjmWhccwtuResSwMbACjx/xXQofvM9CE/aeg==", + "dev": true, + "requires": { + "ms": "^2.1.1" + } + }, + "globals": { + "version": "11.7.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.7.0.tgz", + "integrity": "sha512-K8BNSPySfeShBQXsahYB/AbbWruVOTyVpgoIDnl8odPpeSfP2J5QO2oLFFdl2j7GfDCtZj2bMKar2T49itTPCg==", + "dev": true + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "dev": true + }, + "ms": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", + "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==", + "dev": true + }, + "strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", + "dev": true, + "requires": { + "ansi-regex": "^3.0.0" + } + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + } + } + }, + "eslint-plugin-compat": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/eslint-plugin-compat/-/eslint-plugin-compat-1.0.4.tgz", + "integrity": "sha512-16yjDdjrivRQT7/Kov+3O6DMvfg8WYC1JKPAsvf/UNtdLBeMXVYATohAM4nOak1ynGP69mKUlOjw7nroUqY9Sg==", + "dev": true, + "requires": { + "babel-runtime": "^6.23.0", + "browserslist": "2.1.4", + "caniuse-db": "1.0.30000671", + "requireindex": "^1.1.0" + }, + "dependencies": { + "browserslist": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-2.1.4.tgz", + "integrity": "sha1-zFJq9KExK30uBWU+VtDIq3DA4FM=", + "dev": true, + "requires": { + "caniuse-lite": "^1.0.30000670", + "electron-to-chromium": "^1.3.11" + } + }, + "caniuse-db": { + "version": "1.0.30000671", + "resolved": "https://registry.npmjs.org/caniuse-db/-/caniuse-db-1.0.30000671.tgz", + "integrity": "sha1-nwcbvHuWmUY4zLr0eCnVihV3qO0=", + "dev": true + } + } + }, + "eslint-scope": { + "version": "3.7.3", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-3.7.3.tgz", + "integrity": "sha512-W+B0SvF4gamyCTmUc+uITPY0989iXVfKvhwtmJocTaYoc/3khEHmEmvfY/Gn9HA9VV75jrQECsHizkNw1b68FA==", + "dev": true, + "requires": { + "esrecurse": "^4.1.0", + "estraverse": "^4.1.1" + } + }, + "eslint-visitor-keys": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-1.0.0.tgz", + "integrity": "sha512-qzm/XxIbxm/FHyH341ZrbnMUpe+5Bocte9xkmFMzPMjRaZMcXww+MpBptFvtU+79L362nqiLhekCxCxDPaUMBQ==", + "dev": true + }, + "espree": { + "version": "3.5.4", + "resolved": "https://registry.npmjs.org/espree/-/espree-3.5.4.tgz", + "integrity": "sha512-yAcIQxtmMiB/jL32dzEp2enBeidsB7xWPLNiw3IIkpVds1P+h7qF9YwJq1yUNzp2OKXgAprs4F61ih66UsoD1A==", + "dev": true, + "requires": { + "acorn": "^5.5.0", + "acorn-jsx": "^3.0.0" + } + }, + "esprima": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "dev": true + }, + "esquery": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.0.1.tgz", + "integrity": "sha512-SmiyZ5zIWH9VM+SRUReLS5Q8a7GxtRdxEBVZpm98rJM7Sb+A9DVCndXfkeFUd3byderg+EbDkfnevfCwynWaNA==", + "dev": true, + "requires": { + "estraverse": "^4.0.0" + } + }, + "esrecurse": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.2.1.tgz", + "integrity": "sha512-64RBB++fIOAXPw3P9cy89qfMlvZEXZkqqJkjqqXIvzP5ezRZjW+lPWjw35UX/3EhUPFYbg5ER4JYgDw4007/DQ==", + "dev": true, + "requires": { + "estraverse": "^4.1.0" + } + }, + "estraverse": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.2.0.tgz", + "integrity": "sha1-De4/7TH81GlhjOc0IJn8GvoL2xM=", + "dev": true + }, + "esutils": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.2.tgz", + "integrity": "sha1-Cr9PHKpbyx96nYrMbepPqqBLrJs=", + "dev": true + }, + "event-emitter": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", + "integrity": "sha1-34xp7vFkeSPHFXuc6DhAYQsCzDk=", + "dev": true, + "requires": { + "d": "1", + "es5-ext": "~0.10.14" + } + }, + "events": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/events/-/events-1.1.1.tgz", + "integrity": "sha1-nr23Y1rQmccNzEwqH1AEKI6L2SQ=", + "dev": true + }, + "evp_bytestokey": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/evp_bytestokey/-/evp_bytestokey-1.0.3.tgz", + "integrity": "sha512-/f2Go4TognH/KvCISP7OUsHn85hT9nUkxxA9BEWxFn+Oj9o8ZNLm/40hdlgSLyuOimsrTKLUMEorQexp/aPQeA==", + "dev": true, + "requires": { + "md5.js": "^1.3.4", + "safe-buffer": "^5.1.1" + } + }, + "execa": { + "version": "0.7.0", + "resolved": "https://registry.npmjs.org/execa/-/execa-0.7.0.tgz", + "integrity": "sha1-lEvs00zEHuMqY6n68nrVpl/Fl3c=", + "dev": true, + "requires": { + "cross-spawn": "^5.0.1", + "get-stream": "^3.0.0", + "is-stream": "^1.1.0", + "npm-run-path": "^2.0.0", + "p-finally": "^1.0.0", + "signal-exit": "^3.0.0", + "strip-eof": "^1.0.0" + } + }, + "exit-hook": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-1.1.1.tgz", + "integrity": "sha1-8FyiM7SMBdVP/wd2XfhQfpXAL/g=", + "dev": true + }, + "expand-brackets": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-2.1.4.tgz", + "integrity": "sha1-t3c14xXOMPa27/D4OwQVGiJEliI=", + "dev": true, + "requires": { + "debug": "^2.3.3", + "define-property": "^0.2.5", + "extend-shallow": "^2.0.1", + "posix-character-classes": "^0.1.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.1" + }, + "dependencies": { + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "requires": { + "is-descriptor": "^0.1.0" + } + }, + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "expand-tilde": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/expand-tilde/-/expand-tilde-2.0.2.tgz", + "integrity": "sha1-l+gBqgUt8CRU3kawK/YhZCzchQI=", + "dev": true, + "requires": { + "homedir-polyfill": "^1.0.1" + } + }, + "exports-loader": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/exports-loader/-/exports-loader-0.6.4.tgz", + "integrity": "sha1-1w/GEhl1s1/BKDDPUnVL4nQPyIY=", + "dev": true, + "requires": { + "loader-utils": "^1.0.2", + "source-map": "0.5.x" + } + }, + "extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "dev": true + }, + "extend-shallow": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-3.0.2.tgz", + "integrity": "sha1-Jqcarwc7OfshJxcnRhMcJwQCjbg=", + "dev": true, + "requires": { + "assign-symbols": "^1.0.0", + "is-extendable": "^1.0.1" + }, + "dependencies": { + "is-extendable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", + "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", + "dev": true, + "requires": { + "is-plain-object": "^2.0.4" + } + } + } + }, + "external-editor": { + "version": "2.2.0", + "resolved": "http://registry.npmjs.org/external-editor/-/external-editor-2.2.0.tgz", + "integrity": "sha512-bSn6gvGxKt+b7+6TKEv1ZycHleA7aHhRHyAqJyp5pbUFuYYNIzpZnQDk7AsYckyWdEnTeAnay0aCy2aV6iTk9A==", + "dev": true, + "requires": { + "chardet": "^0.4.0", + "iconv-lite": "^0.4.17", + "tmp": "^0.0.33" + } + }, + "extglob": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/extglob/-/extglob-2.0.4.tgz", + "integrity": "sha512-Nmb6QXkELsuBr24CJSkilo6UHHgbekK5UiZgfE6UHD3Eb27YC6oD+bhcT+tJ6cl8dmsgdQxnWlcry8ksBIBLpw==", + "dev": true, + "requires": { + "array-unique": "^0.3.2", + "define-property": "^1.0.0", + "expand-brackets": "^2.1.4", + "extend-shallow": "^2.0.1", + "fragment-cache": "^0.2.1", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.1" + }, + "dependencies": { + "define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", + "integrity": "sha1-dp66rz9KY6rTr56NMEybvnm/sOY=", + "dev": true, + "requires": { + "is-descriptor": "^1.0.0" + } + }, + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + }, + "is-accessor-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", + "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-data-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", + "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-descriptor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", + "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "^1.0.0", + "is-data-descriptor": "^1.0.0", + "kind-of": "^6.0.2" + } + } + } + }, + "extsprintf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz", + "integrity": "sha1-lpGEQOMEGnpBT4xS48V06zw+HgU=", + "dev": true + }, + "fancy-log": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/fancy-log/-/fancy-log-1.3.2.tgz", + "integrity": "sha1-9BEl49hPLn2JpD0G2VjI94vha+E=", + "dev": true, + "requires": { + "ansi-gray": "^0.1.1", + "color-support": "^1.1.3", + "time-stamp": "^1.0.0" + } + }, + "fast-deep-equal": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-1.1.0.tgz", + "integrity": "sha1-wFNHeBfIa1HaqFPIHgWbcz0CNhQ=", + "dev": true + }, + "fast-json-stable-stringify": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz", + "integrity": "sha1-1RQsDK7msRifh9OnYREGT4bIu/I=", + "dev": true + }, + "fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=", + "dev": true + }, + "figures": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-2.0.0.tgz", + "integrity": "sha1-OrGi0qYsi/tDGgyUy3l6L84nyWI=", + "dev": true, + "requires": { + "escape-string-regexp": "^1.0.5" + } + }, + "file-entry-cache": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-2.0.0.tgz", + "integrity": "sha1-w5KZDD5oR4PYOLjISkXYoEhFg2E=", + "dev": true, + "requires": { + "flat-cache": "^1.2.1", + "object-assign": "^4.0.1" + } + }, + "fill-range": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz", + "integrity": "sha1-1USBHUKPmOsGpj3EAtJAPDKMOPc=", + "dev": true, + "requires": { + "extend-shallow": "^2.0.1", + "is-number": "^3.0.0", + "repeat-string": "^1.6.1", + "to-regex-range": "^2.1.0" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "find-cache-dir": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-1.0.0.tgz", + "integrity": "sha1-kojj6ePMN0hxfTnq3hfPcfww7m8=", + "dev": true, + "requires": { + "commondir": "^1.0.1", + "make-dir": "^1.0.0", + "pkg-dir": "^2.0.0" + } + }, + "find-index": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/find-index/-/find-index-0.1.1.tgz", + "integrity": "sha1-Z101iyyjiS15Whq0cjL4tuLg3eQ=", + "dev": true + }, + "find-up": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-2.1.0.tgz", + "integrity": "sha1-RdG35QbHF93UgndaK3eSCjwMV6c=", + "dev": true, + "requires": { + "locate-path": "^2.0.0" + } + }, + "findup-sync": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/findup-sync/-/findup-sync-2.0.0.tgz", + "integrity": "sha1-kyaxSIwi0aYIhlCoaQGy2akKLLw=", + "dev": true, + "requires": { + "detect-file": "^1.0.0", + "is-glob": "^3.1.0", + "micromatch": "^3.0.4", + "resolve-dir": "^1.0.1" + } + }, + "fined": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/fined/-/fined-1.1.0.tgz", + "integrity": "sha1-s33IRLdqL15wgeiE98CuNE8VNHY=", + "dev": true, + "requires": { + "expand-tilde": "^2.0.2", + "is-plain-object": "^2.0.3", + "object.defaults": "^1.1.0", + "object.pick": "^1.2.0", + "parse-filepath": "^1.0.1" + } + }, + "first-chunk-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/first-chunk-stream/-/first-chunk-stream-1.0.0.tgz", + "integrity": "sha1-Wb+1DNkF9g18OUzT2ayqtOatk04=", + "dev": true + }, + "flagged-respawn": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/flagged-respawn/-/flagged-respawn-1.0.0.tgz", + "integrity": "sha1-Tnmumy6zi/hrO7Vr8+ClaqX8q9c=", + "dev": true + }, + "flat-cache": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-1.3.0.tgz", + "integrity": "sha1-0wMLMrOBVPTjt+nHCfSQ9++XxIE=", + "dev": true, + "requires": { + "circular-json": "^0.3.1", + "del": "^2.0.2", + "graceful-fs": "^4.1.2", + "write": "^0.2.1" + } + }, + "for-in": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/for-in/-/for-in-1.0.2.tgz", + "integrity": "sha1-gQaNKVqBQuwKxybG4iAMMPttXoA=", + "dev": true + }, + "for-own": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/for-own/-/for-own-1.0.0.tgz", + "integrity": "sha1-xjMy9BXO3EsE2/5wz4NklMU8tEs=", + "dev": true, + "requires": { + "for-in": "^1.0.1" + } + }, + "forever-agent": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", + "integrity": "sha1-+8cfDEGt6zf5bFd60e1C2P2sypE=", + "dev": true + }, + "fork-stream": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/fork-stream/-/fork-stream-0.0.4.tgz", + "integrity": "sha1-24Sfznf2cIpfjzhq5TOgkHtUrnA=", + "dev": true + }, + "form-data": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-2.3.2.tgz", + "integrity": "sha1-SXBJi+YEwgwAXU9cI67NIda0kJk=", + "dev": true, + "requires": { + "asynckit": "^0.4.0", + "combined-stream": "1.0.6", + "mime-types": "^2.1.12" + }, + "dependencies": { + "combined-stream": { + "version": "1.0.6", + "resolved": "http://registry.npmjs.org/combined-stream/-/combined-stream-1.0.6.tgz", + "integrity": "sha1-cj599ugBrFYTETp+RFqbactjKBg=", + "dev": true, + "requires": { + "delayed-stream": "~1.0.0" + } + } + } + }, + "fragment-cache": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/fragment-cache/-/fragment-cache-0.2.1.tgz", + "integrity": "sha1-QpD60n8T6Jvn8zeZxrxaCr//DRk=", + "dev": true, + "requires": { + "map-cache": "^0.2.2" + } + }, + "fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha1-FQStJSMVjKpA20onh8sBQRmU6k8=", + "dev": true + }, + "fsevents": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.4.tgz", + "integrity": "sha512-z8H8/diyk76B7q5wg+Ud0+CqzcAF3mBBI/bA5ne5zrRUUIvNkJY//D3BqyH571KuAC4Nr7Rw7CjWX4r0y9DvNg==", + "dev": true, + "optional": true, + "requires": { + "nan": "^2.9.2", + "node-pre-gyp": "^0.10.0" + }, + "dependencies": { + "abbrev": { + "version": "1.1.1", + "bundled": true, + "dev": true, + "optional": true + }, + "ansi-regex": { + "version": "2.1.1", + "bundled": true, + "dev": true + }, + "aproba": { + "version": "1.2.0", + "bundled": true, + "dev": true, + "optional": true + }, + "are-we-there-yet": { + "version": "1.1.4", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "delegates": "^1.0.0", + "readable-stream": "^2.0.6" + } + }, + "balanced-match": { + "version": "1.0.0", + "bundled": true, + "dev": true + }, + "brace-expansion": { + "version": "1.1.11", + "bundled": true, + "dev": true, + "requires": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "chownr": { + "version": "1.0.1", + "bundled": true, + "dev": true, + "optional": true + }, + "code-point-at": { + "version": "1.1.0", + "bundled": true, + "dev": true + }, + "concat-map": { + "version": "0.0.1", + "bundled": true, + "dev": true + }, + "console-control-strings": { + "version": "1.1.0", + "bundled": true, + "dev": true + }, + "core-util-is": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "debug": { + "version": "2.6.9", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "ms": "2.0.0" + } + }, + "deep-extend": { + "version": "0.5.1", + "bundled": true, + "dev": true, + "optional": true + }, + "delegates": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "detect-libc": { + "version": "1.0.3", + "bundled": true, + "dev": true, + "optional": true + }, + "fs-minipass": { + "version": "1.2.5", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "minipass": "^2.2.1" + } + }, + "fs.realpath": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "gauge": { + "version": "2.7.4", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "aproba": "^1.0.3", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.0", + "object-assign": "^4.1.0", + "signal-exit": "^3.0.0", + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1", + "wide-align": "^1.1.0" + } + }, + "glob": { + "version": "7.1.2", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "has-unicode": { + "version": "2.0.1", + "bundled": true, + "dev": true, + "optional": true + }, + "iconv-lite": { + "version": "0.4.21", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "safer-buffer": "^2.1.0" + } + }, + "ignore-walk": { + "version": "3.0.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "minimatch": "^3.0.4" + } + }, + "inflight": { + "version": "1.0.6", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.3", + "bundled": true, + "dev": true + }, + "ini": { + "version": "1.3.5", + "bundled": true, + "dev": true, + "optional": true + }, + "is-fullwidth-code-point": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "requires": { + "number-is-nan": "^1.0.0" + } + }, + "isarray": { + "version": "1.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "minimatch": { + "version": "3.0.4", + "bundled": true, + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "minimist": { + "version": "0.0.8", + "bundled": true, + "dev": true + }, + "minipass": { + "version": "2.2.4", + "bundled": true, + "dev": true, + "requires": { + "safe-buffer": "^5.1.1", + "yallist": "^3.0.0" + } + }, + "minizlib": { + "version": "1.1.0", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "minipass": "^2.2.1" + } + }, + "mkdirp": { + "version": "0.5.1", + "bundled": true, + "dev": true, + "requires": { + "minimist": "0.0.8" + } + }, + "ms": { + "version": "2.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "needle": { + "version": "2.2.0", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "debug": "^2.1.2", + "iconv-lite": "^0.4.4", + "sax": "^1.2.4" + } + }, + "node-pre-gyp": { + "version": "0.10.0", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "detect-libc": "^1.0.2", + "mkdirp": "^0.5.1", + "needle": "^2.2.0", + "nopt": "^4.0.1", + "npm-packlist": "^1.1.6", + "npmlog": "^4.0.2", + "rc": "^1.1.7", + "rimraf": "^2.6.1", + "semver": "^5.3.0", + "tar": "^4" + } + }, + "nopt": { + "version": "4.0.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "abbrev": "1", + "osenv": "^0.1.4" + } + }, + "npm-bundled": { + "version": "1.0.3", + "bundled": true, + "dev": true, + "optional": true + }, + "npm-packlist": { + "version": "1.1.10", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "ignore-walk": "^3.0.1", + "npm-bundled": "^1.0.1" + } + }, + "npmlog": { + "version": "4.1.2", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "are-we-there-yet": "~1.1.2", + "console-control-strings": "~1.1.0", + "gauge": "~2.7.3", + "set-blocking": "~2.0.0" + } + }, + "number-is-nan": { + "version": "1.0.1", + "bundled": true, + "dev": true + }, + "object-assign": { + "version": "4.1.1", + "bundled": true, + "dev": true, + "optional": true + }, + "once": { + "version": "1.4.0", + "bundled": true, + "dev": true, + "requires": { + "wrappy": "1" + } + }, + "os-homedir": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "os-tmpdir": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "osenv": { + "version": "0.1.5", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "os-homedir": "^1.0.0", + "os-tmpdir": "^1.0.0" + } + }, + "path-is-absolute": { + "version": "1.0.1", + "bundled": true, + "dev": true, + "optional": true + }, + "process-nextick-args": { + "version": "2.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "rc": { + "version": "1.2.7", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "deep-extend": "^0.5.1", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "dependencies": { + "minimist": { + "version": "1.2.0", + "bundled": true, + "dev": true, + "optional": true + } + } + }, + "readable-stream": { + "version": "2.3.6", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "rimraf": { + "version": "2.6.2", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "glob": "^7.0.5" + } + }, + "safe-buffer": { + "version": "5.1.1", + "bundled": true, + "dev": true + }, + "safer-buffer": { + "version": "2.1.2", + "bundled": true, + "dev": true, + "optional": true + }, + "sax": { + "version": "1.2.4", + "bundled": true, + "dev": true, + "optional": true + }, + "semver": { + "version": "5.5.0", + "bundled": true, + "dev": true, + "optional": true + }, + "set-blocking": { + "version": "2.0.0", + "bundled": true, + "dev": true, + "optional": true + }, + "signal-exit": { + "version": "3.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "string-width": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "requires": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "strip-ansi": "^3.0.0" + } + }, + "string_decoder": { + "version": "1.1.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "safe-buffer": "~5.1.0" + } + }, + "strip-ansi": { + "version": "3.0.1", + "bundled": true, + "dev": true, + "requires": { + "ansi-regex": "^2.0.0" + } + }, + "strip-json-comments": { + "version": "2.0.1", + "bundled": true, + "dev": true, + "optional": true + }, + "tar": { + "version": "4.4.1", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "chownr": "^1.0.1", + "fs-minipass": "^1.2.5", + "minipass": "^2.2.4", + "minizlib": "^1.1.0", + "mkdirp": "^0.5.0", + "safe-buffer": "^5.1.1", + "yallist": "^3.0.2" + } + }, + "util-deprecate": { + "version": "1.0.2", + "bundled": true, + "dev": true, + "optional": true + }, + "wide-align": { + "version": "1.1.2", + "bundled": true, + "dev": true, + "optional": true, + "requires": { + "string-width": "^1.0.2" + } + }, + "wrappy": { + "version": "1.0.2", + "bundled": true, + "dev": true + }, + "yallist": { + "version": "3.0.2", + "bundled": true, + "dev": true + } + } + }, + "fstream": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.11.tgz", + "integrity": "sha1-XB+x8RdHcRTwYyoOtLcbPLD9MXE=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "inherits": "~2.0.0", + "mkdirp": ">=0.5 0", + "rimraf": "2" + } + }, + "functional-red-black-tree": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz", + "integrity": "sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=", + "dev": true + }, + "gauge": { + "version": "2.7.4", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-2.7.4.tgz", + "integrity": "sha1-LANAXHU4w51+s3sxcCLjJfsBi/c=", + "dev": true, + "requires": { + "aproba": "^1.0.3", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.0", + "object-assign": "^4.1.0", + "signal-exit": "^3.0.0", + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1", + "wide-align": "^1.1.0" + }, + "dependencies": { + "is-fullwidth-code-point": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", + "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", + "dev": true, + "requires": { + "number-is-nan": "^1.0.0" + } + }, + "string-width": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", + "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", + "dev": true, + "requires": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "strip-ansi": "^3.0.0" + } + } + } + }, + "gaze": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/gaze/-/gaze-0.5.2.tgz", + "integrity": "sha1-QLcJU30k0dRXZ9takIaJ3+aaxE8=", + "dev": true, + "requires": { + "globule": "~0.1.0" + } + }, + "generate-function": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/generate-function/-/generate-function-2.3.1.tgz", + "integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==", + "dev": true, + "requires": { + "is-property": "^1.0.2" + } + }, + "generate-object-property": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/generate-object-property/-/generate-object-property-1.2.0.tgz", + "integrity": "sha1-nA4cQDCM6AT0eDYYuTf6iPmdUNA=", + "dev": true, + "requires": { + "is-property": "^1.0.0" + } + }, + "get-caller-file": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-1.0.3.tgz", + "integrity": "sha512-3t6rVToeoZfYSGd8YoLFR2DJkiQrIiUrGcjvFX2mDw3bn6k2OtwHN0TNCLbBO+w8qTvimhDkv+LSscbJY1vE6w==", + "dev": true + }, + "get-stdin": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-4.0.1.tgz", + "integrity": "sha1-uWjGsKBDhDJJAui/Gl3zJXmkUP4=", + "dev": true + }, + "get-stream": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-3.0.0.tgz", + "integrity": "sha1-jpQ9E1jcN1VQVOy+LtsFqhdO3hQ=", + "dev": true + }, + "get-value": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/get-value/-/get-value-2.0.6.tgz", + "integrity": "sha1-3BXKHGcjh8p2vTesCjlbogQqLCg=", + "dev": true + }, + "getpass": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/getpass/-/getpass-0.1.7.tgz", + "integrity": "sha1-Xv+OPmhNVprkyysSgmBOi6YhSfo=", + "dev": true, + "requires": { + "assert-plus": "^1.0.0" + } + }, + "glob": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.3.tgz", + "integrity": "sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ==", + "dev": true, + "requires": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + } + }, + "glob-parent": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-3.1.0.tgz", + "integrity": "sha1-nmr2KZ2NO9K9QEMIMr0RPfkGxa4=", + "dev": true, + "requires": { + "is-glob": "^3.1.0", + "path-dirname": "^1.0.0" + } + }, + "glob-stream": { + "version": "3.1.18", + "resolved": "https://registry.npmjs.org/glob-stream/-/glob-stream-3.1.18.tgz", + "integrity": "sha1-kXCl8St5Awb9/lmPMT+PeVT9FDs=", + "dev": true, + "requires": { + "glob": "^4.3.1", + "glob2base": "^0.0.12", + "minimatch": "^2.0.1", + "ordered-read-streams": "^0.1.0", + "through2": "^0.6.1", + "unique-stream": "^1.0.0" + }, + "dependencies": { + "glob": { + "version": "4.5.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-4.5.3.tgz", + "integrity": "sha1-xstz0yJsHv7wTePFbQEvAzd+4V8=", + "dev": true, + "requires": { + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^2.0.1", + "once": "^1.3.0" + } + }, + "minimatch": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-2.0.10.tgz", + "integrity": "sha1-jQh8OcazjAAbl/ynzm0OHoCvusc=", + "dev": true, + "requires": { + "brace-expansion": "^1.0.0" + } + } + } + }, + "glob-watcher": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/glob-watcher/-/glob-watcher-0.0.6.tgz", + "integrity": "sha1-uVtKjfdLOcgymLDAXJeLTZo7cQs=", + "dev": true, + "requires": { + "gaze": "^0.5.1" + } + }, + "glob2base": { + "version": "0.0.12", + "resolved": "https://registry.npmjs.org/glob2base/-/glob2base-0.0.12.tgz", + "integrity": "sha1-nUGbPijxLoOjYhZKJ3BVkiycDVY=", + "dev": true, + "requires": { + "find-index": "^0.1.1" + } + }, + "global-modules": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/global-modules/-/global-modules-1.0.0.tgz", + "integrity": "sha512-sKzpEkf11GpOFuw0Zzjzmt4B4UZwjOcG757PPvrfhxcLFbq0wpsgpOqxpxtxFiCG4DtG93M6XRVbF2oGdev7bg==", + "dev": true, + "requires": { + "global-prefix": "^1.0.1", + "is-windows": "^1.0.1", + "resolve-dir": "^1.0.0" + } + }, + "global-prefix": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/global-prefix/-/global-prefix-1.0.2.tgz", + "integrity": "sha1-2/dDxsFJklk8ZVVoy2btMsASLr4=", + "dev": true, + "requires": { + "expand-tilde": "^2.0.2", + "homedir-polyfill": "^1.0.1", + "ini": "^1.3.4", + "is-windows": "^1.0.1", + "which": "^1.2.14" + } + }, + "globals": { + "version": "9.18.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-9.18.0.tgz", + "integrity": "sha512-S0nG3CLEQiY/ILxqtztTWH/3iRRdyBLw6KMDxnKMchrtbj2OFmehVh0WUCfW3DUrIgx/qFrJPICrq4Z4sTR9UQ==", + "dev": true + }, + "globby": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/globby/-/globby-5.0.0.tgz", + "integrity": "sha1-69hGZ8oNuzMLmbz8aOrCvFQ3Dg0=", + "dev": true, + "requires": { + "array-union": "^1.0.1", + "arrify": "^1.0.0", + "glob": "^7.0.3", + "object-assign": "^4.0.1", + "pify": "^2.0.0", + "pinkie-promise": "^2.0.0" + }, + "dependencies": { + "pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", + "dev": true + } + } + }, + "globule": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/globule/-/globule-0.1.0.tgz", + "integrity": "sha1-2cjt3h2nnRJaFRt5UzuXhnY0auU=", + "dev": true, + "requires": { + "glob": "~3.1.21", + "lodash": "~1.0.1", + "minimatch": "~0.2.11" + }, + "dependencies": { + "glob": { + "version": "3.1.21", + "resolved": "https://registry.npmjs.org/glob/-/glob-3.1.21.tgz", + "integrity": "sha1-0p4KBV3qUTj00H7UDomC6DwgZs0=", + "dev": true, + "requires": { + "graceful-fs": "~1.2.0", + "inherits": "1", + "minimatch": "~0.2.11" + } + }, + "graceful-fs": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-1.2.3.tgz", + "integrity": "sha1-FaSAaldUfLLS2/J/QuiajDRRs2Q=", + "dev": true + }, + "inherits": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-1.0.2.tgz", + "integrity": "sha1-ykMJ2t7mtUzAuNJH6NfHoJdb3Js=", + "dev": true + }, + "lodash": { + "version": "1.0.2", + "resolved": "http://registry.npmjs.org/lodash/-/lodash-1.0.2.tgz", + "integrity": "sha1-j1dWDIO1n8JwvT1WG2kAQ0MOJVE=", + "dev": true + }, + "lru-cache": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-2.7.3.tgz", + "integrity": "sha1-bUUk6LlV+V1PW1iFHOId1y+06VI=", + "dev": true + }, + "minimatch": { + "version": "0.2.14", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-0.2.14.tgz", + "integrity": "sha1-x054BXT2PG+aCQ6Q775u9TpqdWo=", + "dev": true, + "requires": { + "lru-cache": "2", + "sigmund": "~1.0.0" + } + } + } + }, + "glogg": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/glogg/-/glogg-1.0.1.tgz", + "integrity": "sha512-ynYqXLoluBKf9XGR1gA59yEJisIL7YHEH4xr3ZziHB5/yl4qWfaK8Js9jGe6gBGCSCKVqiyO30WnRZADvemUNw==", + "dev": true, + "requires": { + "sparkles": "^1.0.0" + } + }, + "graceful-fs": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.1.11.tgz", + "integrity": "sha1-Dovf5NHduIVNZOBOp8AOKgJuVlg=", + "dev": true + }, + "gulp": { + "version": "3.9.1", + "resolved": "https://registry.npmjs.org/gulp/-/gulp-3.9.1.tgz", + "integrity": "sha1-VxzkWSjdQK9lFPxAEYZgFsE4RbQ=", + "dev": true, + "requires": { + "archy": "^1.0.0", + "chalk": "^1.0.0", + "deprecated": "^0.0.1", + "gulp-util": "^3.0.0", + "interpret": "^1.0.0", + "liftoff": "^2.1.0", + "minimist": "^1.1.0", + "orchestrator": "^0.3.0", + "pretty-hrtime": "^1.0.0", + "semver": "^4.1.0", + "tildify": "^1.0.0", + "v8flags": "^2.0.2", + "vinyl-fs": "^0.3.0" + }, + "dependencies": { + "semver": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/semver/-/semver-4.3.6.tgz", + "integrity": "sha1-MAvG4OhjdPe6YQaLWx7NV/xlMto=", + "dev": true + } + } + }, + "gulp-clean-css": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/gulp-clean-css/-/gulp-clean-css-3.10.0.tgz", + "integrity": "sha512-7Isf9Y690o/Q5MVjEylH1H7L8WeZ89woW7DnhD5unTintOdZb67KdOayRgp9trUFo+f9UyJtuatV42e/+kghPg==", + "dev": true, + "requires": { + "clean-css": "4.2.1", + "plugin-error": "1.0.1", + "through2": "2.0.3", + "vinyl-sourcemaps-apply": "0.2.1" + }, + "dependencies": { + "through2": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.3.tgz", + "integrity": "sha1-AARWmzfHx0ujnEPzzteNGtlBQL4=", + "dev": true, + "requires": { + "readable-stream": "^2.1.5", + "xtend": "~4.0.1" + } + } + } + }, + "gulp-eslint": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/gulp-eslint/-/gulp-eslint-3.0.1.tgz", + "integrity": "sha1-BOV+PhjGl0JnwSz2hV3HF9SjE70=", + "dev": true, + "requires": { + "bufferstreams": "^1.1.1", + "eslint": "^3.0.0", + "gulp-util": "^3.0.6" + }, + "dependencies": { + "ajv": { + "version": "4.11.8", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-4.11.8.tgz", + "integrity": "sha1-gv+wKynmYq5TvcIK8VlHcGc5xTY=", + "dev": true, + "requires": { + "co": "^4.6.0", + "json-stable-stringify": "^1.0.1" + } + }, + "ajv-keywords": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-1.5.1.tgz", + "integrity": "sha1-MU3QpLM2j609/NxU7eYXG4htrzw=", + "dev": true + }, + "ansi-escapes": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-1.4.0.tgz", + "integrity": "sha1-06ioOzGapneTZisT52HHkRQiMG4=", + "dev": true + }, + "cli-cursor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-1.0.2.tgz", + "integrity": "sha1-ZNo/fValRBLll5S9Ytw1KV6PKYc=", + "dev": true, + "requires": { + "restore-cursor": "^1.0.1" + } + }, + "clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha1-2jCcwmPfFZlMaIypAheco8fNfH4=", + "dev": true + }, + "dateformat": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-2.2.0.tgz", + "integrity": "sha1-QGXiATz5+5Ft39gu+1Bq1MZ2kGI=", + "dev": true + }, + "eslint": { + "version": "3.19.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-3.19.0.tgz", + "integrity": "sha1-yPxiAcf0DdCJQbh8CFdnOGpnmsw=", + "dev": true, + "requires": { + "babel-code-frame": "^6.16.0", + "chalk": "^1.1.3", + "concat-stream": "^1.5.2", + "debug": "^2.1.1", + "doctrine": "^2.0.0", + "escope": "^3.6.0", + "espree": "^3.4.0", + "esquery": "^1.0.0", + "estraverse": "^4.2.0", + "esutils": "^2.0.2", + "file-entry-cache": "^2.0.0", + "glob": "^7.0.3", + "globals": "^9.14.0", + "ignore": "^3.2.0", + "imurmurhash": "^0.1.4", + "inquirer": "^0.12.0", + "is-my-json-valid": "^2.10.0", + "is-resolvable": "^1.0.0", + "js-yaml": "^3.5.1", + "json-stable-stringify": "^1.0.0", + "levn": "^0.3.0", + "lodash": "^4.0.0", + "mkdirp": "^0.5.0", + "natural-compare": "^1.4.0", + "optionator": "^0.8.2", + "path-is-inside": "^1.0.1", + "pluralize": "^1.2.1", + "progress": "^1.1.8", + "require-uncached": "^1.0.2", + "shelljs": "^0.7.5", + "strip-bom": "^3.0.0", + "strip-json-comments": "~2.0.1", + "table": "^3.7.8", + "text-table": "~0.2.0", + "user-home": "^2.0.0" + } + }, + "figures": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/figures/-/figures-1.7.0.tgz", + "integrity": "sha1-y+Hjr/zxzUS4DK3+0o3Hk6lwHS4=", + "dev": true, + "requires": { + "escape-string-regexp": "^1.0.5", + "object-assign": "^4.1.0" + } + }, + "gulp-util": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/gulp-util/-/gulp-util-3.0.8.tgz", + "integrity": "sha1-AFTh50RQLifATBh8PsxQXdVLu08=", + "dev": true, + "requires": { + "array-differ": "^1.0.0", + "array-uniq": "^1.0.2", + "beeper": "^1.0.0", + "chalk": "^1.0.0", + "dateformat": "^2.0.0", + "fancy-log": "^1.1.0", + "gulplog": "^1.0.0", + "has-gulplog": "^0.1.0", + "lodash._reescape": "^3.0.0", + "lodash._reevaluate": "^3.0.0", + "lodash._reinterpolate": "^3.0.0", + "lodash.template": "^3.0.0", + "minimist": "^1.1.0", + "multipipe": "^0.1.2", + "object-assign": "^3.0.0", + "replace-ext": "0.0.1", + "through2": "^2.0.0", + "vinyl": "^0.5.0" + }, + "dependencies": { + "object-assign": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-3.0.0.tgz", + "integrity": "sha1-m+3VygiXlJvKR+f/QIBi1Un1h/I=", + "dev": true + } + } + }, + "inquirer": { + "version": "0.12.0", + "resolved": "http://registry.npmjs.org/inquirer/-/inquirer-0.12.0.tgz", + "integrity": "sha1-HvK/1jUE3wvHV4X/+MLEHfEvB34=", + "dev": true, + "requires": { + "ansi-escapes": "^1.1.0", + "ansi-regex": "^2.0.0", + "chalk": "^1.0.0", + "cli-cursor": "^1.0.1", + "cli-width": "^2.0.0", + "figures": "^1.3.5", + "lodash": "^4.3.0", + "readline2": "^1.0.1", + "run-async": "^0.1.0", + "rx-lite": "^3.1.2", + "string-width": "^1.0.1", + "strip-ansi": "^3.0.0", + "through": "^2.3.6" + } + }, + "is-fullwidth-code-point": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", + "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", + "dev": true, + "requires": { + "number-is-nan": "^1.0.0" + } + }, + "onetime": { + "version": "1.1.0", + "resolved": "http://registry.npmjs.org/onetime/-/onetime-1.1.0.tgz", + "integrity": "sha1-ofeDj4MUxRbwXs78vEzP4EtO14k=", + "dev": true + }, + "pluralize": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-1.2.1.tgz", + "integrity": "sha1-0aIUg/0iu0HlihL6NCGCMUCJfEU=", + "dev": true + }, + "progress": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/progress/-/progress-1.1.8.tgz", + "integrity": "sha1-4mDHj2Fhzdmw5WzD4Khd4Xx6V74=", + "dev": true + }, + "restore-cursor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-1.0.1.tgz", + "integrity": "sha1-NGYfRohjJ/7SmRR5FSJS35LapUE=", + "dev": true, + "requires": { + "exit-hook": "^1.0.0", + "onetime": "^1.0.0" + } + }, + "run-async": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/run-async/-/run-async-0.1.0.tgz", + "integrity": "sha1-yK1KXhEGYeQCp9IbUw4AnyX444k=", + "dev": true, + "requires": { + "once": "^1.3.0" + } + }, + "rx-lite": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/rx-lite/-/rx-lite-3.1.2.tgz", + "integrity": "sha1-Gc5QLKVyZl87ZHsQk5+X/RYV8QI=", + "dev": true + }, + "slice-ansi": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-0.0.4.tgz", + "integrity": "sha1-7b+JA/ZvfOL46v1s7tZeJkyDGzU=", + "dev": true + }, + "string-width": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", + "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", + "dev": true, + "requires": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "strip-ansi": "^3.0.0" + } + }, + "strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=", + "dev": true + }, + "table": { + "version": "3.8.3", + "resolved": "http://registry.npmjs.org/table/-/table-3.8.3.tgz", + "integrity": "sha1-K7xULw/amGGnVdOUf+/Ys/UThV8=", + "dev": true, + "requires": { + "ajv": "^4.7.0", + "ajv-keywords": "^1.0.0", + "chalk": "^1.1.1", + "lodash": "^4.0.0", + "slice-ansi": "0.0.4", + "string-width": "^2.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", + "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=", + "dev": true + }, + "is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", + "dev": true + }, + "string-width": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", + "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==", + "dev": true, + "requires": { + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^4.0.0" + } + }, + "strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", + "dev": true, + "requires": { + "ansi-regex": "^3.0.0" + } + } + } + }, + "through2": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.3.tgz", + "integrity": "sha1-AARWmzfHx0ujnEPzzteNGtlBQL4=", + "dev": true, + "requires": { + "readable-stream": "^2.1.5", + "xtend": "~4.0.1" + } + }, + "user-home": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/user-home/-/user-home-2.0.0.tgz", + "integrity": "sha1-nHC/2Babwdy/SGBODwS4tJzenp8=", + "dev": true, + "requires": { + "os-homedir": "^1.0.0" + } + }, + "vinyl": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-0.5.3.tgz", + "integrity": "sha1-sEVbOPxeDPMNQyUTLkYZcMIJHN4=", + "dev": true, + "requires": { + "clone": "^1.0.0", + "clone-stats": "^0.0.1", + "replace-ext": "0.0.1" + } + } + } + }, + "gulp-if": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/gulp-if/-/gulp-if-1.2.5.tgz", + "integrity": "sha1-m9nBYDLswo4BVL+wWCjSMxZvLak=", + "dev": true, + "requires": { + "gulp-match": "~0.2.1", + "ternary-stream": "^1.2.0", + "through2": "~0.6.2" + } + }, + "gulp-match": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/gulp-match/-/gulp-match-0.2.1.tgz", + "integrity": "sha1-C+0I2ovW6JaG+J/7AEM3+LrQbSI=", + "dev": true, + "requires": { + "minimatch": "^1.0.0" + }, + "dependencies": { + "lru-cache": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-2.7.3.tgz", + "integrity": "sha1-bUUk6LlV+V1PW1iFHOId1y+06VI=", + "dev": true + }, + "minimatch": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-1.0.0.tgz", + "integrity": "sha1-4N0hILSeG3JM6NcUxSCCKpQ4V20=", + "dev": true, + "requires": { + "lru-cache": "2", + "sigmund": "~1.0.0" + } + } + } + }, + "gulp-plumber": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gulp-plumber/-/gulp-plumber-1.2.0.tgz", + "integrity": "sha512-L/LJftsbKoHbVj6dN5pvMsyJn9jYI0wT0nMg3G6VZhDac4NesezecYTi8/48rHi+yEic3sUpw6jlSc7qNWh32A==", + "dev": true, + "requires": { + "chalk": "^1.1.3", + "fancy-log": "^1.3.2", + "plugin-error": "^0.1.2", + "through2": "^2.0.3" + }, + "dependencies": { + "arr-diff": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/arr-diff/-/arr-diff-1.1.0.tgz", + "integrity": "sha1-aHwydYFjWI/vfeezb6vklesaOZo=", + "dev": true, + "requires": { + "arr-flatten": "^1.0.1", + "array-slice": "^0.2.3" + } + }, + "arr-union": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/arr-union/-/arr-union-2.1.0.tgz", + "integrity": "sha1-IPnqtexw9cfSFbEHexw5Fh0pLH0=", + "dev": true + }, + "array-slice": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/array-slice/-/array-slice-0.2.3.tgz", + "integrity": "sha1-3Tz7gO15c6dRF82sabC5nshhhvU=", + "dev": true + }, + "extend-shallow": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-1.1.4.tgz", + "integrity": "sha1-Gda/lN/AnXa6cR85uHLSH/TdkHE=", + "dev": true, + "requires": { + "kind-of": "^1.1.0" + } + }, + "kind-of": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-1.1.0.tgz", + "integrity": "sha1-FAo9LUGjbS78+pN3tiwk+ElaXEQ=", + "dev": true + }, + "plugin-error": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/plugin-error/-/plugin-error-0.1.2.tgz", + "integrity": "sha1-O5uzM1zPAPQl4HQ34ZJ2ln2kes4=", + "dev": true, + "requires": { + "ansi-cyan": "^0.1.1", + "ansi-red": "^0.1.1", + "arr-diff": "^1.0.1", + "arr-union": "^2.0.1", + "extend-shallow": "^1.1.2" + } + }, + "through2": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.3.tgz", + "integrity": "sha1-AARWmzfHx0ujnEPzzteNGtlBQL4=", + "dev": true, + "requires": { + "readable-stream": "^2.1.5", + "xtend": "~4.0.1" + } + } + } + }, + "gulp-postcss": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/gulp-postcss/-/gulp-postcss-6.4.0.tgz", + "integrity": "sha1-eKMuPIeqbNzsWuHJBeGW1HjoxdU=", + "dev": true, + "requires": { + "gulp-util": "^3.0.8", + "postcss": "^5.2.12", + "postcss-load-config": "^1.2.0", + "vinyl-sourcemaps-apply": "^0.2.1" + }, + "dependencies": { + "clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha1-2jCcwmPfFZlMaIypAheco8fNfH4=", + "dev": true + }, + "dateformat": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-2.2.0.tgz", + "integrity": "sha1-QGXiATz5+5Ft39gu+1Bq1MZ2kGI=", + "dev": true + }, + "gulp-util": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/gulp-util/-/gulp-util-3.0.8.tgz", + "integrity": "sha1-AFTh50RQLifATBh8PsxQXdVLu08=", + "dev": true, + "requires": { + "array-differ": "^1.0.0", + "array-uniq": "^1.0.2", + "beeper": "^1.0.0", + "chalk": "^1.0.0", + "dateformat": "^2.0.0", + "fancy-log": "^1.1.0", + "gulplog": "^1.0.0", + "has-gulplog": "^0.1.0", + "lodash._reescape": "^3.0.0", + "lodash._reevaluate": "^3.0.0", + "lodash._reinterpolate": "^3.0.0", + "lodash.template": "^3.0.0", + "minimist": "^1.1.0", + "multipipe": "^0.1.2", + "object-assign": "^3.0.0", + "replace-ext": "0.0.1", + "through2": "^2.0.0", + "vinyl": "^0.5.0" + } + }, + "object-assign": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-3.0.0.tgz", + "integrity": "sha1-m+3VygiXlJvKR+f/QIBi1Un1h/I=", + "dev": true + }, + "through2": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.3.tgz", + "integrity": "sha1-AARWmzfHx0ujnEPzzteNGtlBQL4=", + "dev": true, + "requires": { + "readable-stream": "^2.1.5", + "xtend": "~4.0.1" + } + }, + "vinyl": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-0.5.3.tgz", + "integrity": "sha1-sEVbOPxeDPMNQyUTLkYZcMIJHN4=", + "dev": true, + "requires": { + "clone": "^1.0.0", + "clone-stats": "^0.0.1", + "replace-ext": "0.0.1" + } + } + } + }, + "gulp-sass": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/gulp-sass/-/gulp-sass-3.1.0.tgz", + "integrity": "sha1-U9xLaKH13f5EJKtMJHZVJpqLdLc=", + "dev": true, + "requires": { + "gulp-util": "^3.0", + "lodash.clonedeep": "^4.3.2", + "node-sass": "^4.2.0", + "through2": "^2.0.0", + "vinyl-sourcemaps-apply": "^0.2.0" + }, + "dependencies": { + "through2": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.3.tgz", + "integrity": "sha1-AARWmzfHx0ujnEPzzteNGtlBQL4=", + "dev": true, + "requires": { + "readable-stream": "^2.1.5", + "xtend": "~4.0.1" + } + } + } + }, + "gulp-sourcemaps": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/gulp-sourcemaps/-/gulp-sourcemaps-2.4.1.tgz", + "integrity": "sha1-j2XcXA0Hsv1ciLxg7H8T5WcWv3Q=", + "dev": true, + "requires": { + "acorn": "4.X", + "convert-source-map": "1.X", + "css": "2.X", + "debug-fabulous": "0.0.X", + "detect-newline": "2.X", + "graceful-fs": "4.X", + "source-map": "0.X", + "strip-bom": "3.X", + "through2": "2.X", + "vinyl": "1.X" + }, + "dependencies": { + "acorn": { + "version": "4.0.13", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-4.0.13.tgz", + "integrity": "sha1-EFSVrlNh1pe9GVyCUZLhrX8lN4c=", + "dev": true + }, + "clone": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz", + "integrity": "sha1-2jCcwmPfFZlMaIypAheco8fNfH4=", + "dev": true + }, + "strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=", + "dev": true + }, + "through2": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/through2/-/through2-2.0.3.tgz", + "integrity": "sha1-AARWmzfHx0ujnEPzzteNGtlBQL4=", + "dev": true, + "requires": { + "readable-stream": "^2.1.5", + "xtend": "~4.0.1" + } + }, + "vinyl": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-1.2.0.tgz", + "integrity": "sha1-XIgDbPVl5d8FVYv8kR+GVt8hiIQ=", + "dev": true, + "requires": { + "clone": "^1.0.0", + "clone-stats": "^0.0.1", + "replace-ext": "0.0.1" + } + } + } + }, + "gulp-util": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/gulp-util/-/gulp-util-3.0.5.tgz", + "integrity": "sha1-LJ7qP/WGs/UXc8AtdMiCcTR9oCk=", + "dev": true, + "requires": { + "array-differ": "^1.0.0", + "array-uniq": "^1.0.2", + "beeper": "^1.0.0", + "chalk": "^1.0.0", + "dateformat": "^1.0.11", + "lodash._reescape": "^3.0.0", + "lodash._reevaluate": "^3.0.0", + "lodash._reinterpolate": "^3.0.0", + "lodash.template": "^3.0.0", + "minimist": "^1.1.0", + "multipipe": "^0.1.2", + "object-assign": "^2.0.0", + "replace-ext": "0.0.1", + "through2": "^0.6.3", + "vinyl": "^0.4.3" + }, + "dependencies": { + "object-assign": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-2.1.1.tgz", + "integrity": "sha1-Q8NuXVaf+OSBbE76i+AtJpZ8GKo=", + "dev": true + } + } + }, + "gulplog": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/gulplog/-/gulplog-1.0.0.tgz", + "integrity": "sha1-4oxNRdBey77YGDY86PnFkmIp/+U=", + "dev": true, + "requires": { + "glogg": "^1.0.0" + } + }, + "har-schema": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/har-schema/-/har-schema-2.0.0.tgz", + "integrity": "sha1-qUwiJOvKwEeCoNkDVSHyRzW37JI=", + "dev": true + }, + "har-validator": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.0.3.tgz", + "integrity": "sha1-ukAsJmGU8VlW7xXg/PJCmT9qff0=", + "dev": true, + "requires": { + "ajv": "^5.1.0", + "har-schema": "^2.0.0" + } + }, + "has-ansi": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/has-ansi/-/has-ansi-2.0.0.tgz", + "integrity": "sha1-NPUEnOHs3ysGSa8+8k5F7TVBbZE=", + "dev": true, + "requires": { + "ansi-regex": "^2.0.0" + } + }, + "has-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-1.0.0.tgz", + "integrity": "sha1-nZ55MWXOAXoA8AQYxD+UKnsdEfo=", + "dev": true + }, + "has-gulplog": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/has-gulplog/-/has-gulplog-0.1.0.tgz", + "integrity": "sha1-ZBTIKRNpfaUVkDl9r7EvIpZ4Ec4=", + "dev": true, + "requires": { + "sparkles": "^1.0.0" + } + }, + "has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha1-4Ob+aijPUROIVeCG0Wkedx3iqLk=", + "dev": true + }, + "has-value": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-value/-/has-value-1.0.0.tgz", + "integrity": "sha1-GLKB2lhbHFxR3vJMkw7SmgvmsXc=", + "dev": true, + "requires": { + "get-value": "^2.0.6", + "has-values": "^1.0.0", + "isobject": "^3.0.0" + } + }, + "has-values": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/has-values/-/has-values-1.0.0.tgz", + "integrity": "sha1-lbC2P+whRmGab+V/51Yo1aOe/k8=", + "dev": true, + "requires": { + "is-number": "^3.0.0", + "kind-of": "^4.0.0" + }, + "dependencies": { + "kind-of": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-4.0.0.tgz", + "integrity": "sha1-IIE989cSkosgc3hpGkUGb65y3Vc=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "hash-base": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/hash-base/-/hash-base-3.0.4.tgz", + "integrity": "sha1-X8hoaEfs1zSZQDMZprCj8/auSRg=", + "dev": true, + "requires": { + "inherits": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "hash.js": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/hash.js/-/hash.js-1.1.5.tgz", + "integrity": "sha512-eWI5HG9Np+eHV1KQhisXWwM+4EPPYe5dFX1UZZH7k/E3JzDEazVH+VGlZi6R94ZqImq+A3D1mCEtrFIfg/E7sA==", + "dev": true, + "requires": { + "inherits": "^2.0.3", + "minimalistic-assert": "^1.0.1" + } + }, + "hmac-drbg": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/hmac-drbg/-/hmac-drbg-1.0.1.tgz", + "integrity": "sha1-0nRXAQJabHdabFRXk+1QL8DGSaE=", + "dev": true, + "requires": { + "hash.js": "^1.0.3", + "minimalistic-assert": "^1.0.0", + "minimalistic-crypto-utils": "^1.0.1" + } + }, + "home-or-tmp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/home-or-tmp/-/home-or-tmp-2.0.0.tgz", + "integrity": "sha1-42w/LSyufXRqhX440Y1fMqeILbg=", + "dev": true, + "requires": { + "os-homedir": "^1.0.0", + "os-tmpdir": "^1.0.1" + } + }, + "homedir-polyfill": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/homedir-polyfill/-/homedir-polyfill-1.0.1.tgz", + "integrity": "sha1-TCu8inWJmP7r9e1oWA921GdotLw=", + "dev": true, + "requires": { + "parse-passwd": "^1.0.0" + } + }, + "hosted-git-info": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-2.7.1.tgz", + "integrity": "sha512-7T/BxH19zbcCTa8XkMlbK5lTo1WtgkFi3GvdWEyNuc4Vex7/9Dqbnpsf4JMydcfj9HCg4zUWFTL3Za6lapg5/w==", + "dev": true + }, + "http-signature": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/http-signature/-/http-signature-1.2.0.tgz", + "integrity": "sha1-muzZJRFHcvPZW2WmCruPfBj7rOE=", + "dev": true, + "requires": { + "assert-plus": "^1.0.0", + "jsprim": "^1.2.2", + "sshpk": "^1.7.0" + } + }, + "https-browserify": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/https-browserify/-/https-browserify-1.0.0.tgz", + "integrity": "sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM=", + "dev": true + }, + "iconv-lite": { + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", + "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "dev": true, + "requires": { + "safer-buffer": ">= 2.1.2 < 3" + } + }, + "ieee754": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.12.tgz", + "integrity": "sha512-GguP+DRY+pJ3soyIiGPTvdiVXjZ+DbXOxGpXn3eMvNW4x4irjqXm4wHKscC+TfxSJ0yw/S1F24tqdMNsMZTiLA==", + "dev": true + }, + "ignore": { + "version": "3.3.10", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-3.3.10.tgz", + "integrity": "sha512-Pgs951kaMm5GXP7MOvxERINe3gsaVjUWFm+UZPSq9xYriQAksyhg0csnS0KXSNRD5NmNdapXEpjxG49+AKh/ug==", + "dev": true + }, + "imports-loader": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/imports-loader/-/imports-loader-0.7.1.tgz", + "integrity": "sha1-8gS180cCoywdt9SNidXoZ6BEElM=", + "dev": true, + "requires": { + "loader-utils": "^1.0.2", + "source-map": "^0.5.6" + } + }, + "imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha1-khi5srkoojixPcT7a21XbyMUU+o=", + "dev": true + }, + "in-publish": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/in-publish/-/in-publish-2.0.0.tgz", + "integrity": "sha1-4g/146KvwmkDILbcVSaCqcf631E=", + "dev": true + }, + "indent-string": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-2.1.0.tgz", + "integrity": "sha1-ji1INIdCEhtKghi3oTfppSBJ3IA=", + "dev": true, + "requires": { + "repeating": "^2.0.0" + } + }, + "indexof": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/indexof/-/indexof-0.0.1.tgz", + "integrity": "sha1-gtwzbSMrkGIXnQWrMpOmYFn9Q10=", + "dev": true + }, + "inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha1-Sb1jMdfQLQwJvJEKEHW6gWW1bfk=", + "dev": true, + "requires": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "inherits": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.3.tgz", + "integrity": "sha1-Yzwsg+PaQqUC9SRmAiSA9CCCYd4=", + "dev": true + }, + "ini": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.5.tgz", + "integrity": "sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==", + "dev": true + }, + "inquirer": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-3.3.0.tgz", + "integrity": "sha512-h+xtnyk4EwKvFWHrUYsWErEVR+igKtLdchu+o0Z1RL7VU/jVMFbYir2bp6bAj8efFNxWqHX0dIss6fJQ+/+qeQ==", + "dev": true, + "requires": { + "ansi-escapes": "^3.0.0", + "chalk": "^2.0.0", + "cli-cursor": "^2.1.0", + "cli-width": "^2.0.0", + "external-editor": "^2.0.4", + "figures": "^2.0.0", + "lodash": "^4.3.0", + "mute-stream": "0.0.7", + "run-async": "^2.2.0", + "rx-lite": "^4.0.8", + "rx-lite-aggregates": "^4.0.8", + "string-width": "^2.1.0", + "strip-ansi": "^4.0.0", + "through": "^2.3.6" + }, + "dependencies": { + "ansi-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", + "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=", + "dev": true + }, + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "chalk": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.1.tgz", + "integrity": "sha512-ObN6h1v2fTJSmUXoS3nMQ92LbDK9be4TV+6G+omQlGJFdcUX5heKi1LZ1YnRMIgwTLEj3E24bT6tYni50rlCfQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "dev": true + }, + "strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", + "dev": true, + "requires": { + "ansi-regex": "^3.0.0" + } + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + } + } + }, + "interpret": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/interpret/-/interpret-1.1.0.tgz", + "integrity": "sha1-ftGxQQxqDg94z5XTuEQMY/eLhhQ=", + "dev": true + }, + "invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "dev": true, + "requires": { + "loose-envify": "^1.0.0" + } + }, + "invert-kv": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/invert-kv/-/invert-kv-1.0.0.tgz", + "integrity": "sha1-EEqOSqym09jNFXqO+L+rLXo//bY=", + "dev": true + }, + "is-absolute": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-absolute/-/is-absolute-1.0.0.tgz", + "integrity": "sha512-dOWoqflvcydARa360Gvv18DZ/gRuHKi2NU/wU5X1ZFzdYfH29nkiNZsF3mp4OJ3H4yo9Mx8A/uAGNzpzPN3yBA==", + "dev": true, + "requires": { + "is-relative": "^1.0.0", + "is-windows": "^1.0.1" + } + }, + "is-accessor-descriptor": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-0.1.6.tgz", + "integrity": "sha1-qeEss66Nh2cn7u84Q/igiXtcmNY=", + "dev": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha1-d8mYQFJ6qOyxqLppe4BkWnqSap0=", + "dev": true + }, + "is-binary-path": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-1.0.1.tgz", + "integrity": "sha1-dfFmQrSA8YenEcgUFh/TpKdlWJg=", + "dev": true, + "requires": { + "binary-extensions": "^1.0.0" + } + }, + "is-buffer": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/is-buffer/-/is-buffer-1.1.6.tgz", + "integrity": "sha512-NcdALwpXkTm5Zvvbk7owOUSvVvBKDgKP5/ewfXEznmQFfs4ZRmanOeKBTjRVjka3QFoN6XJ+9F3USqfHqTaU5w==", + "dev": true + }, + "is-builtin-module": { + "version": "1.0.0", + "resolved": "http://registry.npmjs.org/is-builtin-module/-/is-builtin-module-1.0.0.tgz", + "integrity": "sha1-VAVy0096wxGfj3bDDLwbHgN6/74=", + "dev": true, + "requires": { + "builtin-modules": "^1.0.0" + } + }, + "is-data-descriptor": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-0.1.4.tgz", + "integrity": "sha1-C17mSDiOLIYCgueT8YVv7D8wG1Y=", + "dev": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "is-descriptor": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-0.1.6.tgz", + "integrity": "sha512-avDYr0SB3DwO9zsMov0gKCESFYqCnE4hq/4z3TdUlukEy5t9C0YRq7HLrsN52NAcqXKaepeCD0n+B0arnVG3Hg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "^0.1.6", + "is-data-descriptor": "^0.1.4", + "kind-of": "^5.0.0" + }, + "dependencies": { + "kind-of": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-5.1.0.tgz", + "integrity": "sha512-NGEErnH6F2vUuXDh+OlbcKW7/wOcfdRHaZ7VWtqCztfHri/++YKmP51OdWeGPuqCOba6kk2OTe5d02VmTB80Pw==", + "dev": true + } + } + }, + "is-directory": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/is-directory/-/is-directory-0.3.1.tgz", + "integrity": "sha1-YTObbyR1/Hcv2cnYP1yFddwVSuE=", + "dev": true + }, + "is-extendable": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz", + "integrity": "sha1-YrEQ4omkcUGOPsNqYX1HLjAd/Ik=", + "dev": true + }, + "is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=", + "dev": true + }, + "is-finite": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-finite/-/is-finite-1.0.2.tgz", + "integrity": "sha1-zGZ3aVYCvlUO8R6LSqYwU0K20Ko=", + "dev": true, + "requires": { + "number-is-nan": "^1.0.0" + } + }, + "is-fullwidth-code-point": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-2.0.0.tgz", + "integrity": "sha1-o7MKXE8ZkYMWeqq5O+764937ZU8=", + "dev": true + }, + "is-glob": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-3.1.0.tgz", + "integrity": "sha1-e6WuJCF4BKxwcHuWkiVnSGzD6Eo=", + "dev": true, + "requires": { + "is-extglob": "^2.1.0" + } + }, + "is-my-ip-valid": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-my-ip-valid/-/is-my-ip-valid-1.0.0.tgz", + "integrity": "sha512-gmh/eWXROncUzRnIa1Ubrt5b8ep/MGSnfAUI3aRp+sqTCs1tv1Isl8d8F6JmkN3dXKc3ehZMrtiPN9eL03NuaQ==", + "dev": true + }, + "is-my-json-valid": { + "version": "2.19.0", + "resolved": "https://registry.npmjs.org/is-my-json-valid/-/is-my-json-valid-2.19.0.tgz", + "integrity": "sha512-mG0f/unGX1HZ5ep4uhRaPOS8EkAY8/j6mDRMJrutq4CqhoJWYp7qAlonIPy3TV7p3ju4TK9fo/PbnoksWmsp5Q==", + "dev": true, + "requires": { + "generate-function": "^2.0.0", + "generate-object-property": "^1.1.0", + "is-my-ip-valid": "^1.0.0", + "jsonpointer": "^4.0.0", + "xtend": "^4.0.0" + } + }, + "is-number": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-3.0.0.tgz", + "integrity": "sha1-JP1iAaR4LPUFYcgQJ2r8fRLXEZU=", + "dev": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "is-path-cwd": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-path-cwd/-/is-path-cwd-1.0.0.tgz", + "integrity": "sha1-0iXsIxMuie3Tj9p2dHLmLmXxEG0=", + "dev": true + }, + "is-path-in-cwd": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-path-in-cwd/-/is-path-in-cwd-1.0.1.tgz", + "integrity": "sha512-FjV1RTW48E7CWM7eE/J2NJvAEEVektecDBVBE5Hh3nM1Jd0kvhHtX68Pr3xsDf857xt3Y4AkwVULK1Vku62aaQ==", + "dev": true, + "requires": { + "is-path-inside": "^1.0.0" + } + }, + "is-path-inside": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-path-inside/-/is-path-inside-1.0.1.tgz", + "integrity": "sha1-jvW33lBDej/cprToZe96pVy0gDY=", + "dev": true, + "requires": { + "path-is-inside": "^1.0.1" + } + }, + "is-plain-object": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-2.0.4.tgz", + "integrity": "sha512-h5PpgXkWitc38BBMYawTYMWJHFZJVnBquFE57xFpjB8pJFiF6gZ+bU+WyI/yqXiFR5mdLsgYNaPe8uao6Uv9Og==", + "dev": true, + "requires": { + "isobject": "^3.0.1" + } + }, + "is-promise": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.1.0.tgz", + "integrity": "sha1-eaKp7OfwlugPNtKy87wWwf9L8/o=", + "dev": true + }, + "is-property": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-property/-/is-property-1.0.2.tgz", + "integrity": "sha1-V/4cTkhHTt1lsJkR8msc1Ald2oQ=", + "dev": true + }, + "is-relative": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-relative/-/is-relative-1.0.0.tgz", + "integrity": "sha512-Kw/ReK0iqwKeu0MITLFuj0jbPAmEiOsIwyIXvvbfa6QfmN9pkD1M+8pdk7Rl/dTKbH34/XBFMbgD4iMJhLQbGA==", + "dev": true, + "requires": { + "is-unc-path": "^1.0.0" + } + }, + "is-resolvable": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-resolvable/-/is-resolvable-1.1.0.tgz", + "integrity": "sha512-qgDYXFSR5WvEfuS5dMj6oTMEbrrSaM0CrFk2Yiq/gXnBvD9pMa2jGXxyhGLfvhZpuMZe18CJpFxAt3CRs42NMg==", + "dev": true + }, + "is-stream": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-1.1.0.tgz", + "integrity": "sha1-EtSj3U5o4Lec6428hBc66A2RykQ=", + "dev": true + }, + "is-typedarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-typedarray/-/is-typedarray-1.0.0.tgz", + "integrity": "sha1-5HnICFjfDBsR3dppQPlgEfzaSpo=", + "dev": true + }, + "is-unc-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-unc-path/-/is-unc-path-1.0.0.tgz", + "integrity": "sha512-mrGpVd0fs7WWLfVsStvgF6iEJnbjDFZh9/emhRDcGWTduTfNHd9CHeUwH3gYIjdbwo4On6hunkztwOaAw0yllQ==", + "dev": true, + "requires": { + "unc-path-regex": "^0.1.2" + } + }, + "is-utf8": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-utf8/-/is-utf8-0.2.1.tgz", + "integrity": "sha1-Sw2hRCEE0bM2NA6AeX6GXPOffXI=", + "dev": true + }, + "is-windows": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-windows/-/is-windows-1.0.2.tgz", + "integrity": "sha512-eXK1UInq2bPmjyX6e3VHIzMLobc4J94i4AWn+Hpq3OU5KkrRC96OAcR3PRJ/pGu6m8TRnBHP9dkXQVsT/COVIA==", + "dev": true + }, + "isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=", + "dev": true + }, + "isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha1-6PvzdNxVb/iUehDcsFctYz8s+hA=", + "dev": true + }, + "isobject": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-3.0.1.tgz", + "integrity": "sha1-TkMekrEalzFjaqH5yNHMvP2reN8=", + "dev": true + }, + "isstream": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/isstream/-/isstream-0.1.2.tgz", + "integrity": "sha1-R+Y/evVa+m+S4VAOaQ64uFKcCZo=", + "dev": true + }, + "js-base64": { + "version": "2.4.9", + "resolved": "https://registry.npmjs.org/js-base64/-/js-base64-2.4.9.tgz", + "integrity": "sha512-xcinL3AuDJk7VSzsHgb9DvvIXayBbadtMZ4HFPx8rUszbW1MuNMlwYVC4zzCZ6e1sqZpnNS5ZFYOhXqA39T7LQ==", + "dev": true + }, + "js-tokens": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-3.0.2.tgz", + "integrity": "sha1-mGbfOVECEw449/mWvOtlRDIJwls=", + "dev": true + }, + "js-yaml": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.12.0.tgz", + "integrity": "sha512-PIt2cnwmPfL4hKNwqeiuz4bKfnzHTBv6HyVgjahA6mPLwPDzjDWrplJBMjHUFxku/N3FlmrbyPclad+I+4mJ3A==", + "dev": true, + "requires": { + "argparse": "^1.0.7", + "esprima": "^4.0.0" + } + }, + "jsbn": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/jsbn/-/jsbn-0.1.1.tgz", + "integrity": "sha1-peZUwuWi3rXyAdls77yoDA7y9RM=", + "dev": true, + "optional": true + }, + "jsesc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-1.3.0.tgz", + "integrity": "sha1-RsP+yMGJKxKwgz25vHYiF226s0s=", + "dev": true + }, + "json-loader": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/json-loader/-/json-loader-0.5.7.tgz", + "integrity": "sha512-QLPs8Dj7lnf3e3QYS1zkCo+4ZwqOiF9d/nZnYozTISxXWCfNs9yuky5rJw4/W34s7POaNlbZmQGaB5NiXCbP4w==", + "dev": true + }, + "json-schema": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.2.3.tgz", + "integrity": "sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM=", + "dev": true + }, + "json-schema-traverse": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.3.1.tgz", + "integrity": "sha1-NJptRMU6Ud6JtAgFxdXlm0F9M0A=", + "dev": true + }, + "json-stable-stringify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-1.0.1.tgz", + "integrity": "sha1-mnWdOcXy/1A/1TAGRu1EX4jE+a8=", + "dev": true, + "requires": { + "jsonify": "~0.0.0" + } + }, + "json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha1-nbe1lJatPzz+8wp1FC0tkwrXJlE=", + "dev": true + }, + "json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus=", + "dev": true + }, + "json5": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/json5/-/json5-0.5.1.tgz", + "integrity": "sha1-Hq3nrMASA0rYTiOWdn6tn6VJWCE=", + "dev": true + }, + "jsonify": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/jsonify/-/jsonify-0.0.0.tgz", + "integrity": "sha1-LHS27kHZPKUbe1qu6PUDYx0lKnM=", + "dev": true + }, + "jsonpointer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-4.0.1.tgz", + "integrity": "sha1-T9kss04OnbPInIYi7PUfm5eMbLk=", + "dev": true + }, + "jsprim": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/jsprim/-/jsprim-1.4.1.tgz", + "integrity": "sha1-MT5mvB5cwG5Di8G3SZwuXFastqI=", + "dev": true, + "requires": { + "assert-plus": "1.0.0", + "extsprintf": "1.3.0", + "json-schema": "0.2.3", + "verror": "1.10.0" + } + }, + "karma-sourcemap-loader": { + "version": "0.3.7", + "resolved": "https://registry.npmjs.org/karma-sourcemap-loader/-/karma-sourcemap-loader-0.3.7.tgz", + "integrity": "sha1-kTIsd/jxPUb+0GKwQuEAnUxFBdg=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2" + } + }, + "kind-of": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.2.tgz", + "integrity": "sha512-s5kLOcnH0XqDO+FvuaLX8DDjZ18CGFk7VygH40QoKPUQhW4e2rvM0rwUq0t8IQDOwYSeLK01U90OjzBTme2QqA==", + "dev": true + }, + "lazy-cache": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/lazy-cache/-/lazy-cache-1.0.4.tgz", + "integrity": "sha1-odePw6UEdMuAhF07O24dpJpEbo4=", + "dev": true + }, + "lazy-debug-legacy": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/lazy-debug-legacy/-/lazy-debug-legacy-0.0.1.tgz", + "integrity": "sha1-U3cWwHduTPeePtG2IfdljCkRsbE=", + "dev": true + }, + "lcid": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/lcid/-/lcid-1.0.0.tgz", + "integrity": "sha1-MIrMr6C8SDo4Z7S28rlQYlHRuDU=", + "dev": true, + "requires": { + "invert-kv": "^1.0.0" + } + }, + "levn": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.3.0.tgz", + "integrity": "sha1-OwmSTt+fCDwEkP3UwLxEIeBHZO4=", + "dev": true, + "requires": { + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2" + } + }, + "liftoff": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/liftoff/-/liftoff-2.5.0.tgz", + "integrity": "sha1-IAkpG7Mc6oYbvxCnwVooyvdcMew=", + "dev": true, + "requires": { + "extend": "^3.0.0", + "findup-sync": "^2.0.0", + "fined": "^1.0.1", + "flagged-respawn": "^1.0.0", + "is-plain-object": "^2.0.4", + "object.map": "^1.0.0", + "rechoir": "^0.6.2", + "resolve": "^1.1.7" + } + }, + "load-json-file": { + "version": "1.1.0", + "resolved": "http://registry.npmjs.org/load-json-file/-/load-json-file-1.1.0.tgz", + "integrity": "sha1-lWkFcI1YtLq0wiYbBPWfMcmTdMA=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "parse-json": "^2.2.0", + "pify": "^2.0.0", + "pinkie-promise": "^2.0.0", + "strip-bom": "^2.0.0" + }, + "dependencies": { + "pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", + "dev": true + } + } + }, + "loader-runner": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-2.3.1.tgz", + "integrity": "sha512-By6ZFY7ETWOc9RFaAIb23IjJVcM4dvJC/N57nmdz9RSkMXvAXGI7SyVlAw3v8vjtDRlqThgVDVmTnr9fqMlxkw==", + "dev": true + }, + "loader-utils": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/loader-utils/-/loader-utils-1.1.0.tgz", + "integrity": "sha1-yYrvSIvM7aL/teLeZG1qdUQp9c0=", + "dev": true, + "requires": { + "big.js": "^3.1.3", + "emojis-list": "^2.0.0", + "json5": "^0.5.0" + } + }, + "locate-path": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-2.0.0.tgz", + "integrity": "sha1-K1aLJl7slExtnA3pw9u7ygNUzY4=", + "dev": true, + "requires": { + "p-locate": "^2.0.0", + "path-exists": "^3.0.0" + } + }, + "lodash": { + "version": "4.17.11", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.11.tgz", + "integrity": "sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg==", + "dev": true + }, + "lodash._basecopy": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/lodash._basecopy/-/lodash._basecopy-3.0.1.tgz", + "integrity": "sha1-jaDmqHbPNEwK2KVIghEd08XHyjY=", + "dev": true + }, + "lodash._basetostring": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/lodash._basetostring/-/lodash._basetostring-3.0.1.tgz", + "integrity": "sha1-0YYdh3+CSlL2aYMtyvPuFVZqB9U=", + "dev": true + }, + "lodash._basevalues": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lodash._basevalues/-/lodash._basevalues-3.0.0.tgz", + "integrity": "sha1-W3dXYoAr3j0yl1A+JjAIIP32Ybc=", + "dev": true + }, + "lodash._getnative": { + "version": "3.9.1", + "resolved": "https://registry.npmjs.org/lodash._getnative/-/lodash._getnative-3.9.1.tgz", + "integrity": "sha1-VwvH3t5G1hzc3mh9ZdPuy6o6r/U=", + "dev": true + }, + "lodash._isiterateecall": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/lodash._isiterateecall/-/lodash._isiterateecall-3.0.9.tgz", + "integrity": "sha1-UgOte6Ql+uhCRg5pbbnPPmqsBXw=", + "dev": true + }, + "lodash._reescape": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lodash._reescape/-/lodash._reescape-3.0.0.tgz", + "integrity": "sha1-Kx1vXf4HyKNVdT5fJ/rH8c3hYWo=", + "dev": true + }, + "lodash._reevaluate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lodash._reevaluate/-/lodash._reevaluate-3.0.0.tgz", + "integrity": "sha1-WLx0xAZklTrgsSTYBpltrKQx4u0=", + "dev": true + }, + "lodash._reinterpolate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lodash._reinterpolate/-/lodash._reinterpolate-3.0.0.tgz", + "integrity": "sha1-DM8tiRZq8Ds2Y8eWU4t1rG4RTZ0=", + "dev": true + }, + "lodash._root": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/lodash._root/-/lodash._root-3.0.1.tgz", + "integrity": "sha1-+6HEUkwZ7ppfgTa0YJ8BfPTe1pI=", + "dev": true + }, + "lodash.assign": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.assign/-/lodash.assign-4.2.0.tgz", + "integrity": "sha1-DZnzzNem0mHRm9rrkkUAXShYCOc=", + "dev": true + }, + "lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=", + "dev": true + }, + "lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha1-gteb/zCmfEAF/9XiUVMArZyk168=", + "dev": true + }, + "lodash.escape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/lodash.escape/-/lodash.escape-3.2.0.tgz", + "integrity": "sha1-mV7g3BjBtIzJLv+ucaEKq1tIdpg=", + "dev": true, + "requires": { + "lodash._root": "^3.0.0" + } + }, + "lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha1-L1c9hcaiQon/AGY7SRwdM4/zRYo=", + "dev": true + }, + "lodash.isarray": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/lodash.isarray/-/lodash.isarray-3.0.4.tgz", + "integrity": "sha1-eeTriMNqgSKvhvhEqpvNhRtfu1U=", + "dev": true + }, + "lodash.keys": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/lodash.keys/-/lodash.keys-3.1.2.tgz", + "integrity": "sha1-TbwEcrFWvlCgsoaFXRvQsMZWCYo=", + "dev": true, + "requires": { + "lodash._getnative": "^3.0.0", + "lodash.isarguments": "^3.0.0", + "lodash.isarray": "^3.0.0" + } + }, + "lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4=" + }, + "lodash.mergewith": { + "version": "4.6.1", + "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.1.tgz", + "integrity": "sha512-eWw5r+PYICtEBgrBE5hhlT6aAa75f411bgDz/ZL2KZqYV03USvucsxcHUIlGTDTECs1eunpI7HOV7U+WLDvNdQ==", + "dev": true + }, + "lodash.restparam": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/lodash.restparam/-/lodash.restparam-3.6.1.tgz", + "integrity": "sha1-k2pOMJ7zMKdkXtQUWYbIWuWyCAU=", + "dev": true + }, + "lodash.template": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/lodash.template/-/lodash.template-3.6.2.tgz", + "integrity": "sha1-+M3sxhaaJVvpCYrosMU9N4kx0U8=", + "dev": true, + "requires": { + "lodash._basecopy": "^3.0.0", + "lodash._basetostring": "^3.0.0", + "lodash._basevalues": "^3.0.0", + "lodash._isiterateecall": "^3.0.0", + "lodash._reinterpolate": "^3.0.0", + "lodash.escape": "^3.0.0", + "lodash.keys": "^3.0.0", + "lodash.restparam": "^3.0.0", + "lodash.templatesettings": "^3.0.0" + } + }, + "lodash.templatesettings": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/lodash.templatesettings/-/lodash.templatesettings-3.1.1.tgz", + "integrity": "sha1-+zB4RHU7Zrnxr6VOJix0UwfbqOU=", + "dev": true, + "requires": { + "lodash._reinterpolate": "^3.0.0", + "lodash.escape": "^3.0.0" + } + }, + "longest": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/longest/-/longest-1.0.1.tgz", + "integrity": "sha1-MKCy2jj3N3DoKUoNIuZiXtd9AJc=", + "dev": true + }, + "loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "requires": { + "js-tokens": "^3.0.0 || ^4.0.0" + } + }, + "loud-rejection": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/loud-rejection/-/loud-rejection-1.6.0.tgz", + "integrity": "sha1-W0b4AUft7leIcPCG0Eghz5mOVR8=", + "dev": true, + "requires": { + "currently-unhandled": "^0.4.1", + "signal-exit": "^3.0.0" + } + }, + "lru-cache": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-4.1.3.tgz", + "integrity": "sha512-fFEhvcgzuIoJVUF8fYr5KR0YqxD238zgObTps31YdADwPPAp82a4M8TrckkWyx7ekNlf9aBcVn81cFwwXngrJA==", + "dev": true, + "requires": { + "pseudomap": "^1.0.2", + "yallist": "^2.1.2" + } + }, + "make-dir": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-1.3.0.tgz", + "integrity": "sha512-2w31R7SJtieJJnQtGc7RVL2StM2vGYVfqUOvUDxH6bC6aJTxPxTF0GnIgCyu7tjockiUWAYQRbxa7vKn34s5sQ==", + "dev": true, + "requires": { + "pify": "^3.0.0" + } + }, + "make-iterator": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/make-iterator/-/make-iterator-1.0.1.tgz", + "integrity": "sha512-pxiuXh0iVEq7VM7KMIhs5gxsfxCux2URptUQaXo4iZZJxBAzTPOLE2BumO5dbfVYq/hBJFBR/a1mFDmOx5AGmw==", + "dev": true, + "requires": { + "kind-of": "^6.0.2" + } + }, + "map-cache": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/map-cache/-/map-cache-0.2.2.tgz", + "integrity": "sha1-wyq9C9ZSXZsFFkW7TyasXcmKDb8=", + "dev": true + }, + "map-obj": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/map-obj/-/map-obj-1.0.1.tgz", + "integrity": "sha1-2TPOuSBdgr3PSIb2dCvcK03qFG0=", + "dev": true + }, + "map-visit": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/map-visit/-/map-visit-1.0.0.tgz", + "integrity": "sha1-7Nyo8TFE5mDxtb1B8S80edmN+48=", + "dev": true, + "requires": { + "object-visit": "^1.0.0" + } + }, + "md5.js": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/md5.js/-/md5.js-1.3.4.tgz", + "integrity": "sha1-6b296UogpawYsENA/Fdk1bCdkB0=", + "dev": true, + "requires": { + "hash-base": "^3.0.0", + "inherits": "^2.0.1" + } + }, + "mem": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/mem/-/mem-1.1.0.tgz", + "integrity": "sha1-Xt1StIXKHZAP5kiVUFOZoN+kX3Y=", + "dev": true, + "requires": { + "mimic-fn": "^1.0.0" + } + }, + "memory-fs": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.4.1.tgz", + "integrity": "sha1-OpoguEYlI+RHz7x+i7gO1me/xVI=", + "dev": true, + "requires": { + "errno": "^0.1.3", + "readable-stream": "^2.0.1" + } + }, + "meow": { + "version": "3.7.0", + "resolved": "https://registry.npmjs.org/meow/-/meow-3.7.0.tgz", + "integrity": "sha1-cstmi0JSKCkKu/qFaJJYcwioAfs=", + "dev": true, + "requires": { + "camelcase-keys": "^2.0.0", + "decamelize": "^1.1.2", + "loud-rejection": "^1.0.0", + "map-obj": "^1.0.1", + "minimist": "^1.1.3", + "normalize-package-data": "^2.3.4", + "object-assign": "^4.0.1", + "read-pkg-up": "^1.0.1", + "redent": "^1.0.0", + "trim-newlines": "^1.0.0" + } + }, + "merge-stream": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-0.1.8.tgz", + "integrity": "sha1-SKB7O0oSHXSj7b/c20sIrb8CQLE=", + "dev": true, + "requires": { + "through2": "^0.6.1" + } + }, + "micromatch": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-3.1.10.tgz", + "integrity": "sha512-MWikgl9n9M3w+bpsY3He8L+w9eF9338xRl8IAO5viDizwSzziFEyUzo2xrrloB64ADbTf8uA8vRqqttDTOmccg==", + "dev": true, + "requires": { + "arr-diff": "^4.0.0", + "array-unique": "^0.3.2", + "braces": "^2.3.1", + "define-property": "^2.0.2", + "extend-shallow": "^3.0.2", + "extglob": "^2.0.4", + "fragment-cache": "^0.2.1", + "kind-of": "^6.0.2", + "nanomatch": "^1.2.9", + "object.pick": "^1.3.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.2" + } + }, + "miller-rabin": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/miller-rabin/-/miller-rabin-4.0.1.tgz", + "integrity": "sha512-115fLhvZVqWwHPbClyntxEVfVDfl9DLLTuJvq3g2O/Oxi8AiNouAHvDSzHS0viUJc+V5vm3eq91Xwqn9dp4jRA==", + "dev": true, + "requires": { + "bn.js": "^4.0.0", + "brorand": "^1.0.1" + } + }, + "mime-db": { + "version": "1.36.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.36.0.tgz", + "integrity": "sha512-L+xvyD9MkoYMXb1jAmzI/lWYAxAMCPvIBSWur0PZ5nOf5euahRLVqH//FKW9mWp2lkqUgYiXPgkzfMUFi4zVDw==", + "dev": true + }, + "mime-types": { + "version": "2.1.20", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.20.tgz", + "integrity": "sha512-HrkrPaP9vGuWbLK1B1FfgAkbqNjIuy4eHlIYnFi7kamZyLLrGlo2mpcx0bBmNpKqBtYtAfGbodDddIgddSJC2A==", + "dev": true, + "requires": { + "mime-db": "~1.36.0" + } + }, + "mimic-fn": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-1.2.0.tgz", + "integrity": "sha512-jf84uxzwiuiIVKiOLpfYk7N46TSy8ubTonmneY9vrpHNAnp0QBt2BxWV9dO3/j+BoVAb+a5G6YDPW3M5HOdMWQ==", + "dev": true + }, + "minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==", + "dev": true + }, + "minimalistic-crypto-utils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz", + "integrity": "sha1-9sAMHAsIIkblxNmd+4x8CDsrWCo=", + "dev": true + }, + "minimatch": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", + "integrity": "sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA==", + "dev": true, + "requires": { + "brace-expansion": "^1.1.7" + } + }, + "minimist": { + "version": "1.2.0", + "resolved": "http://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", + "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=", + "dev": true + }, + "mixin-deep": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/mixin-deep/-/mixin-deep-1.3.1.tgz", + "integrity": "sha512-8ZItLHeEgaqEvd5lYBXfm4EZSFCX29Jb9K+lAHhDKzReKBQKj3R+7NOF6tjqYi9t4oI8VUfaWITJQm86wnXGNQ==", + "dev": true, + "requires": { + "for-in": "^1.0.2", + "is-extendable": "^1.0.1" + }, + "dependencies": { + "is-extendable": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-1.0.1.tgz", + "integrity": "sha512-arnXMxT1hhoKo9k1LZdmlNyJdDDfy2v0fXjFlmok4+i8ul/6WlbVge9bhM74OpNPQPMGUToDtz+KXa1PneJxOA==", + "dev": true, + "requires": { + "is-plain-object": "^2.0.4" + } + } + } + }, + "mkdirp": { + "version": "0.5.1", + "resolved": "http://registry.npmjs.org/mkdirp/-/mkdirp-0.5.1.tgz", + "integrity": "sha1-MAV0OOrGz3+MR2fzhkjWaX11yQM=", + "dev": true, + "requires": { + "minimist": "0.0.8" + }, + "dependencies": { + "minimist": { + "version": "0.0.8", + "resolved": "http://registry.npmjs.org/minimist/-/minimist-0.0.8.tgz", + "integrity": "sha1-hX/Kv8M5fSYluCKCYuhqp6ARsF0=", + "dev": true + } + } + }, + "ms": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", + "integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g=", + "dev": true + }, + "multipipe": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/multipipe/-/multipipe-0.1.2.tgz", + "integrity": "sha1-Ko8t33Du1WTf8tV/HhoTfZ8FB4s=", + "dev": true, + "requires": { + "duplexer2": "0.0.2" + } + }, + "mute-stream": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.7.tgz", + "integrity": "sha1-MHXOk7whuPq0PhvE2n6BFe0ee6s=", + "dev": true + }, + "nan": { + "version": "2.11.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.11.0.tgz", + "integrity": "sha512-F4miItu2rGnV2ySkXOQoA8FKz/SR2Q2sWP0sbTxNxz/tuokeC8WxOhPMcwi0qIyGtVn/rrSeLbvVkznqCdwYnw==", + "dev": true + }, + "nanomatch": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/nanomatch/-/nanomatch-1.2.13.tgz", + "integrity": "sha512-fpoe2T0RbHwBTBUOftAfBPaDEi06ufaUai0mE6Yn1kacc3SnTErfb/h+X94VXzI64rKFHYImXSvdwGGCmwOqCA==", + "dev": true, + "requires": { + "arr-diff": "^4.0.0", + "array-unique": "^0.3.2", + "define-property": "^2.0.2", + "extend-shallow": "^3.0.2", + "fragment-cache": "^0.2.1", + "is-windows": "^1.0.2", + "kind-of": "^6.0.2", + "object.pick": "^1.3.0", + "regex-not": "^1.0.0", + "snapdragon": "^0.8.1", + "to-regex": "^3.0.1" + } + }, + "natives": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/natives/-/natives-1.1.5.tgz", + "integrity": "sha512-1pJ+02gl2KJgCPFtpZGtuD4lGSJnIZvvFHCQTOeDRMSXjfu2GmYWuhI8NFMA4W2I5NNFRbfy/YCiVt4CgNpP8A==", + "dev": true + }, + "natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=", + "dev": true + }, + "neo-async": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.5.2.tgz", + "integrity": "sha512-vdqTKI9GBIYcAEbFAcpKPErKINfPF5zIuz3/niBfq8WUZjpT2tytLlFVrBgWdOtqI4uaA/Rb6No0hux39XXDuw==", + "dev": true + }, + "next-tick": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.0.0.tgz", + "integrity": "sha1-yobR/ogoFpsBICCOPchCS524NCw=", + "dev": true + }, + "node-gyp": { + "version": "3.8.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-3.8.0.tgz", + "integrity": "sha512-3g8lYefrRRzvGeSowdJKAKyks8oUpLEd/DyPV4eMhVlhJ0aNaZqIrNUIPuEWWTAoPqyFkfGrM67MC69baqn6vA==", + "dev": true, + "requires": { + "fstream": "^1.0.0", + "glob": "^7.0.3", + "graceful-fs": "^4.1.2", + "mkdirp": "^0.5.0", + "nopt": "2 || 3", + "npmlog": "0 || 1 || 2 || 3 || 4", + "osenv": "0", + "request": "^2.87.0", + "rimraf": "2", + "semver": "~5.3.0", + "tar": "^2.0.0", + "which": "1" + }, + "dependencies": { + "semver": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.3.0.tgz", + "integrity": "sha1-myzl094C0XxgEq0yaqa00M9U+U8=", + "dev": true + } + } + }, + "node-libs-browser": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/node-libs-browser/-/node-libs-browser-2.1.0.tgz", + "integrity": "sha512-5AzFzdoIMb89hBGMZglEegffzgRg+ZFoUmisQ8HI4j1KDdpx13J0taNp2y9xPbur6W61gepGDDotGBVQ7mfUCg==", + "dev": true, + "requires": { + "assert": "^1.1.1", + "browserify-zlib": "^0.2.0", + "buffer": "^4.3.0", + "console-browserify": "^1.1.0", + "constants-browserify": "^1.0.0", + "crypto-browserify": "^3.11.0", + "domain-browser": "^1.1.1", + "events": "^1.0.0", + "https-browserify": "^1.0.0", + "os-browserify": "^0.3.0", + "path-browserify": "0.0.0", + "process": "^0.11.10", + "punycode": "^1.2.4", + "querystring-es3": "^0.2.0", + "readable-stream": "^2.3.3", + "stream-browserify": "^2.0.1", + "stream-http": "^2.7.2", + "string_decoder": "^1.0.0", + "timers-browserify": "^2.0.4", + "tty-browserify": "0.0.0", + "url": "^0.11.0", + "util": "^0.10.3", + "vm-browserify": "0.0.4" + } + }, + "node-sass": { + "version": "4.9.3", + "resolved": "https://registry.npmjs.org/node-sass/-/node-sass-4.9.3.tgz", + "integrity": "sha512-XzXyGjO+84wxyH7fV6IwBOTrEBe2f0a6SBze9QWWYR/cL74AcQUks2AsqcCZenl/Fp/JVbuEaLpgrLtocwBUww==", + "dev": true, + "requires": { + "async-foreach": "^0.1.3", + "chalk": "^1.1.1", + "cross-spawn": "^3.0.0", + "gaze": "^1.0.0", + "get-stdin": "^4.0.1", + "glob": "^7.0.3", + "in-publish": "^2.0.0", + "lodash.assign": "^4.2.0", + "lodash.clonedeep": "^4.3.2", + "lodash.mergewith": "^4.6.0", + "meow": "^3.7.0", + "mkdirp": "^0.5.1", + "nan": "^2.10.0", + "node-gyp": "^3.8.0", + "npmlog": "^4.0.0", + "request": "2.87.0", + "sass-graph": "^2.2.4", + "stdout-stream": "^1.4.0", + "true-case-path": "^1.0.2" + }, + "dependencies": { + "cross-spawn": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-3.0.1.tgz", + "integrity": "sha1-ElYDfsufDF9549bvE14wdwGEuYI=", + "dev": true, + "requires": { + "lru-cache": "^4.0.1", + "which": "^1.2.9" + } + }, + "gaze": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/gaze/-/gaze-1.1.3.tgz", + "integrity": "sha512-BRdNm8hbWzFzWHERTrejLqwHDfS4GibPoq5wjTPIoJHoBtKGPg3xAFfxmM+9ztbXelxcf2hwQcaz1PtmFeue8g==", + "dev": true, + "requires": { + "globule": "^1.0.0" + } + }, + "globule": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/globule/-/globule-1.2.1.tgz", + "integrity": "sha512-g7QtgWF4uYSL5/dn71WxubOrS7JVGCnFPEnoeChJmBnyR9Mw8nGoEwOgJL/RC2Te0WhbsEUCejfH8SZNJ+adYQ==", + "dev": true, + "requires": { + "glob": "~7.1.1", + "lodash": "~4.17.10", + "minimatch": "~3.0.2" + } + } + } + }, + "nopt": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-3.0.6.tgz", + "integrity": "sha1-xkZdvwirzU2zWTF/eaxopkayj/k=", + "dev": true, + "requires": { + "abbrev": "1" + } + }, + "normalize-package-data": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-2.4.0.tgz", + "integrity": "sha512-9jjUFbTPfEy3R/ad/2oNbKtW9Hgovl5O1FvFWKkKblNXoN/Oou6+9+KKohPK13Yc3/TyunyWhJp6gvRNR/PPAw==", + "dev": true, + "requires": { + "hosted-git-info": "^2.1.4", + "is-builtin-module": "^1.0.0", + "semver": "2 || 3 || 4 || 5", + "validate-npm-package-license": "^3.0.1" + } + }, + "normalize-path": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-2.1.1.tgz", + "integrity": "sha1-GrKLVW4Zg2Oowab35vogE3/mrtk=", + "dev": true, + "requires": { + "remove-trailing-separator": "^1.0.1" + } + }, + "normalize-range": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", + "integrity": "sha1-LRDAa9/TEuqXd2laTShDlFa3WUI=", + "dev": true + }, + "npm-run-path": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/npm-run-path/-/npm-run-path-2.0.2.tgz", + "integrity": "sha1-NakjLfo11wZ7TLLd8jV7GHFTbF8=", + "dev": true, + "requires": { + "path-key": "^2.0.0" + } + }, + "npmlog": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-4.1.2.tgz", + "integrity": "sha512-2uUqazuKlTaSI/dC8AzicUck7+IrEaOnN/e0jd3Xtt1KcGpwx30v50mL7oPyr/h9bL3E4aZccVwpwP+5W9Vjkg==", + "dev": true, + "requires": { + "are-we-there-yet": "~1.1.2", + "console-control-strings": "~1.1.0", + "gauge": "~2.7.3", + "set-blocking": "~2.0.0" + } + }, + "num2fraction": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/num2fraction/-/num2fraction-1.2.2.tgz", + "integrity": "sha1-b2gragJ6Tp3fpFZM0lidHU5mnt4=", + "dev": true + }, + "number-is-nan": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/number-is-nan/-/number-is-nan-1.0.1.tgz", + "integrity": "sha1-CXtgK1NCKlIsGvuHkDGDNpQaAR0=", + "dev": true + }, + "oauth-sign": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.8.2.tgz", + "integrity": "sha1-Rqarfwrq2N6unsBWV4C31O/rnUM=", + "dev": true + }, + "object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM=", + "dev": true + }, + "object-copy": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/object-copy/-/object-copy-0.1.0.tgz", + "integrity": "sha1-fn2Fi3gb18mRpBupde04EnVOmYw=", + "dev": true, + "requires": { + "copy-descriptor": "^0.1.0", + "define-property": "^0.2.5", + "kind-of": "^3.0.3" + }, + "dependencies": { + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "requires": { + "is-descriptor": "^0.1.0" + } + }, + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "object-visit": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/object-visit/-/object-visit-1.0.1.tgz", + "integrity": "sha1-95xEk68MU3e1n+OdOV5BBC3QRbs=", + "dev": true, + "requires": { + "isobject": "^3.0.0" + } + }, + "object.defaults": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/object.defaults/-/object.defaults-1.1.0.tgz", + "integrity": "sha1-On+GgzS0B96gbaFtiNXNKeQ1/s8=", + "dev": true, + "requires": { + "array-each": "^1.0.1", + "array-slice": "^1.0.0", + "for-own": "^1.0.0", + "isobject": "^3.0.0" + } + }, + "object.map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/object.map/-/object.map-1.0.1.tgz", + "integrity": "sha1-z4Plncj8wK1fQlDh94s7gb2AHTc=", + "dev": true, + "requires": { + "for-own": "^1.0.0", + "make-iterator": "^1.0.0" + } + }, + "object.pick": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/object.pick/-/object.pick-1.3.0.tgz", + "integrity": "sha1-h6EKxMFpS9Lhy/U1kaZhQftd10c=", + "dev": true, + "requires": { + "isobject": "^3.0.1" + } + }, + "once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha1-WDsap3WWHUsROsF9nFC6753Xa9E=", + "dev": true, + "requires": { + "wrappy": "1" + } + }, + "onetime": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-2.0.1.tgz", + "integrity": "sha1-BnQoIw/WdEOyeUsiu6UotoZ5YtQ=", + "dev": true, + "requires": { + "mimic-fn": "^1.0.0" + } + }, + "optionator": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.8.2.tgz", + "integrity": "sha1-NkxeQJ0/TWMB1sC0wFu6UBgK62Q=", + "dev": true, + "requires": { + "deep-is": "~0.1.3", + "fast-levenshtein": "~2.0.4", + "levn": "~0.3.0", + "prelude-ls": "~1.1.2", + "type-check": "~0.3.2", + "wordwrap": "~1.0.0" + } + }, + "orchestrator": { + "version": "0.3.8", + "resolved": "https://registry.npmjs.org/orchestrator/-/orchestrator-0.3.8.tgz", + "integrity": "sha1-FOfp4nZPcxX7rBhOUGx6pt+UrX4=", + "dev": true, + "requires": { + "end-of-stream": "~0.1.5", + "sequencify": "~0.0.7", + "stream-consume": "~0.1.0" + } + }, + "ordered-read-streams": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/ordered-read-streams/-/ordered-read-streams-0.1.0.tgz", + "integrity": "sha1-/VZamvjrRHO6abbtijQ1LLVS8SY=", + "dev": true + }, + "os-browserify": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/os-browserify/-/os-browserify-0.3.0.tgz", + "integrity": "sha1-hUNzx/XCMVkU/Jv8a9gjj92h7Cc=", + "dev": true + }, + "os-homedir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-homedir/-/os-homedir-1.0.2.tgz", + "integrity": "sha1-/7xJiDNuDoM94MFox+8VISGqf7M=", + "dev": true + }, + "os-locale": { + "version": "1.4.0", + "resolved": "http://registry.npmjs.org/os-locale/-/os-locale-1.4.0.tgz", + "integrity": "sha1-IPnxeuKe00XoveWDsT0gCYA8FNk=", + "dev": true, + "requires": { + "lcid": "^1.0.0" + } + }, + "os-tmpdir": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", + "integrity": "sha1-u+Z0BseaqFxc/sdm/lc0VV36EnQ=", + "dev": true + }, + "osenv": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/osenv/-/osenv-0.1.5.tgz", + "integrity": "sha512-0CWcCECdMVc2Rw3U5w9ZjqX6ga6ubk1xDVKxtBQPK7wis/0F2r9T6k4ydGYhecl7YUBxBVxhL5oisPsNxAPe2g==", + "dev": true, + "requires": { + "os-homedir": "^1.0.0", + "os-tmpdir": "^1.0.0" + } + }, + "p-finally": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-finally/-/p-finally-1.0.0.tgz", + "integrity": "sha1-P7z7FbiZpEEjs0ttzBi3JDNqLK4=", + "dev": true + }, + "p-limit": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-1.3.0.tgz", + "integrity": "sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q==", + "dev": true, + "requires": { + "p-try": "^1.0.0" + } + }, + "p-locate": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-2.0.0.tgz", + "integrity": "sha1-IKAQOyIqcMj9OcwuWAaA893l7EM=", + "dev": true, + "requires": { + "p-limit": "^1.1.0" + } + }, + "p-try": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-1.0.0.tgz", + "integrity": "sha1-y8ec26+P1CKOE/Yh8rGiN8GyB7M=", + "dev": true + }, + "pako": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.6.tgz", + "integrity": "sha512-lQe48YPsMJAig+yngZ87Lus+NF+3mtu7DVOBu6b/gHO1YpKwIj5AWjZ/TOS7i46HD/UixzWb1zeWDZfGZ3iYcg==", + "dev": true + }, + "parse-asn1": { + "version": "5.1.1", + "resolved": "http://registry.npmjs.org/parse-asn1/-/parse-asn1-5.1.1.tgz", + "integrity": "sha512-KPx7flKXg775zZpnp9SxJlz00gTd4BmJ2yJufSc44gMCRrRQ7NSzAcSJQfifuOLgW6bEi+ftrALtsgALeB2Adw==", + "dev": true, + "requires": { + "asn1.js": "^4.0.0", + "browserify-aes": "^1.0.0", + "create-hash": "^1.1.0", + "evp_bytestokey": "^1.0.0", + "pbkdf2": "^3.0.3" + } + }, + "parse-filepath": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/parse-filepath/-/parse-filepath-1.0.2.tgz", + "integrity": "sha1-pjISf1Oq89FYdvWHLz/6x2PWyJE=", + "dev": true, + "requires": { + "is-absolute": "^1.0.0", + "map-cache": "^0.2.0", + "path-root": "^0.1.1" + } + }, + "parse-json": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-2.2.0.tgz", + "integrity": "sha1-9ID0BDTvgHQfhGkJn43qGPVaTck=", + "dev": true, + "requires": { + "error-ex": "^1.2.0" + } + }, + "parse-passwd": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/parse-passwd/-/parse-passwd-1.0.0.tgz", + "integrity": "sha1-bVuTSkVpk7I9N/QKOC1vFmao5cY=", + "dev": true + }, + "pascalcase": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/pascalcase/-/pascalcase-0.1.1.tgz", + "integrity": "sha1-s2PlXoAGym/iF4TS2yK9FdeRfxQ=", + "dev": true + }, + "path-browserify": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-0.0.0.tgz", + "integrity": "sha1-oLhwcpquIUAFt9UDLsLLuw+0RRo=", + "dev": true + }, + "path-dirname": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/path-dirname/-/path-dirname-1.0.2.tgz", + "integrity": "sha1-zDPSTVJeCZpTiMAzbG4yuRYGCeA=", + "dev": true + }, + "path-exists": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-3.0.0.tgz", + "integrity": "sha1-zg6+ql94yxiSXqfYENe1mwEP1RU=", + "dev": true + }, + "path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha1-F0uSaHNVNP+8es5r9TpanhtcX18=", + "dev": true + }, + "path-is-inside": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/path-is-inside/-/path-is-inside-1.0.2.tgz", + "integrity": "sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM=", + "dev": true + }, + "path-key": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-2.0.1.tgz", + "integrity": "sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A=", + "dev": true + }, + "path-parse": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.6.tgz", + "integrity": "sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==", + "dev": true + }, + "path-root": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/path-root/-/path-root-0.1.1.tgz", + "integrity": "sha1-mkpoFMrBwM1zNgqV8yCDyOpHRbc=", + "dev": true, + "requires": { + "path-root-regex": "^0.1.0" + } + }, + "path-root-regex": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/path-root-regex/-/path-root-regex-0.1.2.tgz", + "integrity": "sha1-v8zcjfWxLcUsi0PsONGNcsBLqW0=", + "dev": true + }, + "path-type": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-1.1.0.tgz", + "integrity": "sha1-WcRPfuSR2nBNpBXaWkBwuk+P5EE=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "pify": "^2.0.0", + "pinkie-promise": "^2.0.0" + }, + "dependencies": { + "pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", + "dev": true + } + } + }, + "pbkdf2": { + "version": "3.0.16", + "resolved": "https://registry.npmjs.org/pbkdf2/-/pbkdf2-3.0.16.tgz", + "integrity": "sha512-y4CXP3thSxqf7c0qmOF+9UeOTrifiVTIM+u7NWlq+PRsHbr7r7dpCmvzrZxa96JJUNi0Y5w9VqG5ZNeCVMoDcA==", + "dev": true, + "requires": { + "create-hash": "^1.1.2", + "create-hmac": "^1.1.4", + "ripemd160": "^2.0.1", + "safe-buffer": "^5.0.1", + "sha.js": "^2.4.8" + } + }, + "performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha1-Ywn04OX6kT7BxpMHrjZLSzd8nns=", + "dev": true + }, + "pify": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-3.0.0.tgz", + "integrity": "sha1-5aSs0sEB/fPZpNB/DbxNtJ3SgXY=", + "dev": true + }, + "pinkie": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/pinkie/-/pinkie-2.0.4.tgz", + "integrity": "sha1-clVrgM+g1IqXToDnckjoDtT3+HA=", + "dev": true + }, + "pinkie-promise": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pinkie-promise/-/pinkie-promise-2.0.1.tgz", + "integrity": "sha1-ITXW36ejWMBprJsXh3YogihFD/o=", + "dev": true, + "requires": { + "pinkie": "^2.0.0" + } + }, + "pkg-dir": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-2.0.0.tgz", + "integrity": "sha1-9tXREJ4Z1j7fQo4L1X4Sd3YVM0s=", + "dev": true, + "requires": { + "find-up": "^2.1.0" + } + }, + "plugin-error": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/plugin-error/-/plugin-error-1.0.1.tgz", + "integrity": "sha512-L1zP0dk7vGweZME2i+EeakvUNqSrdiI3F91TwEoYiGrAfUXmVv6fJIq4g82PAXxNsWOp0J7ZqQy/3Szz0ajTxA==", + "dev": true, + "requires": { + "ansi-colors": "^1.0.1", + "arr-diff": "^4.0.0", + "arr-union": "^3.1.0", + "extend-shallow": "^3.0.2" + } + }, + "pluralize": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pluralize/-/pluralize-7.0.0.tgz", + "integrity": "sha512-ARhBOdzS3e41FbkW/XWrTEtukqqLoK5+Z/4UeDaLuSW+39JPeFgs4gCGqsrJHVZX0fUrx//4OF0K1CUGwlIFow==", + "dev": true + }, + "posix-character-classes": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/posix-character-classes/-/posix-character-classes-0.1.1.tgz", + "integrity": "sha1-AerA/jta9xoqbAL+q7jB/vfgDqs=", + "dev": true + }, + "postcss": { + "version": "5.2.18", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-5.2.18.tgz", + "integrity": "sha512-zrUjRRe1bpXKsX1qAJNJjqZViErVuyEkMTRrwu4ud4sbTtIBRmtaYDrHmcGgmrbsW3MHfmtIf+vJumgQn+PrXg==", + "dev": true, + "requires": { + "chalk": "^1.1.3", + "js-base64": "^2.1.9", + "source-map": "^0.5.6", + "supports-color": "^3.2.3" + } + }, + "postcss-load-config": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-1.2.0.tgz", + "integrity": "sha1-U56a/J3chiASHr+djDZz4M5Q0oo=", + "dev": true, + "requires": { + "cosmiconfig": "^2.1.0", + "object-assign": "^4.1.0", + "postcss-load-options": "^1.2.0", + "postcss-load-plugins": "^2.3.0" + } + }, + "postcss-load-options": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/postcss-load-options/-/postcss-load-options-1.2.0.tgz", + "integrity": "sha1-sJixVZ3awt8EvAuzdfmaXP4rbYw=", + "dev": true, + "requires": { + "cosmiconfig": "^2.1.0", + "object-assign": "^4.1.0" + } + }, + "postcss-load-plugins": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/postcss-load-plugins/-/postcss-load-plugins-2.3.0.tgz", + "integrity": "sha1-dFdoEWWZrKLwCfrUJrABdQSdjZI=", + "dev": true, + "requires": { + "cosmiconfig": "^2.1.1", + "object-assign": "^4.1.0" + } + }, + "postcss-value-parser": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-3.3.0.tgz", + "integrity": "sha1-h/OPnxj3dKSrTIojL1xc6IcqnRU=", + "dev": true + }, + "prelude-ls": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.1.2.tgz", + "integrity": "sha1-IZMqVJ9eUv/ZqCf1cOBL5iqX2lQ=", + "dev": true + }, + "pretty-hrtime": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/pretty-hrtime/-/pretty-hrtime-1.0.3.tgz", + "integrity": "sha1-t+PqQkNaTJsnWdmeDyAesZWALuE=", + "dev": true + }, + "private": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/private/-/private-0.1.8.tgz", + "integrity": "sha512-VvivMrbvd2nKkiG38qjULzlc+4Vx4wm/whI9pQD35YrARNnhxeiRktSOhSukRLFNlzg6Br/cJPet5J/u19r/mg==", + "dev": true + }, + "process": { + "version": "0.11.10", + "resolved": "https://registry.npmjs.org/process/-/process-0.11.10.tgz", + "integrity": "sha1-czIwDoQBYb2j5podHZGn1LwW8YI=", + "dev": true + }, + "process-nextick-args": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.0.tgz", + "integrity": "sha512-MtEC1TqN0EU5nephaJ4rAtThHtC86dNN9qCuEhtshvpVBkAW5ZO7BASN9REnF9eoXGcRub+pFuKEpOHE+HbEMw==", + "dev": true + }, + "progress": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.0.tgz", + "integrity": "sha1-ihvjZr+Pwj2yvSPxDG/pILQ4nR8=", + "dev": true + }, + "prr": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", + "integrity": "sha1-0/wRS6BplaRexok/SEzrHXj19HY=", + "dev": true + }, + "pseudomap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/pseudomap/-/pseudomap-1.0.2.tgz", + "integrity": "sha1-8FKijacOYYkX7wqKw0wa5aaChrM=", + "dev": true + }, + "public-encrypt": { + "version": "4.0.2", + "resolved": "http://registry.npmjs.org/public-encrypt/-/public-encrypt-4.0.2.tgz", + "integrity": "sha512-4kJ5Esocg8X3h8YgJsKAuoesBgB7mqH3eowiDzMUPKiRDDE7E/BqqZD1hnTByIaAFiwAw246YEltSq7tdrOH0Q==", + "dev": true, + "requires": { + "bn.js": "^4.1.0", + "browserify-rsa": "^4.0.0", + "create-hash": "^1.1.0", + "parse-asn1": "^5.0.0", + "randombytes": "^2.0.1" + } + }, + "punycode": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.4.1.tgz", + "integrity": "sha1-wNWmOycYgArY4esPpSachN1BhF4=", + "dev": true + }, + "qs": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz", + "integrity": "sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA==", + "dev": true + }, + "querystring": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", + "integrity": "sha1-sgmEkgO7Jd+CDadW50cAWHhSFiA=", + "dev": true + }, + "querystring-es3": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/querystring-es3/-/querystring-es3-0.2.1.tgz", + "integrity": "sha1-nsYfeQSYdXB9aUFFlv2Qek1xHnM=", + "dev": true + }, + "randombytes": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.0.6.tgz", + "integrity": "sha512-CIQ5OFxf4Jou6uOKe9t1AOgqpeU5fd70A8NPdHSGeYXqXsPe6peOwI0cUl88RWZ6sP1vPMV3avd/R6cZ5/sP1A==", + "dev": true, + "requires": { + "safe-buffer": "^5.1.0" + } + }, + "randomfill": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/randomfill/-/randomfill-1.0.4.tgz", + "integrity": "sha512-87lcbR8+MhcWcUiQ+9e+Rwx8MyR2P7qnt15ynUlbm3TU/fjbgz4GsvfSUDTemtCCtVCqb4ZcEFlyPNTh9bBTLw==", + "dev": true, + "requires": { + "randombytes": "^2.0.5", + "safe-buffer": "^5.1.0" + } + }, + "raw-loader": { + "version": "0.5.1", + "resolved": "https://registry.npmjs.org/raw-loader/-/raw-loader-0.5.1.tgz", + "integrity": "sha1-DD0L6u2KAclm2Xh793goElKpeao=" + }, + "read-pkg": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-1.1.0.tgz", + "integrity": "sha1-9f+qXs0pyzHAR0vKfXVra7KePyg=", + "dev": true, + "requires": { + "load-json-file": "^1.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^1.0.0" + } + }, + "read-pkg-up": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-1.0.1.tgz", + "integrity": "sha1-nWPBMnbAZZGNV/ACpX9AobZD+wI=", + "dev": true, + "requires": { + "find-up": "^1.0.0", + "read-pkg": "^1.0.0" + }, + "dependencies": { + "find-up": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-1.1.2.tgz", + "integrity": "sha1-ay6YIrGizgpgq2TWEOzK1TyyTQ8=", + "dev": true, + "requires": { + "path-exists": "^2.0.0", + "pinkie-promise": "^2.0.0" + } + }, + "path-exists": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-2.1.0.tgz", + "integrity": "sha1-D+tsZPD8UY2adU3V77YscCJ2H0s=", + "dev": true, + "requires": { + "pinkie-promise": "^2.0.0" + } + } + } + }, + "readable-stream": { + "version": "2.3.6", + "resolved": "http://registry.npmjs.org/readable-stream/-/readable-stream-2.3.6.tgz", + "integrity": "sha512-tQtKA9WIAhBF3+VLAseyMqZeBjW0AHJoxOtYqSUZNJxauErmLbVm2FW1y+J/YA9dUrAC39ITejlZWhVIwawkKw==", + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.3", + "isarray": "~1.0.0", + "process-nextick-args": "~2.0.0", + "safe-buffer": "~5.1.1", + "string_decoder": "~1.1.1", + "util-deprecate": "~1.0.1" + } + }, + "readdirp": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-2.2.1.tgz", + "integrity": "sha512-1JU/8q+VgFZyxwrJ+SVIOsh+KywWGpds3NTqikiKpDMZWScmAYyKIgqkO+ARvNWJfXeXR1zxz7aHF4u4CyH6vQ==", + "dev": true, + "requires": { + "graceful-fs": "^4.1.11", + "micromatch": "^3.1.10", + "readable-stream": "^2.0.2" + } + }, + "readline2": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/readline2/-/readline2-1.0.1.tgz", + "integrity": "sha1-QQWWCP/BVHV7cV2ZidGZ/783LjU=", + "dev": true, + "requires": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "mute-stream": "0.0.5" + }, + "dependencies": { + "is-fullwidth-code-point": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", + "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", + "dev": true, + "requires": { + "number-is-nan": "^1.0.0" + } + }, + "mute-stream": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/mute-stream/-/mute-stream-0.0.5.tgz", + "integrity": "sha1-j7+rsKmKJT0xhDMfno3rc3L6xsA=", + "dev": true + } + } + }, + "rechoir": { + "version": "0.6.2", + "resolved": "https://registry.npmjs.org/rechoir/-/rechoir-0.6.2.tgz", + "integrity": "sha1-hSBLVNuoLVdC4oyWdW70OvUOM4Q=", + "dev": true, + "requires": { + "resolve": "^1.1.6" + } + }, + "redent": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-1.0.0.tgz", + "integrity": "sha1-z5Fqsf1fHxbfsggi3W7H9zDCr94=", + "dev": true, + "requires": { + "indent-string": "^2.1.0", + "strip-indent": "^1.0.1" + } + }, + "regenerate": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.0.tgz", + "integrity": "sha512-1G6jJVDWrt0rK99kBjvEtziZNCICAuvIPkSiUFIQxVP06RCVpq3dmDo2oi6ABpYaDYaTRr67BEhL8r1wgEZZKg==", + "dev": true + }, + "regenerator-runtime": { + "version": "0.11.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz", + "integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg==" + }, + "regenerator-transform": { + "version": "0.10.1", + "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.10.1.tgz", + "integrity": "sha512-PJepbvDbuK1xgIgnau7Y90cwaAmO/LCLMI2mPvaXq2heGMR3aWW5/BQvYrhJ8jgmQjXewXvBjzfqKcVOmhjZ6Q==", + "dev": true, + "requires": { + "babel-runtime": "^6.18.0", + "babel-types": "^6.19.0", + "private": "^0.1.6" + } + }, + "regex-not": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/regex-not/-/regex-not-1.0.2.tgz", + "integrity": "sha512-J6SDjUgDxQj5NusnOtdFxDwN/+HWykR8GELwctJ7mdqhcyy1xEc4SRFHUXvxTp661YaVKAjfRLZ9cCqS6tn32A==", + "dev": true, + "requires": { + "extend-shallow": "^3.0.2", + "safe-regex": "^1.1.0" + } + }, + "regexpp": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/regexpp/-/regexpp-1.1.0.tgz", + "integrity": "sha512-LOPw8FpgdQF9etWMaAfG/WRthIdXJGYp4mJ2Jgn/2lpkbod9jPn0t9UqN7AxBOKNfzRbYyVfgc7Vk4t/MpnXgw==", + "dev": true + }, + "regexpu-core": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-2.0.0.tgz", + "integrity": "sha1-SdA4g3uNz4v6W5pCE5k45uoq4kA=", + "dev": true, + "requires": { + "regenerate": "^1.2.1", + "regjsgen": "^0.2.0", + "regjsparser": "^0.1.4" + } + }, + "regjsgen": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.2.0.tgz", + "integrity": "sha1-bAFq3qxVT3WCP+N6wFuS1aTtsfc=", + "dev": true + }, + "regjsparser": { + "version": "0.1.5", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.1.5.tgz", + "integrity": "sha1-fuj4Tcb6eS0/0K4ijSS9lJ6tIFw=", + "dev": true, + "requires": { + "jsesc": "~0.5.0" + }, + "dependencies": { + "jsesc": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-0.5.0.tgz", + "integrity": "sha1-597mbjXW/Bb3EP6R1c9p9w8IkR0=", + "dev": true + } + } + }, + "remove-trailing-separator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/remove-trailing-separator/-/remove-trailing-separator-1.1.0.tgz", + "integrity": "sha1-wkvOKig62tW8P1jg1IJJuSN52O8=", + "dev": true + }, + "repeat-element": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/repeat-element/-/repeat-element-1.1.3.tgz", + "integrity": "sha512-ahGq0ZnV5m5XtZLMb+vP76kcAM5nkLqk0lpqAuojSKGgQtn4eRi4ZZGm2olo2zKFH+sMsWaqOCW1dqAnOru72g==", + "dev": true + }, + "repeat-string": { + "version": "1.6.1", + "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", + "integrity": "sha1-jcrkcOHIirwtYA//Sndihtp15jc=", + "dev": true + }, + "repeating": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/repeating/-/repeating-2.0.1.tgz", + "integrity": "sha1-UhTFOpJtNVJwdSf7q0FdvAjQbdo=", + "dev": true, + "requires": { + "is-finite": "^1.0.0" + } + }, + "replace-ext": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/replace-ext/-/replace-ext-0.0.1.tgz", + "integrity": "sha1-KbvZIHinOfC8zitO5B6DeVNSKSQ=", + "dev": true + }, + "request": { + "version": "2.87.0", + "resolved": "https://registry.npmjs.org/request/-/request-2.87.0.tgz", + "integrity": "sha512-fcogkm7Az5bsS6Sl0sibkbhcKsnyon/jV1kF3ajGmF0c8HrttdKTPRT9hieOaQHA5HEq6r8OyWOo/o781C1tNw==", + "dev": true, + "requires": { + "aws-sign2": "~0.7.0", + "aws4": "^1.6.0", + "caseless": "~0.12.0", + "combined-stream": "~1.0.5", + "extend": "~3.0.1", + "forever-agent": "~0.6.1", + "form-data": "~2.3.1", + "har-validator": "~5.0.3", + "http-signature": "~1.2.0", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.17", + "oauth-sign": "~0.8.2", + "performance-now": "^2.1.0", + "qs": "~6.5.1", + "safe-buffer": "^5.1.1", + "tough-cookie": "~2.3.3", + "tunnel-agent": "^0.6.0", + "uuid": "^3.1.0" + } + }, + "require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha1-jGStX9MNqxyXbiNE/+f3kqam30I=", + "dev": true + }, + "require-from-string": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-1.2.1.tgz", + "integrity": "sha1-UpyczvJzgK3+yaL5ZbZJu+5jZBg=", + "dev": true + }, + "require-main-filename": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-1.0.1.tgz", + "integrity": "sha1-l/cXtp1IeE9fUmpsWqj/3aBVpNE=", + "dev": true + }, + "require-package-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/require-package-name/-/require-package-name-2.0.1.tgz", + "integrity": "sha1-wR6XJ2tluOKSP3Xav1+y7ww4Qbk=", + "dev": true + }, + "require-uncached": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/require-uncached/-/require-uncached-1.0.3.tgz", + "integrity": "sha1-Tg1W1slmL9MeQwEcS5WqSZVUIdM=", + "dev": true, + "requires": { + "caller-path": "^0.1.0", + "resolve-from": "^1.0.0" + } + }, + "requireindex": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/requireindex/-/requireindex-1.2.0.tgz", + "integrity": "sha512-L9jEkOi3ASd9PYit2cwRfyppc9NoABujTP8/5gFcbERmo5jUoAKovIC3fsF17pkTnGsrByysqX+Kxd2OTNI1ww==", + "dev": true + }, + "resolve": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.8.1.tgz", + "integrity": "sha512-AicPrAC7Qu1JxPCZ9ZgCZlY35QgFnNqc+0LtbRNxnVw4TXvjQ72wnuL9JQcEBgXkI9JM8MsT9kaQoHcpCRJOYA==", + "dev": true, + "requires": { + "path-parse": "^1.0.5" + } + }, + "resolve-dir": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/resolve-dir/-/resolve-dir-1.0.1.tgz", + "integrity": "sha1-eaQGRMNivoLybv/nOcm7U4IEb0M=", + "dev": true, + "requires": { + "expand-tilde": "^2.0.0", + "global-modules": "^1.0.0" + } + }, + "resolve-from": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-1.0.1.tgz", + "integrity": "sha1-Jsv+k10a7uq7Kbw/5a6wHpPUQiY=", + "dev": true + }, + "resolve-url": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/resolve-url/-/resolve-url-0.2.1.tgz", + "integrity": "sha1-LGN/53yJOv0qZj/iGqkIAGjiBSo=", + "dev": true + }, + "restore-cursor": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-2.0.0.tgz", + "integrity": "sha1-n37ih/gv0ybU/RYpI9YhKe7g368=", + "dev": true, + "requires": { + "onetime": "^2.0.0", + "signal-exit": "^3.0.2" + } + }, + "ret": { + "version": "0.1.15", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz", + "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==", + "dev": true + }, + "right-align": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/right-align/-/right-align-0.1.3.tgz", + "integrity": "sha1-YTObci/mo1FWiSENJOFMlhSGE+8=", + "dev": true, + "requires": { + "align-text": "^0.1.1" + } + }, + "rimraf": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-2.6.2.tgz", + "integrity": "sha512-lreewLK/BlghmxtfH36YYVg1i8IAce4TI7oao75I1g245+6BctqTVQiBP3YUJ9C6DQOXJmkYR9X9fCLtCOJc5w==", + "dev": true, + "requires": { + "glob": "^7.0.5" + } + }, + "ripemd160": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/ripemd160/-/ripemd160-2.0.2.tgz", + "integrity": "sha512-ii4iagi25WusVoiC4B4lq7pbXfAp3D9v5CwfkY33vffw2+pkDjY1D8GaN7spsxvCSx8dkPqOZCEZyfxcmJG2IA==", + "dev": true, + "requires": { + "hash-base": "^3.0.0", + "inherits": "^2.0.1" + } + }, + "run-async": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.3.0.tgz", + "integrity": "sha1-A3GrSuC91yDUFm19/aZP96RFpsA=", + "dev": true, + "requires": { + "is-promise": "^2.1.0" + } + }, + "rx-lite": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/rx-lite/-/rx-lite-4.0.8.tgz", + "integrity": "sha1-Cx4Rr4vESDbwSmQH6S2kJGe3lEQ=", + "dev": true + }, + "rx-lite-aggregates": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/rx-lite-aggregates/-/rx-lite-aggregates-4.0.8.tgz", + "integrity": "sha1-dTuHqJoRyVRnxKwWJsTvxOBcZ74=", + "dev": true, + "requires": { + "rx-lite": "*" + } + }, + "safe-buffer": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz", + "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", + "dev": true + }, + "safe-regex": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex/-/safe-regex-1.1.0.tgz", + "integrity": "sha1-QKNmnzsHfR6UPURinhV91IAjvy4=", + "dev": true, + "requires": { + "ret": "~0.1.10" + } + }, + "safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true + }, + "sass-graph": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/sass-graph/-/sass-graph-2.2.4.tgz", + "integrity": "sha1-E/vWPNHK8JCLn9k0dq1DpR0eC0k=", + "dev": true, + "requires": { + "glob": "^7.0.0", + "lodash": "^4.0.0", + "scss-tokenizer": "^0.2.3", + "yargs": "^7.0.0" + } + }, + "scss-tokenizer": { + "version": "0.2.3", + "resolved": "https://registry.npmjs.org/scss-tokenizer/-/scss-tokenizer-0.2.3.tgz", + "integrity": "sha1-jrBtualyMzOCTT9VMGQRSYR85dE=", + "dev": true, + "requires": { + "js-base64": "^2.1.8", + "source-map": "^0.4.2" + }, + "dependencies": { + "source-map": { + "version": "0.4.4", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.4.4.tgz", + "integrity": "sha1-66T12pwNyZneaAMti092FzZSA2s=", + "dev": true, + "requires": { + "amdefine": ">=0.0.4" + } + } + } + }, + "semver": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.5.1.tgz", + "integrity": "sha512-PqpAxfrEhlSUWge8dwIp4tZnQ25DIOthpiaHNIthsjEFQD6EvqUKUDM7L8O2rShkFccYo1VjJR0coWfNkCubRw==", + "dev": true + }, + "sequencify": { + "version": "0.0.7", + "resolved": "https://registry.npmjs.org/sequencify/-/sequencify-0.0.7.tgz", + "integrity": "sha1-kM/xnQLgcCf9dn9erT57ldHnOAw=", + "dev": true + }, + "set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha1-BF+XgtARrppoA93TgrJDkrPYkPc=", + "dev": true + }, + "set-value": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-value/-/set-value-2.0.0.tgz", + "integrity": "sha512-hw0yxk9GT/Hr5yJEYnHNKYXkIA8mVJgd9ditYZCe16ZczcaELYYcfvaXesNACk2O8O0nTiPQcQhGUQj8JLzeeg==", + "dev": true, + "requires": { + "extend-shallow": "^2.0.1", + "is-extendable": "^0.1.1", + "is-plain-object": "^2.0.3", + "split-string": "^3.0.1" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "setimmediate": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz", + "integrity": "sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU=", + "dev": true + }, + "sha.js": { + "version": "2.4.11", + "resolved": "http://registry.npmjs.org/sha.js/-/sha.js-2.4.11.tgz", + "integrity": "sha512-QMEp5B7cftE7APOjk5Y6xgrbWu+WkLVQwk8JNjZ8nKRciZaByEW6MubieAiToS7+dwvrjGhH8jRXz3MVd0AYqQ==", + "dev": true, + "requires": { + "inherits": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "shebang-command": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-1.2.0.tgz", + "integrity": "sha1-RKrGW2lbAzmJaMOfNj/uXer98eo=", + "dev": true, + "requires": { + "shebang-regex": "^1.0.0" + } + }, + "shebang-regex": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-1.0.0.tgz", + "integrity": "sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM=", + "dev": true + }, + "shelljs": { + "version": "0.7.8", + "resolved": "https://registry.npmjs.org/shelljs/-/shelljs-0.7.8.tgz", + "integrity": "sha1-3svPh0sNHl+3LhSxZKloMEjprLM=", + "dev": true, + "requires": { + "glob": "^7.0.0", + "interpret": "^1.0.0", + "rechoir": "^0.6.2" + } + }, + "sigmund": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/sigmund/-/sigmund-1.0.1.tgz", + "integrity": "sha1-P/IfGYytIXX587eBhT/ZTQ0ZtZA=", + "dev": true + }, + "signal-exit": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz", + "integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=", + "dev": true + }, + "slash": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/slash/-/slash-1.0.0.tgz", + "integrity": "sha1-xB8vbDn8FtHNF61LXYlhFK5HDVU=", + "dev": true + }, + "slice-ansi": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-1.0.0.tgz", + "integrity": "sha512-POqxBK6Lb3q6s047D/XsDVNPnF9Dl8JSaqe9h9lURl0OdNqy/ujDrOiIHtsqXMGbWWTIomRzAMaTyawAU//Reg==", + "dev": true, + "requires": { + "is-fullwidth-code-point": "^2.0.0" + } + }, + "snapdragon": { + "version": "0.8.2", + "resolved": "https://registry.npmjs.org/snapdragon/-/snapdragon-0.8.2.tgz", + "integrity": "sha512-FtyOnWN/wCHTVXOMwvSv26d+ko5vWlIDD6zoUJ7LW8vh+ZBC8QdljveRP+crNrtBwioEUWy/4dMtbBjA4ioNlg==", + "dev": true, + "requires": { + "base": "^0.11.1", + "debug": "^2.2.0", + "define-property": "^0.2.5", + "extend-shallow": "^2.0.1", + "map-cache": "^0.2.2", + "source-map": "^0.5.6", + "source-map-resolve": "^0.5.0", + "use": "^3.1.0" + }, + "dependencies": { + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "requires": { + "is-descriptor": "^0.1.0" + } + }, + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + } + } + }, + "snapdragon-node": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/snapdragon-node/-/snapdragon-node-2.1.1.tgz", + "integrity": "sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw==", + "dev": true, + "requires": { + "define-property": "^1.0.0", + "isobject": "^3.0.0", + "snapdragon-util": "^3.0.1" + }, + "dependencies": { + "define-property": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-1.0.0.tgz", + "integrity": "sha1-dp66rz9KY6rTr56NMEybvnm/sOY=", + "dev": true, + "requires": { + "is-descriptor": "^1.0.0" + } + }, + "is-accessor-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-accessor-descriptor/-/is-accessor-descriptor-1.0.0.tgz", + "integrity": "sha512-m5hnHTkcVsPfqx3AKlyttIPb7J+XykHvJP2B9bZDjlhLIoEq4XoK64Vg7boZlVWYK6LUY94dYPEE7Lh0ZkZKcQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-data-descriptor": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-data-descriptor/-/is-data-descriptor-1.0.0.tgz", + "integrity": "sha512-jbRXy1FmtAoCjQkVmIVYwuuqDFUbaOeDjmed1tOGPrsMhtJA4rD9tkgA0F1qJ3gRFRXcHYVkdeaP50Q5rE/jLQ==", + "dev": true, + "requires": { + "kind-of": "^6.0.0" + } + }, + "is-descriptor": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-descriptor/-/is-descriptor-1.0.2.tgz", + "integrity": "sha512-2eis5WqQGV7peooDyLmNEPUrps9+SXX5c9pL3xEB+4e9HnGuDa7mB7kHxHw4CbqS9k1T2hOH3miL8n8WtiYVtg==", + "dev": true, + "requires": { + "is-accessor-descriptor": "^1.0.0", + "is-data-descriptor": "^1.0.0", + "kind-of": "^6.0.2" + } + } + } + }, + "snapdragon-util": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/snapdragon-util/-/snapdragon-util-3.0.1.tgz", + "integrity": "sha512-mbKkMdQKsjX4BAL4bRYTj21edOf8cN7XHdYUJEe+Zn99hVEYcMvKPct1IqNe7+AZPirn8BCDOQBHQZknqmKlZQ==", + "dev": true, + "requires": { + "kind-of": "^3.2.0" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "source-list-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/source-list-map/-/source-list-map-2.0.0.tgz", + "integrity": "sha512-I2UmuJSRr/T8jisiROLU3A3ltr+swpniSmNPI4Ml3ZCX6tVnDsuZzK7F2hl5jTqbZBWCEKlj5HRQiPExXLgE8A==", + "dev": true + }, + "source-map": { + "version": "0.5.7", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", + "integrity": "sha1-igOdLRAh0i0eoUyA2OpGi6LvP8w=", + "dev": true + }, + "source-map-resolve": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/source-map-resolve/-/source-map-resolve-0.5.2.tgz", + "integrity": "sha512-MjqsvNwyz1s0k81Goz/9vRBe9SZdB09Bdw+/zYyO+3CuPk6fouTaxscHkgtE8jKvf01kVfl8riHzERQ/kefaSA==", + "dev": true, + "requires": { + "atob": "^2.1.1", + "decode-uri-component": "^0.2.0", + "resolve-url": "^0.2.1", + "source-map-url": "^0.4.0", + "urix": "^0.1.0" + } + }, + "source-map-support": { + "version": "0.4.18", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.4.18.tgz", + "integrity": "sha512-try0/JqxPLF9nOjvSta7tVondkP5dwgyLDjVoyMDlmjugT2lRZ1OfsrYTkCd2hkDnJTKRbO/Rl3orm8vlsUzbA==", + "dev": true, + "requires": { + "source-map": "^0.5.6" + } + }, + "source-map-url": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/source-map-url/-/source-map-url-0.4.0.tgz", + "integrity": "sha1-PpNdfd1zYxuXZZlW1VEo6HtQhKM=", + "dev": true + }, + "sparkles": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/sparkles/-/sparkles-1.0.1.tgz", + "integrity": "sha512-dSO0DDYUahUt/0/pD/Is3VIm5TGJjludZ0HVymmhYF6eNA53PVLhnUk0znSYbH8IYBuJdCE+1luR22jNLMaQdw==", + "dev": true + }, + "spdx-correct": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/spdx-correct/-/spdx-correct-3.0.0.tgz", + "integrity": "sha512-N19o9z5cEyc8yQQPukRCZ9EUmb4HUpnrmaL/fxS2pBo2jbfcFRVuFZ/oFC+vZz0MNNk0h80iMn5/S6qGZOL5+g==", + "dev": true, + "requires": { + "spdx-expression-parse": "^3.0.0", + "spdx-license-ids": "^3.0.0" + } + }, + "spdx-exceptions": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/spdx-exceptions/-/spdx-exceptions-2.1.0.tgz", + "integrity": "sha512-4K1NsmrlCU1JJgUrtgEeTVyfx8VaYea9J9LvARxhbHtVtohPs/gFGG5yy49beySjlIMhhXZ4QqujIZEfS4l6Cg==", + "dev": true + }, + "spdx-expression-parse": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/spdx-expression-parse/-/spdx-expression-parse-3.0.0.tgz", + "integrity": "sha512-Yg6D3XpRD4kkOmTpdgbUiEJFKghJH03fiC1OPll5h/0sO6neh2jqRDVHOQ4o/LMea0tgCkbMgea5ip/e+MkWyg==", + "dev": true, + "requires": { + "spdx-exceptions": "^2.1.0", + "spdx-license-ids": "^3.0.0" + } + }, + "spdx-license-ids": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.1.tgz", + "integrity": "sha512-TfOfPcYGBB5sDuPn3deByxPhmfegAhpDYKSOXZQN81Oyrrif8ZCodOLzK3AesELnCx03kikhyDwh0pfvvQvF8w==", + "dev": true + }, + "split-string": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/split-string/-/split-string-3.1.0.tgz", + "integrity": "sha512-NzNVhJDYpwceVVii8/Hu6DKfD2G+NrQHlS/V/qgv763EYudVwEcMQNxd2lh+0VrUByXN/oJkl5grOhYWvQUYiw==", + "dev": true, + "requires": { + "extend-shallow": "^3.0.0" + } + }, + "sprintf-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", + "integrity": "sha1-BOaSb2YolTVPPdAVIDYzuFcpfiw=", + "dev": true + }, + "srcdoc-polyfill": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/srcdoc-polyfill/-/srcdoc-polyfill-1.0.0.tgz", + "integrity": "sha1-gbbXkTHzMjHqDyBckja+kOmspxg=" + }, + "sshpk": { + "version": "1.14.2", + "resolved": "https://registry.npmjs.org/sshpk/-/sshpk-1.14.2.tgz", + "integrity": "sha1-xvxhZIo9nE52T9P8306hBeSSupg=", + "dev": true, + "requires": { + "asn1": "~0.2.3", + "assert-plus": "^1.0.0", + "bcrypt-pbkdf": "^1.0.0", + "dashdash": "^1.12.0", + "ecc-jsbn": "~0.1.1", + "getpass": "^0.1.1", + "jsbn": "~0.1.0", + "safer-buffer": "^2.0.2", + "tweetnacl": "~0.14.0" + } + }, + "static-extend": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/static-extend/-/static-extend-0.1.2.tgz", + "integrity": "sha1-YICcOcv/VTNyJv1eC1IPNB8ftcY=", + "dev": true, + "requires": { + "define-property": "^0.2.5", + "object-copy": "^0.1.0" + }, + "dependencies": { + "define-property": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/define-property/-/define-property-0.2.5.tgz", + "integrity": "sha1-w1se+RjsPJkPmlvFe+BKrOxcgRY=", + "dev": true, + "requires": { + "is-descriptor": "^0.1.0" + } + } + } + }, + "stdout-stream": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/stdout-stream/-/stdout-stream-1.4.1.tgz", + "integrity": "sha512-j4emi03KXqJWcIeF8eIXkjMFN1Cmb8gUlDYGeBALLPo5qdyTfA9bOtl8m33lRoC+vFMkP3gl0WsDr6+gzxbbTA==", + "dev": true, + "requires": { + "readable-stream": "^2.0.1" + } + }, + "stream-browserify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/stream-browserify/-/stream-browserify-2.0.1.tgz", + "integrity": "sha1-ZiZu5fm9uZQKTkUUyvtDu3Hlyds=", + "dev": true, + "requires": { + "inherits": "~2.0.1", + "readable-stream": "^2.0.2" + } + }, + "stream-consume": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/stream-consume/-/stream-consume-0.1.1.tgz", + "integrity": "sha512-tNa3hzgkjEP7XbCkbRXe1jpg+ievoa0O4SCFlMOYEscGSS4JJsckGL8swUyAa/ApGU3Ae4t6Honor4HhL+tRyg==", + "dev": true + }, + "stream-http": { + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/stream-http/-/stream-http-2.8.3.tgz", + "integrity": "sha512-+TSkfINHDo4J+ZobQLWiMouQYB+UVYFttRA94FpEzzJ7ZdqcL4uUUQ7WkdkI4DSozGmgBUE/a47L+38PenXhUw==", + "dev": true, + "requires": { + "builtin-status-codes": "^3.0.0", + "inherits": "^2.0.1", + "readable-stream": "^2.3.6", + "to-arraybuffer": "^1.0.0", + "xtend": "^4.0.0" + } + }, + "string-width": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-2.1.1.tgz", + "integrity": "sha512-nOqH59deCq9SRHlxq1Aw85Jnt4w6KvLKqWVik6oA9ZklXLNIOlqg4F2yrT1MVaTjAqvVwdfeZ7w7aCvJD7ugkw==", + "dev": true, + "requires": { + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^4.0.0" + }, + "dependencies": { + "ansi-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-3.0.0.tgz", + "integrity": "sha1-7QMXwyIGT3lGbAKWa922Bas32Zg=", + "dev": true + }, + "strip-ansi": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-4.0.0.tgz", + "integrity": "sha1-qEeQIusaw2iocTibY1JixQXuNo8=", + "dev": true, + "requires": { + "ansi-regex": "^3.0.0" + } + } + } + }, + "string_decoder": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", + "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==", + "dev": true, + "requires": { + "safe-buffer": "~5.1.0" + } + }, + "strip-ansi": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-3.0.1.tgz", + "integrity": "sha1-ajhfuIU9lS1f8F0Oiq+UJ43GPc8=", + "dev": true, + "requires": { + "ansi-regex": "^2.0.0" + } + }, + "strip-bom": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-2.0.0.tgz", + "integrity": "sha1-YhmoVhZSBJHzV4i9vxRHqZx+aw4=", + "dev": true, + "requires": { + "is-utf8": "^0.2.0" + } + }, + "strip-eof": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-eof/-/strip-eof-1.0.0.tgz", + "integrity": "sha1-u0P/VZim6wXYm1n80SnJgzE2Br8=", + "dev": true + }, + "strip-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-1.0.1.tgz", + "integrity": "sha1-DHlipq3vp7vUrDZkYKY4VSrhoKI=", + "dev": true, + "requires": { + "get-stdin": "^4.0.1" + } + }, + "strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha1-PFMZQukIwml8DsNEhYwobHygpgo=", + "dev": true + }, + "supports-color": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-3.2.3.tgz", + "integrity": "sha1-ZawFBLOVQXHYpklGsq48u4pfVPY=", + "dev": true, + "requires": { + "has-flag": "^1.0.0" + } + }, + "table": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/table/-/table-4.0.2.tgz", + "integrity": "sha512-UUkEAPdSGxtRpiV9ozJ5cMTtYiqz7Ni1OGqLXRCynrvzdtR1p+cfOWe2RJLwvUG8hNanaSRjecIqwOjqeatDsA==", + "dev": true, + "requires": { + "ajv": "^5.2.3", + "ajv-keywords": "^2.1.0", + "chalk": "^2.1.0", + "lodash": "^4.17.4", + "slice-ansi": "1.0.0", + "string-width": "^2.1.1" + }, + "dependencies": { + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "chalk": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-2.4.1.tgz", + "integrity": "sha512-ObN6h1v2fTJSmUXoS3nMQ92LbDK9be4TV+6G+omQlGJFdcUX5heKi1LZ1YnRMIgwTLEj3E24bT6tYni50rlCfQ==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.1", + "escape-string-regexp": "^1.0.5", + "supports-color": "^5.3.0" + } + }, + "has-flag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", + "integrity": "sha1-tdRU3CGZriJWmfNGfloH87lVuv0=", + "dev": true + }, + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + } + } + }, + "tapable": { + "version": "0.2.8", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-0.2.8.tgz", + "integrity": "sha1-mTcqXJmb8t8WCvwNdL7U9HlIzSI=", + "dev": true + }, + "tar": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-2.2.1.tgz", + "integrity": "sha1-jk0qJWwOIYXGsYrWlK7JaLg8sdE=", + "dev": true, + "requires": { + "block-stream": "*", + "fstream": "^1.0.2", + "inherits": "2" + } + }, + "ternary-stream": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/ternary-stream/-/ternary-stream-1.2.3.tgz", + "integrity": "sha1-8Zafg4R/lkImG8FC4X7iAKrag/0=", + "dev": true, + "requires": { + "duplexer2": "~0.0.2", + "fork-stream": "~0.0.4", + "merge-stream": "~0.1.6", + "through2": "~0.6.3" + } + }, + "text-table": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", + "integrity": "sha1-f17oI66AUgfACvLfSoTsP8+lcLQ=", + "dev": true + }, + "through": { + "version": "2.3.8", + "resolved": "http://registry.npmjs.org/through/-/through-2.3.8.tgz", + "integrity": "sha1-DdTJ/6q8NXlgsbckEV1+Doai4fU=", + "dev": true + }, + "through2": { + "version": "0.6.5", + "resolved": "https://registry.npmjs.org/through2/-/through2-0.6.5.tgz", + "integrity": "sha1-QaucZ7KdVyCQcUEOHXp6lozTrUg=", + "dev": true, + "requires": { + "readable-stream": ">=1.0.33-1 <1.1.0-0", + "xtend": ">=4.0.0 <4.1.0-0" + }, + "dependencies": { + "isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=", + "dev": true + }, + "readable-stream": { + "version": "1.0.34", + "resolved": "http://registry.npmjs.org/readable-stream/-/readable-stream-1.0.34.tgz", + "integrity": "sha1-Elgg40vIQtLyqq+v5MKRbuMsFXw=", + "dev": true, + "requires": { + "core-util-is": "~1.0.0", + "inherits": "~2.0.1", + "isarray": "0.0.1", + "string_decoder": "~0.10.x" + } + }, + "string_decoder": { + "version": "0.10.31", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz", + "integrity": "sha1-YuIDvEF2bGwoyfyEMB2rHFMQ+pQ=", + "dev": true + } + } + }, + "tildify": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tildify/-/tildify-1.2.0.tgz", + "integrity": "sha1-3OwD9V3Km3qj5bBPIYF+tW5jWIo=", + "dev": true, + "requires": { + "os-homedir": "^1.0.0" + } + }, + "time-stamp": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/time-stamp/-/time-stamp-1.1.0.tgz", + "integrity": "sha1-dkpaEa9QVhkhsTPztE5hhofg9cM=", + "dev": true + }, + "timers-browserify": { + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/timers-browserify/-/timers-browserify-2.0.10.tgz", + "integrity": "sha512-YvC1SV1XdOUaL6gx5CoGroT3Gu49pK9+TZ38ErPldOWW4j49GI1HKs9DV+KGq/w6y+LZ72W1c8cKz2vzY+qpzg==", + "dev": true, + "requires": { + "setimmediate": "^1.0.4" + } + }, + "tmp": { + "version": "0.0.33", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", + "integrity": "sha512-jRCJlojKnZ3addtTOjdIqoRuPEKBvNXcGYqzO6zWZX8KfKEpnGY5jfggJQ3EjKuu8D4bJRr0y+cYJFmYbImXGw==", + "dev": true, + "requires": { + "os-tmpdir": "~1.0.2" + } + }, + "to-arraybuffer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/to-arraybuffer/-/to-arraybuffer-1.0.1.tgz", + "integrity": "sha1-fSKbH8xjfkZsoIEYCDanqr/4P0M=", + "dev": true + }, + "to-fast-properties": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/to-fast-properties/-/to-fast-properties-1.0.3.tgz", + "integrity": "sha1-uDVx+k2MJbguIxsG46MFXeTKGkc=", + "dev": true + }, + "to-object-path": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/to-object-path/-/to-object-path-0.3.0.tgz", + "integrity": "sha1-KXWIt7Dn4KwI4E5nL4XB9JmeF68=", + "dev": true, + "requires": { + "kind-of": "^3.0.2" + }, + "dependencies": { + "kind-of": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-3.2.2.tgz", + "integrity": "sha1-MeohpzS6ubuw8yRm2JOupR5KPGQ=", + "dev": true, + "requires": { + "is-buffer": "^1.1.5" + } + } + } + }, + "to-regex": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/to-regex/-/to-regex-3.0.2.tgz", + "integrity": "sha512-FWtleNAtZ/Ki2qtqej2CXTOayOH9bHDQF+Q48VpWyDXjbYxA4Yz8iDB31zXOBUlOHHKidDbqGVrTUvQMPmBGBw==", + "dev": true, + "requires": { + "define-property": "^2.0.2", + "extend-shallow": "^3.0.2", + "regex-not": "^1.0.2", + "safe-regex": "^1.1.0" + } + }, + "to-regex-range": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-2.1.1.tgz", + "integrity": "sha1-fIDBe53+vlmeJzZ+DU3VWQFB2zg=", + "dev": true, + "requires": { + "is-number": "^3.0.0", + "repeat-string": "^1.6.1" + } + }, + "tough-cookie": { + "version": "2.3.4", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.3.4.tgz", + "integrity": "sha512-TZ6TTfI5NtZnuyy/Kecv+CnoROnyXn2DN97LontgQpCwsX2XyLYCC0ENhYkehSOwAp8rTQKc/NUIF7BkQ5rKLA==", + "dev": true, + "requires": { + "punycode": "^1.4.1" + } + }, + "trim-newlines": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/trim-newlines/-/trim-newlines-1.0.0.tgz", + "integrity": "sha1-WIeWa7WCpFA6QetST301ARgVphM=", + "dev": true + }, + "trim-right": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/trim-right/-/trim-right-1.0.1.tgz", + "integrity": "sha1-yy4SAwZ+DI3h9hQJS5/kVwTqYAM=", + "dev": true + }, + "true-case-path": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/true-case-path/-/true-case-path-1.0.3.tgz", + "integrity": "sha512-m6s2OdQe5wgpFMC+pAJ+q9djG82O2jcHPOI6RNg1yy9rCYR+WD6Nbpl32fDpfC56nirdRy+opFa/Vk7HYhqaew==", + "dev": true, + "requires": { + "glob": "^7.1.2" + } + }, + "tty-browserify": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.0.tgz", + "integrity": "sha1-oVe6QC2iTpv5V/mqadUk7tQpAaY=", + "dev": true + }, + "tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha1-J6XeoGs2sEoKmWZ3SykIaPD8QP0=", + "dev": true, + "requires": { + "safe-buffer": "^5.0.1" + } + }, + "tweetnacl": { + "version": "0.14.5", + "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", + "integrity": "sha1-WuaBd/GS1EViadEIr6k/+HQ/T2Q=", + "dev": true, + "optional": true + }, + "type-check": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.3.2.tgz", + "integrity": "sha1-WITKtRLPHTVeP7eE8wgEsrUg23I=", + "dev": true, + "requires": { + "prelude-ls": "~1.1.2" + } + }, + "typedarray": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/typedarray/-/typedarray-0.0.6.tgz", + "integrity": "sha1-hnrHTjhkGHsdPUfZlqeOxciDB3c=", + "dev": true + }, + "uglify-js": { + "version": "2.8.29", + "resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-2.8.29.tgz", + "integrity": "sha1-KcVzMUgFe7Th913zW3qcty5qWd0=", + "dev": true, + "requires": { + "source-map": "~0.5.1", + "uglify-to-browserify": "~1.0.0", + "yargs": "~3.10.0" + }, + "dependencies": { + "camelcase": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-1.2.1.tgz", + "integrity": "sha1-m7UwTS4LVmmLLHWLCKPqqdqlijk=", + "dev": true + }, + "cliui": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-2.1.0.tgz", + "integrity": "sha1-S0dXYP+AJkx2LDoXGQMukcf+oNE=", + "dev": true, + "requires": { + "center-align": "^0.1.1", + "right-align": "^0.1.1", + "wordwrap": "0.0.2" + } + }, + "wordwrap": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-0.0.2.tgz", + "integrity": "sha1-t5Zpu0LstAn4PVg8rVLKF+qhZD8=", + "dev": true + }, + "yargs": { + "version": "3.10.0", + "resolved": "http://registry.npmjs.org/yargs/-/yargs-3.10.0.tgz", + "integrity": "sha1-9+572FfdfB0tOMDnTvvWgdFDH9E=", + "dev": true, + "requires": { + "camelcase": "^1.0.2", + "cliui": "^2.1.0", + "decamelize": "^1.0.0", + "window-size": "0.1.0" + } + } + } + }, + "uglify-to-browserify": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/uglify-to-browserify/-/uglify-to-browserify-1.0.2.tgz", + "integrity": "sha1-bgkk1r2mta/jSeOabWMoUKD4grc=", + "dev": true, + "optional": true + }, + "uglifyjs-webpack-plugin": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/uglifyjs-webpack-plugin/-/uglifyjs-webpack-plugin-0.4.6.tgz", + "integrity": "sha1-uVH0q7a9YX5m9j64kUmOORdj4wk=", + "dev": true, + "requires": { + "source-map": "^0.5.6", + "uglify-js": "^2.8.29", + "webpack-sources": "^1.0.1" + } + }, + "unc-path-regex": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/unc-path-regex/-/unc-path-regex-0.1.2.tgz", + "integrity": "sha1-5z3T17DXxe2G+6xrCufYxqadUPo=", + "dev": true + }, + "union-value": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.0.tgz", + "integrity": "sha1-XHHDTLW61dzr4+oM0IIHulqhrqQ=", + "dev": true, + "requires": { + "arr-union": "^3.1.0", + "get-value": "^2.0.6", + "is-extendable": "^0.1.1", + "set-value": "^0.4.3" + }, + "dependencies": { + "extend-shallow": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz", + "integrity": "sha1-Ua99YUrZqfYQ6huvu5idaxxWiQ8=", + "dev": true, + "requires": { + "is-extendable": "^0.1.0" + } + }, + "set-value": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/set-value/-/set-value-0.4.3.tgz", + "integrity": "sha1-fbCPnT0i3H945Trzw79GZuzfzPE=", + "dev": true, + "requires": { + "extend-shallow": "^2.0.1", + "is-extendable": "^0.1.1", + "is-plain-object": "^2.0.1", + "to-object-path": "^0.3.0" + } + } + } + }, + "unique-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unique-stream/-/unique-stream-1.0.0.tgz", + "integrity": "sha1-1ZpKdUJ0R9mqbJHnAmP40mpLEEs=", + "dev": true + }, + "unset-value": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unset-value/-/unset-value-1.0.0.tgz", + "integrity": "sha1-g3aHP30jNRef+x5vw6jtDfyKtVk=", + "dev": true, + "requires": { + "has-value": "^0.3.1", + "isobject": "^3.0.0" + }, + "dependencies": { + "has-value": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/has-value/-/has-value-0.3.1.tgz", + "integrity": "sha1-ex9YutpiyoJ+wKIHgCVlSEWZXh8=", + "dev": true, + "requires": { + "get-value": "^2.0.3", + "has-values": "^0.1.4", + "isobject": "^2.0.0" + }, + "dependencies": { + "isobject": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/isobject/-/isobject-2.1.0.tgz", + "integrity": "sha1-8GVWEJaj8dou9GJy+BXIQNh+DIk=", + "dev": true, + "requires": { + "isarray": "1.0.0" + } + } + } + }, + "has-values": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/has-values/-/has-values-0.1.4.tgz", + "integrity": "sha1-bWHeldkd/Km5oCCJrThL/49it3E=", + "dev": true + } + } + }, + "upath": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/upath/-/upath-1.1.0.tgz", + "integrity": "sha512-bzpH/oBhoS/QI/YtbkqCg6VEiPYjSZtrHQM6/QnJS6OL9pKUFLqb3aFh4Scvwm45+7iAgiMkLhSbaZxUqmrprw==", + "dev": true + }, + "uri-js": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.2.2.tgz", + "integrity": "sha512-KY9Frmirql91X2Qgjry0Wd4Y+YTdrdZheS8TFwvkbLWf/G5KNJDCh6pKL5OZctEW4+0Baa5idK2ZQuELRwPznQ==", + "dev": true, + "requires": { + "punycode": "^2.1.0" + }, + "dependencies": { + "punycode": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", + "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", + "dev": true + } + } + }, + "urix": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/urix/-/urix-0.1.0.tgz", + "integrity": "sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI=", + "dev": true + }, + "url": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/url/-/url-0.11.0.tgz", + "integrity": "sha1-ODjpfPxgUh63PFJajlW/3Z4uKPE=", + "dev": true, + "requires": { + "punycode": "1.3.2", + "querystring": "0.2.0" + }, + "dependencies": { + "punycode": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz", + "integrity": "sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0=", + "dev": true + } + } + }, + "use": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz", + "integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ==", + "dev": true + }, + "user-home": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/user-home/-/user-home-1.1.1.tgz", + "integrity": "sha1-K1viOjK2Onyd640PKNSFcko98ZA=", + "dev": true + }, + "util": { + "version": "0.10.4", + "resolved": "https://registry.npmjs.org/util/-/util-0.10.4.tgz", + "integrity": "sha512-0Pm9hTQ3se5ll1XihRic3FDIku70C+iHUdT/W926rSgHV5QgXsYbKZN8MSC3tJtSkhuROzvsQjAaFENRXr+19A==", + "dev": true, + "requires": { + "inherits": "2.0.3" + } + }, + "util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=", + "dev": true + }, + "uuid": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-3.3.2.tgz", + "integrity": "sha512-yXJmeNaw3DnnKAOKJE51sL/ZaYfWJRl1pK9dr19YFCu0ObS231AB1/LbqTKRAQ5kw8A90rA6fr4riOUpTZvQZA==", + "dev": true + }, + "v8flags": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/v8flags/-/v8flags-2.1.1.tgz", + "integrity": "sha1-qrGh+jDUX4jdMhFIh1rALAtV5bQ=", + "dev": true, + "requires": { + "user-home": "^1.1.1" + } + }, + "validate-npm-package-license": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/validate-npm-package-license/-/validate-npm-package-license-3.0.4.tgz", + "integrity": "sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==", + "dev": true, + "requires": { + "spdx-correct": "^3.0.0", + "spdx-expression-parse": "^3.0.0" + } + }, + "verror": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/verror/-/verror-1.10.0.tgz", + "integrity": "sha1-OhBcoXBTr1XW4nDB+CiGguGNpAA=", + "dev": true, + "requires": { + "assert-plus": "^1.0.0", + "core-util-is": "1.0.2", + "extsprintf": "^1.2.0" + } + }, + "vinyl": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/vinyl/-/vinyl-0.4.6.tgz", + "integrity": "sha1-LzVsh6VQolVGHza76ypbqL94SEc=", + "dev": true, + "requires": { + "clone": "^0.2.0", + "clone-stats": "^0.0.1" + } + }, + "vinyl-fs": { + "version": "0.3.14", + "resolved": "https://registry.npmjs.org/vinyl-fs/-/vinyl-fs-0.3.14.tgz", + "integrity": "sha1-mmhRzhysHBzqX+hsCTHWIMLPqeY=", + "dev": true, + "requires": { + "defaults": "^1.0.0", + "glob-stream": "^3.1.5", + "glob-watcher": "^0.0.6", + "graceful-fs": "^3.0.0", + "mkdirp": "^0.5.0", + "strip-bom": "^1.0.0", + "through2": "^0.6.1", + "vinyl": "^0.4.0" + }, + "dependencies": { + "graceful-fs": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-3.0.11.tgz", + "integrity": "sha1-dhPHeKGv6mLyXGMKCG1/Osu92Bg=", + "dev": true, + "requires": { + "natives": "^1.1.0" + } + }, + "strip-bom": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-1.0.0.tgz", + "integrity": "sha1-hbiGLzhEtabV7IRnqTWYFzo295Q=", + "dev": true, + "requires": { + "first-chunk-stream": "^1.0.0", + "is-utf8": "^0.2.0" + } + } + } + }, + "vinyl-sourcemaps-apply": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/vinyl-sourcemaps-apply/-/vinyl-sourcemaps-apply-0.2.1.tgz", + "integrity": "sha1-q2VJ1h0XLCsbh75cUI0jnI74dwU=", + "dev": true, + "requires": { + "source-map": "^0.5.1" + } + }, + "vm-browserify": { + "version": "0.0.4", + "resolved": "https://registry.npmjs.org/vm-browserify/-/vm-browserify-0.0.4.tgz", + "integrity": "sha1-XX6kW7755Kb/ZflUOOCofDV9WnM=", + "dev": true, + "requires": { + "indexof": "0.0.1" + } + }, + "watchpack": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-1.6.0.tgz", + "integrity": "sha512-i6dHe3EyLjMmDlU1/bGQpEw25XSjkJULPuAVKCbNRefQVq48yXKUpwg538F7AZTf9kyr57zj++pQFltUa5H7yA==", + "dev": true, + "requires": { + "chokidar": "^2.0.2", + "graceful-fs": "^4.1.2", + "neo-async": "^2.5.0" + } + }, + "webpack": { + "version": "3.12.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-3.12.0.tgz", + "integrity": "sha512-Sw7MdIIOv/nkzPzee4o0EdvCuPmxT98+vVpIvwtcwcF1Q4SDSNp92vwcKc4REe7NItH9f1S4ra9FuQ7yuYZ8bQ==", + "dev": true, + "requires": { + "acorn": "^5.0.0", + "acorn-dynamic-import": "^2.0.0", + "ajv": "^6.1.0", + "ajv-keywords": "^3.1.0", + "async": "^2.1.2", + "enhanced-resolve": "^3.4.0", + "escope": "^3.6.0", + "interpret": "^1.0.0", + "json-loader": "^0.5.4", + "json5": "^0.5.1", + "loader-runner": "^2.3.0", + "loader-utils": "^1.1.0", + "memory-fs": "~0.4.1", + "mkdirp": "~0.5.0", + "node-libs-browser": "^2.0.0", + "source-map": "^0.5.3", + "supports-color": "^4.2.1", + "tapable": "^0.2.7", + "uglifyjs-webpack-plugin": "^0.4.6", + "watchpack": "^1.4.0", + "webpack-sources": "^1.0.1", + "yargs": "^8.0.2" + }, + "dependencies": { + "ajv": { + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.5.4.tgz", + "integrity": "sha512-4Wyjt8+t6YszqaXnLDfMmG/8AlO5Zbcsy3ATHncCzjW/NoPzAId8AK6749Ybjmdt+kUY1gP60fCu46oDxPv/mg==", + "dev": true, + "requires": { + "fast-deep-equal": "^2.0.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "ajv-keywords": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.2.0.tgz", + "integrity": "sha1-6GuBnGAs+IIa1jdBNpjx3sAhhHo=", + "dev": true + }, + "camelcase": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-4.1.0.tgz", + "integrity": "sha1-1UVjW+HjPFQmScaRc+Xeas+uNN0=", + "dev": true + }, + "fast-deep-equal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz", + "integrity": "sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk=", + "dev": true + }, + "has-flag": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-2.0.0.tgz", + "integrity": "sha1-6CB68cx7MNRGzHC3NLXovhj4jVE=", + "dev": true + }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "load-json-file": { + "version": "2.0.0", + "resolved": "http://registry.npmjs.org/load-json-file/-/load-json-file-2.0.0.tgz", + "integrity": "sha1-eUfkIUmvgNaWy/eXvKq8/h/inKg=", + "dev": true, + "requires": { + "graceful-fs": "^4.1.2", + "parse-json": "^2.2.0", + "pify": "^2.0.0", + "strip-bom": "^3.0.0" + } + }, + "os-locale": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/os-locale/-/os-locale-2.1.0.tgz", + "integrity": "sha512-3sslG3zJbEYcaC4YVAvDorjGxc7tv6KVATnLPZONiljsUncvihe9BQoVCEs0RZ1kmf4Hk9OBqlZfJZWI4GanKA==", + "dev": true, + "requires": { + "execa": "^0.7.0", + "lcid": "^1.0.0", + "mem": "^1.1.0" + } + }, + "path-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/path-type/-/path-type-2.0.0.tgz", + "integrity": "sha1-8BLMuEFbcJb8LaoQVMPXI4lZTHM=", + "dev": true, + "requires": { + "pify": "^2.0.0" + } + }, + "pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha1-7RQaasBDqEnqWISY59yosVMw6Qw=", + "dev": true + }, + "read-pkg": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-2.0.0.tgz", + "integrity": "sha1-jvHAYjxqbbDcZxPEv6xGMysjaPg=", + "dev": true, + "requires": { + "load-json-file": "^2.0.0", + "normalize-package-data": "^2.3.2", + "path-type": "^2.0.0" + } + }, + "read-pkg-up": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/read-pkg-up/-/read-pkg-up-2.0.0.tgz", + "integrity": "sha1-a3KoBImE4MQeeVEP1en6mbO1Sb4=", + "dev": true, + "requires": { + "find-up": "^2.0.0", + "read-pkg": "^2.0.0" + } + }, + "strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha1-IzTBjpx1n3vdVv3vfprj1YjmjtM=", + "dev": true + }, + "supports-color": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-4.5.0.tgz", + "integrity": "sha1-vnoN5ITexcXN34s9WRJQRJEvY1s=", + "dev": true, + "requires": { + "has-flag": "^2.0.0" + } + }, + "which-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz", + "integrity": "sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=", + "dev": true + }, + "yargs": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-8.0.2.tgz", + "integrity": "sha1-YpmpBVsc78lp/355wdkY3Osiw2A=", + "dev": true, + "requires": { + "camelcase": "^4.1.0", + "cliui": "^3.2.0", + "decamelize": "^1.1.1", + "get-caller-file": "^1.0.1", + "os-locale": "^2.0.0", + "read-pkg-up": "^2.0.0", + "require-directory": "^2.1.1", + "require-main-filename": "^1.0.1", + "set-blocking": "^2.0.0", + "string-width": "^2.0.0", + "which-module": "^2.0.0", + "y18n": "^3.2.1", + "yargs-parser": "^7.0.0" + } + }, + "yargs-parser": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-7.0.0.tgz", + "integrity": "sha1-jQrELxbqVd69MyyvTEA4s+P139k=", + "dev": true, + "requires": { + "camelcase": "^4.1.0" + } + } + } + }, + "webpack-sources": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-1.3.0.tgz", + "integrity": "sha512-OiVgSrbGu7NEnEvQJJgdSFPl2qWKkWq5lHMhgiToIiN9w34EBnjYzSYs+VbL5KoYiLNtFFa7BZIKxRED3I32pA==", + "dev": true, + "requires": { + "source-list-map": "^2.0.0", + "source-map": "~0.6.1" + }, + "dependencies": { + "source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true + } + } + }, + "which": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", + "integrity": "sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==", + "dev": true, + "requires": { + "isexe": "^2.0.0" + } + }, + "which-module": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-1.0.0.tgz", + "integrity": "sha1-u6Y8qGGUiZT/MHc2CJ47lgJsKk8=", + "dev": true + }, + "wide-align": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.3.tgz", + "integrity": "sha512-QGkOQc8XL6Bt5PwnsExKBPuMKBxnGxWWW3fU55Xt4feHozMUhdUMaBCk290qpm/wG5u/RSKzwdAC4i51YigihA==", + "dev": true, + "requires": { + "string-width": "^1.0.2 || 2" + } + }, + "window-size": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/window-size/-/window-size-0.1.0.tgz", + "integrity": "sha1-VDjNLqk7IC76Ohn+iIeu58lPnJ0=", + "dev": true + }, + "wordwrap": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", + "integrity": "sha1-J1hIEIkUVqQXHI0CJkQa3pDLyus=", + "dev": true + }, + "wrap-ansi": { + "version": "2.1.0", + "resolved": "http://registry.npmjs.org/wrap-ansi/-/wrap-ansi-2.1.0.tgz", + "integrity": "sha1-2Pw9KE3QV5T+hJc8rs3Rz4JP3YU=", + "dev": true, + "requires": { + "string-width": "^1.0.1", + "strip-ansi": "^3.0.1" + }, + "dependencies": { + "is-fullwidth-code-point": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", + "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", + "dev": true, + "requires": { + "number-is-nan": "^1.0.0" + } + }, + "string-width": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", + "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", + "dev": true, + "requires": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "strip-ansi": "^3.0.0" + } + } + } + }, + "wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=", + "dev": true + }, + "write": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/write/-/write-0.2.1.tgz", + "integrity": "sha1-X8A4KOJkzqP+kUVUdvejxWbLB1c=", + "dev": true, + "requires": { + "mkdirp": "^0.5.1" + } + }, + "xtend": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.1.tgz", + "integrity": "sha1-pcbVMr5lbiPbgg77lDofBJmNY68=", + "dev": true + }, + "y18n": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-3.2.1.tgz", + "integrity": "sha1-bRX7qITAhnnA136I53WegR4H+kE=", + "dev": true + }, + "yallist": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", + "integrity": "sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=", + "dev": true + }, + "yargs": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-7.1.0.tgz", + "integrity": "sha1-a6MY6xaWFyf10oT46gA+jWFU0Mg=", + "dev": true, + "requires": { + "camelcase": "^3.0.0", + "cliui": "^3.2.0", + "decamelize": "^1.1.1", + "get-caller-file": "^1.0.1", + "os-locale": "^1.4.0", + "read-pkg-up": "^1.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^1.0.1", + "set-blocking": "^2.0.0", + "string-width": "^1.0.2", + "which-module": "^1.0.0", + "y18n": "^3.2.1", + "yargs-parser": "^5.0.0" + }, + "dependencies": { + "camelcase": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-3.0.0.tgz", + "integrity": "sha1-MvxLn82vhF/N9+c7uXysImHwqwo=", + "dev": true + }, + "is-fullwidth-code-point": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", + "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", + "dev": true, + "requires": { + "number-is-nan": "^1.0.0" + } + }, + "string-width": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", + "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", + "dev": true, + "requires": { + "code-point-at": "^1.0.0", + "is-fullwidth-code-point": "^1.0.0", + "strip-ansi": "^3.0.0" + } + } + } + }, + "yargs-parser": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-5.0.0.tgz", + "integrity": "sha1-J17PDX/+Bcd+ZOfIbkzZS/DhIoo=", + "dev": true, + "requires": { + "camelcase": "^3.0.0" + }, + "dependencies": { + "camelcase": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-3.0.0.tgz", + "integrity": "sha1-MvxLn82vhF/N9+c7uXysImHwqwo=", + "dev": true + } + } + } + } +} diff --git a/tests/test_moderation_flows.py b/tests/test_moderation_flows.py index 8df99f8d..cf2517a2 100644 --- a/tests/test_moderation_flows.py +++ b/tests/test_moderation_flows.py @@ -16,6 +16,7 @@ from djangocms_moderation.utils import get_admin_url +@skip('1.0.x rework TBC') class ModerationFlowsTestCase(TestCase): @classmethod def setUpTestData(cls): @@ -23,8 +24,6 @@ def setUpTestData(cls): name='Workflow 1', is_default=True, requires_compliance_number=True ) - cls.page = create_page(title='Page 1', template='page.html', language='en', ) - # create users, groups and roles cls.author = User.objects.create_superuser( username='test1', email='test1@test1.com', password='test1', @@ -36,6 +35,10 @@ def setUpTestData(cls): username='test3', email='test3@test.com', password='test3', ) + cls.page = create_page( + title='Page 1', template='page.html', language='en', created_by=cls.author + ) + cls.role1 = Role.objects.create(name='Role 1', user=cls.moderator_1) cls.role2 = Role.objects.create(name='Role 2', user=cls.moderator_2) @@ -69,7 +72,6 @@ def _resubmit_moderation_request(self, user, message='Test message - resubmit'): def _cancel_moderation_request(self, user, message='Test message - cancel'): return self._process_moderation_request(user, 'cancel', message) - @skip('1.0.x rework TBC') def test_approve_moderation_workflow(self): """ This case tests the following workflow: @@ -129,7 +131,6 @@ def test_approve_moderation_workflow(self): self.assertTrue(last_action.action, constants.ACTION_FINISHED) self.assertEqual(moderation_request.compliance_number, compliance_number) - @skip('1.0.x rework TBC') def test_reject_moderation_workflow(self): """ This case tests the following workflow: From bd9823d1ce982b4accc49c119419d40a812a6e72 Mon Sep 17 00:00:00 2001 From: Rizwan Date: Tue, 25 Sep 2018 13:47:16 +0100 Subject: [PATCH 035/147] Prepopulate moderation collection author (#65) --- djangocms_moderation/admin.py | 26 ++++++++++++++++++++------ djangocms_moderation/helpers.py | 14 -------------- 2 files changed, 20 insertions(+), 20 deletions(-) diff --git a/djangocms_moderation/admin.py b/djangocms_moderation/admin.py index 3746666d..5f250de0 100644 --- a/djangocms_moderation/admin.py +++ b/djangocms_moderation/admin.py @@ -1,5 +1,6 @@ from __future__ import unicode_literals +from django import forms from django.conf.urls import url from django.contrib import admin from django.core.urlresolvers import reverse @@ -26,7 +27,7 @@ RequestCommentForm, WorkflowStepInlineFormSet, ) -from .helpers import EditAndAddOnlyFieldsMixin, get_form_submission_for_step +from .helpers import get_form_submission_for_step from .models import ( CollectionComment, ConfirmationFormSubmission, @@ -273,11 +274,11 @@ class CollectionCommentAdmin(admin.ModelAdmin): fields = ['collection', 'message', 'author'] def get_changeform_initial_data(self, request): - # Extract the id from the URL. The id is stored in _changelsit_filters - # by Django so that the request knows where to return to after form submission. data = { 'author': request.user, } + # Extract the id from the URL. The id is stored in _changelist_filters + # by Django so that the request knows where to return to after form submission. collection_id = utils.extract_filter_param_from_changelist_url( request, '_changelist_filters', 'collection__id__exact' ) @@ -453,10 +454,8 @@ class WorkflowAdmin(admin.ModelAdmin): ] -class ModerationCollectionAdmin(EditAndAddOnlyFieldsMixin, admin.ModelAdmin): +class ModerationCollectionAdmin(admin.ModelAdmin): actions = None # remove `delete_selected` for now, it will be handled later - editonly_fields = ('status',) # fields editable only on EDIT - addonly_fields = ('workflow',) # fields editable only on CREATE list_filter = [ 'author', 'status', @@ -517,6 +516,21 @@ def _url(regex, fn, name, **kwargs): ] return url_patterns + super().get_urls() + def get_changeform_initial_data(self, request): + return {'author': request.user} + + def get_readonly_fields(self, request, obj=None): + if obj: + return ['author', 'workflow'] + else: + return ['status'] + + def get_form(self, request, obj=None, **kwargs): + form = super().get_form(request, obj, **kwargs) + if obj and 'author' in self.readonly_fields: + form.base_fields['author'].widget = forms.HiddenInput() + return form + class ConfirmationPageAdmin(PlaceholderAdminMixin, admin.ModelAdmin): view_on_site = True diff --git a/djangocms_moderation/helpers.py b/djangocms_moderation/helpers.py index 0d01e346..82cb8156 100644 --- a/djangocms_moderation/helpers.py +++ b/djangocms_moderation/helpers.py @@ -34,17 +34,3 @@ def get_form_submission_for_step(active_request, current_step): .filter(request=active_request, for_step=current_step) ) return lookup.first() - - -class EditAndAddOnlyFieldsMixin(object): - editonly_fields = () - addonly_fields = () - - def get_readonly_fields(self, request, obj=None): - """ - Override to provide editonly_fields and addonly_fields functionality - """ - if obj: # Editing an existing object, so `addonly_fields` should be readonly - return self.readonly_fields + self.addonly_fields - else: # Adding a new object - return self.readonly_fields + self.editonly_fields From 7690bd77cf634cfd8b7357e1682132c0123af63e Mon Sep 17 00:00:00 2001 From: Matus Moravcik Date: Tue, 25 Sep 2018 13:47:32 +0100 Subject: [PATCH 036/147] Override version admin list view (#67) --- djangocms_moderation/admin.py | 12 ++-- djangocms_moderation/apps.py | 3 +- djangocms_moderation/cms_toolbars.py | 21 +++--- .../migrations/0005_auto_20180919_1348.py | 27 ++++++++ djangocms_moderation/models.py | 27 +------- djangocms_moderation/monkeypatch.py | 68 +++++++++++++++++++ .../collectioncomment/change_list.html | 26 +++---- .../requestcomment/change_list.html | 19 ------ .../item_to_collection.html | 31 ++++++--- .../moderation_request_change_list.html | 12 ---- djangocms_moderation/utils.py | 27 +++++--- djangocms_moderation/views.py | 43 ++++++++++-- tests/test_admin.py | 6 +- tests/test_monkeypatch.py | 61 +++++++++++++++++ tests/test_utils.py | 19 +++++- tests/test_views.py | 56 +++++++++++++-- tests/utils/base.py | 4 ++ 17 files changed, 332 insertions(+), 130 deletions(-) create mode 100644 djangocms_moderation/migrations/0005_auto_20180919_1348.py create mode 100644 djangocms_moderation/monkeypatch.py create mode 100644 tests/test_monkeypatch.py diff --git a/djangocms_moderation/admin.py b/djangocms_moderation/admin.py index 5f250de0..fd6054dd 100644 --- a/djangocms_moderation/admin.py +++ b/djangocms_moderation/admin.py @@ -116,7 +116,7 @@ def get_list_display(self, request): 'id', 'get_content_type', 'get_title', - 'get_content_author', + 'get_version_author', 'get_preview_link', 'get_status', ] @@ -150,13 +150,9 @@ def get_comments_link(self, obj): ) get_comments_link.short_description = _('Comments') - def get_content_author(self, obj): - """ - This is not necessarily the same person as the RequestAction author - """ - # TODO this should get the author from the version object e.g. obj.content_object.created_by - return "author placeholder" - get_content_author.short_description = _('Content author') + def get_version_author(self, obj): + return obj.version.created_by + get_version_author.short_description = _('Version author') def has_add_permission(self, request): return False diff --git a/djangocms_moderation/apps.py b/djangocms_moderation/apps.py index 1e6fedd2..1ac2c50b 100644 --- a/djangocms_moderation/apps.py +++ b/djangocms_moderation/apps.py @@ -9,5 +9,6 @@ class ModerationConfig(AppConfig): verbose_name = _('django CMS Moderation') def ready(self): - import djangocms_moderation.handlers + import djangocms_moderation.handlers # noqa: F401 import djangocms_moderation.signals # noqa: F401 + import djangocms_moderation.monkeypatch # noqa: F401 diff --git a/djangocms_moderation/cms_toolbars.py b/djangocms_moderation/cms_toolbars.py index 9664ce50..9943bd08 100644 --- a/djangocms_moderation/cms_toolbars.py +++ b/djangocms_moderation/cms_toolbars.py @@ -7,8 +7,11 @@ from djangocms_versioning.cms_toolbars import VersioningToolbar from djangocms_versioning.models import Version -from .models import ModerationRequest -from .utils import get_admin_url, is_obj_review_locked +from .utils import ( + get_active_moderation_request, + get_admin_url, + is_obj_review_locked, +) class ModerationToolbar(VersioningToolbar): @@ -50,11 +53,8 @@ def _add_edit_button(self): def _add_moderation_buttons(self): if self._is_versioned() and self.toolbar.edit_mode_active: - version = Version.objects.get_for_content(self.toolbar.obj) - try: - moderation_request = ModerationRequest.objects.get( - version=version - ) + moderation_request = get_active_moderation_request(self.toolbar.obj) + if moderation_request: self.toolbar.add_modal_button( name=_('In Moderation "%(collection_name)s"') % { 'collection_name': moderation_request.collection.name @@ -63,14 +63,17 @@ def _add_moderation_buttons(self): disabled=True, side=self.toolbar.RIGHT, ) - except ModerationRequest.DoesNotExist: + else: + version = Version.objects.get_for_content(self.toolbar.obj) url = add_url_parameters( get_admin_url( name='cms_moderation_item_to_collection', language=self.current_lang, args=() ), - version_id=version.pk + version_id=version.pk, + # Indicate to the view that we opened the view as a modal + _modal=1, ) self.toolbar.add_modal_button( diff --git a/djangocms_moderation/migrations/0005_auto_20180919_1348.py b/djangocms_moderation/migrations/0005_auto_20180919_1348.py new file mode 100644 index 00000000..29829e31 --- /dev/null +++ b/djangocms_moderation/migrations/0005_auto_20180919_1348.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.13 on 2018-09-19 12:48 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('djangocms_moderation', '0004_auto_20180907_1206'), + ] + + operations = [ + migrations.AlterField( + model_name='moderationcollection', + name='author', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='moderator'), + ), + migrations.AlterField( + model_name='moderationrequest', + name='is_active', + field=models.BooleanField(db_index=True, default=True, verbose_name='is active'), + ), + ] diff --git a/djangocms_moderation/models.py b/djangocms_moderation/models.py index c0ed4875..78077393 100644 --- a/djangocms_moderation/models.py +++ b/djangocms_moderation/models.py @@ -174,31 +174,6 @@ def clean(self): def first_step(self): return self.steps.first() - def _lookup_active_request(self, page, language): - lookup = ( - self - .requests - .filter( - page=page, - language=language, - is_active=True, - ) - ) - return lookup - - def get_active_request(self, page, language): - lookup = self._lookup_active_request(page, language) - - try: - active_request = lookup.get() - except ModerationRequest.DoesNotExist: - active_request = None - return active_request - - def has_active_request(self, page, language): - lookup = self._lookup_active_request(page, language) - return lookup.exists() - @python_2_unicode_compatible class WorkflowStep(models.Model): @@ -358,7 +333,7 @@ class ModerationRequest(models.Model): ) is_active = models.BooleanField( verbose_name=_('is active'), - default=False, + default=True, db_index=True, ) date_sent = models.DateTimeField( diff --git a/djangocms_moderation/monkeypatch.py b/djangocms_moderation/monkeypatch.py new file mode 100644 index 00000000..fb566b85 --- /dev/null +++ b/djangocms_moderation/monkeypatch.py @@ -0,0 +1,68 @@ +from django.utils.html import format_html +from django.utils.translation import ugettext_lazy as _ + +from cms.utils.urlutils import add_url_parameters + +from djangocms_versioning.admin import VersionAdmin +from djangocms_versioning.constants import DRAFT + +from .utils import ( + get_active_moderation_request, + get_admin_url, + is_obj_review_locked, +) + + +def get_state_actions(func): + """ + Monkey patch VersionAdmin's get_state_actions to remove publish link, + as we don't want publishing CMSToolbar button in moderation. + + Add moderation link + """ + def inner(self): + links = func(self) + links = [link for link in links if link != self._get_publish_link] + return links + [self._get_moderation_link] + return inner + + +def _get_moderation_link(self, version, request): + if version.state != DRAFT: + return '' + moderation_request = get_active_moderation_request(version.content) + if moderation_request: + return _('In Moderation "%(collection_name)s"') % { + 'collection_name': moderation_request.collection.name + } + else: + url = add_url_parameters( + get_admin_url( + name='cms_moderation_item_to_collection', + language='en', + args=() + ), + version_id=version.pk + ) + # TODO use a fancy icon as for the rest of the actions? + return format_html( + '{}', + url, + _('Submit for moderation') + ) + + +def _get_edit_link(func): + """ + Don't display edit link if the object is review locked + """ + def inner(self, version, request): + if is_obj_review_locked(version.content, request.user): + return '' + return func(self, version, request) + + return inner + + +VersionAdmin.get_state_actions = get_state_actions(VersionAdmin.get_state_actions) +VersionAdmin._get_edit_link = _get_edit_link(VersionAdmin._get_edit_link) +VersionAdmin._get_moderation_link = _get_moderation_link diff --git a/djangocms_moderation/templates/admin/djangocms_moderation/collectioncomment/change_list.html b/djangocms_moderation/templates/admin/djangocms_moderation/collectioncomment/change_list.html index c5e5a79e..9e67890f 100644 --- a/djangocms_moderation/templates/admin/djangocms_moderation/collectioncomment/change_list.html +++ b/djangocms_moderation/templates/admin/djangocms_moderation/collectioncomment/change_list.html @@ -1,13 +1,14 @@ {% extends "admin/change_list.html" %} {% load i18n %} -{% block object-tools-items %} -{{ block.super }} -{% if submit_for_review_url %} -
    • - {% trans 'Submit for review' %} -
    • -{% endif %} +{% block content_title %} + {% if collection %} +

      + {% blocktrans with collection.name|capfirst as collection_name %} + {{ collection_name }} comments + {% endblocktrans %} +

      + {% endif %} {% endblock %} {% block breadcrumbs %} @@ -18,14 +19,3 @@ › {% trans "Comments" %} {% endblock %} - -{% block content %} -{{ collection }} -{{ block.super }} - -{% comment %} - TODO This template will be overridden to provide the required functionality. - For now, lets just output the selected collection -{% endcomment %} - -{% endblock %} diff --git a/djangocms_moderation/templates/admin/djangocms_moderation/requestcomment/change_list.html b/djangocms_moderation/templates/admin/djangocms_moderation/requestcomment/change_list.html index 55607ab1..3de2b183 100644 --- a/djangocms_moderation/templates/admin/djangocms_moderation/requestcomment/change_list.html +++ b/djangocms_moderation/templates/admin/djangocms_moderation/requestcomment/change_list.html @@ -2,15 +2,6 @@ {% load i18n %} {% load l10n %} -{% block object-tools-items %} -{{ block.super }} -{% if submit_for_review_url %} -
    • - {% trans 'Submit for review' %} -
    • -{% endif %} -{% endblock %} - {% block breadcrumbs %} {% endblock %} - -{% block content %} -{{ block.super }} - -{% comment %} - TODO This template will be overridden to provide the required functionality. - For now, lets just output the selected collection -{% endcomment %} - -{% endblock %} diff --git a/djangocms_moderation/templates/djangocms_moderation/item_to_collection.html b/djangocms_moderation/templates/djangocms_moderation/item_to_collection.html index c50efeda..49633f98 100644 --- a/djangocms_moderation/templates/djangocms_moderation/item_to_collection.html +++ b/djangocms_moderation/templates/djangocms_moderation/item_to_collection.html @@ -5,7 +5,11 @@
      {% csrf_token %}
      -

      {% trans "Add to existing collection" %}

      +

      + {% blocktrans with version.content as content_name %} + Add '{{ content_name }}' to collection + {% endblocktrans %} +

      {% if form.non_field_errors %} {{ form.non_field_errors }} {% endif %} @@ -13,6 +17,8 @@

      {% trans "Add to existing collection" %}

      @@ -48,22 +54,27 @@

      {% trans "Add to existing collection" %}

      +{% endblock %} + +{% block extrahead %} +{{ block.super }} -{% endblock %} diff --git a/djangocms_moderation/templates/djangocms_moderation/items_to_collection.html b/djangocms_moderation/templates/djangocms_moderation/items_to_collection.html index a252b278..b44ec140 100644 --- a/djangocms_moderation/templates/djangocms_moderation/items_to_collection.html +++ b/djangocms_moderation/templates/djangocms_moderation/items_to_collection.html @@ -1,19 +1,99 @@ -{% extends "djangocms_moderation/item_to_collection.html" %} +{% extends "admin/change_form.html" %} {% load i18n static %} -{% block item_to_collection_header %} + +{% block content %} +
      +{% csrf_token %} +

      - {% trans "Add items to collection" %} + {% if form.initial.versions %} + {% if form.initial.versions|length > 1 %} + {% trans "Add items to collection" %} + {% else %} + {% blocktrans with form.initial.versions.0.content as content_name %} + Add '{{ content_name }}' to collection + {% endblocktrans %} + {% endif %} + {% else %} + {% trans 'No items to add' %} + {% endif %}

      -{% endblock %} - -{% block item_to_collection_form %} + {% if form.non_field_errors %} +
      {{ form.non_field_errors }}
      + {% endif %}
      + + {% if moderation_requests %} +

      + {% trans 'The selected collection currently contains the following items:' %} +

      +
      +
      {% trans "Version" %}{% trans "Content Type" %}{% trans "Identifier" %} {% trans "Author" %} {% trans "Edit Date" %} {% trans "Version" %}
      {{ version }}Stub Author [versioning]Stub Date [versioning]{{ mrl.version.content_type|title }}{{ mrl.version.content }}{{ mrl.version.created_by }}{{ mrl.version.created }} Stub VNumber [versioning]Stub Published [versioning] {{ mrl.version.get_state_display }}
      {% trans "Identifier" %} {% trans "Author" %} {% trans "Edit Date" %}{% trans "Version" %}{% trans "Version #" %} {% trans "Status" %}
      {{ mrl.version.content }} {{ mrl.version.created_by }} {{ mrl.version.created }}Stub VNumber [versioning]{{ mrl.version.number }} {{ mrl.version.get_state_display }}
      + + + + + + + + + + + + {% for mr in moderation_requests %} + + + + + + + + + {% endfor %} + +
      {% trans "Content Type" %}{% trans "Identifier" %}{% trans "Author" %}{% trans "Edit Date" %}{% trans "Version #" %}{% trans "Status" %}
      {{ mr.version.content_type|title }}{{ mr.version.content }}{{ mr.version.created_by }}{{ mr.version.created }}{{ mr.version.number }}{{ mr.version.get_state_display }}
      + + {% elif collection_id %} +

      + {% trans 'This collection contains no items yet' %} +

      + {% endif %} +
      + +
      + + +{% endblock %} + +{% block extrahead %} +{{ block.super }} + {% endblock %} diff --git a/djangocms_moderation/templates/djangocms_moderation/request_form.html b/djangocms_moderation/templates/djangocms_moderation/request_form.html index 4fc0a38a..9abc8c70 100644 --- a/djangocms_moderation/templates/djangocms_moderation/request_form.html +++ b/djangocms_moderation/templates/djangocms_moderation/request_form.html @@ -8,7 +8,7 @@

      {{ adminform.non_field_errors }} {% endif %} -
      + {% csrf_token %}
      diff --git a/djangocms_moderation/views.py b/djangocms_moderation/views.py index 21240ef5..ea5773f6 100644 --- a/djangocms_moderation/views.py +++ b/djangocms_moderation/views.py @@ -10,12 +10,10 @@ from cms.utils.urlutils import add_url_parameters -from djangocms_versioning.helpers import version_list_url from djangocms_versioning.models import Version from .forms import ( CancelCollectionForm, - CollectionItemForm, CollectionItemsForm, SubmitCollectionForModerationForm, ) @@ -26,108 +24,26 @@ from . import constants # isort:skip -class CollectionItemView(FormView): - template_name = 'djangocms_moderation/item_to_collection.html' - form_class = CollectionItemForm - success_template_name = 'djangocms_moderation/request_finalized.html' - - def get_form_kwargs(self): - kwargs = super(CollectionItemView, self).get_form_kwargs() - kwargs['user'] = self.request.user - kwargs['initial'].update({ - 'version': self.request.GET.get('version_id'), - }) - collection_id = self.request.GET.get('collection_id') - - if collection_id: - kwargs['initial']['collection'] = collection_id - return kwargs - - def form_valid(self, form): - version = form.cleaned_data['version'] - collection = form.cleaned_data['collection'] - collection.add_version(version) - messages.success(self.request, _('Item successfully added to moderation collection')) - - # Return different response if we opened the view as a modal - if self.request.GET.get('_modal'): - return render(self.request, self.success_template_name, {}) - else: - # Otherwise redirect to the grouper changelist as this is likely - # the place this view was called from - return HttpResponseRedirect(version_list_url(version.content)) - - def get_form(self, **kwargs): - form = super().get_form(**kwargs) - form.set_collection_widget(self.request) - return form - - def get_context_data(self, **kwargs): - """ - Gets collection_id from params or from the first collection in the list - when no ?collection_id is not supplied - - Always gets content_object_list from a collection at a time - """ - context = super().get_context_data(**kwargs) - opts_meta = ModerationCollection._meta - collection_id = self.request.GET.get('collection_id') - version_id = self.request.GET.get('version_id') - - if collection_id: - try: - collection = ModerationCollection.objects.get(pk=int(collection_id)) - except (ValueError, ModerationCollection.DoesNotExist): - raise Http404 - else: - moderation_request_list = collection.moderation_requests.all() - else: - moderation_request_list = [] - - if version_id: - try: - version = Version.objects.get(pk=int(version_id)) - except (ValueError, Version.DoesNotExist): - raise Http404 - else: - version = None - - model_admin = admin.site._registry[ModerationCollection] - context.update({ - 'moderation_request_list': moderation_request_list, - 'opts': opts_meta, - 'title': _('Add to collection'), - 'form': self.get_form(), - 'version': version, - 'media': model_admin.media, - }) - - return context - - -add_item_to_collection = CollectionItemView.as_view() - - class CollectionItemsView(FormView): template_name = 'djangocms_moderation/items_to_collection.html' form_class = CollectionItemsForm - success_template_name = 'djangocms_moderation/request_finalized.html' def get_form_kwargs(self): kwargs = super().get_form_kwargs() kwargs['user'] = self.request.user + return kwargs + + def get_initial(self): + initial = super().get_initial() ids = self.request.GET.get('version_ids', '').split(',') ids = [int(x) for x in ids if x.isdigit()] versions = Version.objects.filter(pk__in=ids) + initial['versions'] = versions - kwargs['initial'].update({ - 'versions': versions, - }) collection_id = self.request.GET.get('collection_id') - if collection_id: - kwargs['initial']['collection'] = collection_id - return kwargs + initial['collection'] = collection_id + return initial def form_valid(self, form): versions = form.cleaned_data['versions'] @@ -145,16 +61,27 @@ def form_valid(self, form): 'count': len(versions) }, ) - return_to_url = self.request.GET.get('return_to_url') - url_is_safe = is_safe_url( - url=return_to_url, - allowed_hosts=self.request.get_host(), - require_https=self.request.is_secure(), - ) - if not url_is_safe: - return_to_url = self.request.path - return HttpResponseRedirect(return_to_url) + return self._get_success_redirect() + + def _get_success_redirect(self): + """ + Lets work out where should we redirect the user after they've added + versions to a collection + """ + return_to_url = self.request.GET.get('return_to_url') + if return_to_url: + url_is_safe = is_safe_url( + url=return_to_url, + allowed_hosts=self.request.get_host(), + require_https=self.request.is_secure(), + ) + if not url_is_safe: + return_to_url = self.request.path + return HttpResponseRedirect(return_to_url) + + success_template = 'djangocms_moderation/request_finalized.html' + return render(self.request, success_template, {}) def get_form(self, **kwargs): form = super().get_form(**kwargs) @@ -164,24 +91,24 @@ def get_form(self, **kwargs): def get_context_data(self, **kwargs): context = super().get_context_data(**kwargs) opts_meta = ModerationCollection._meta - collection_id = self.request.GET.get('collection_id') + collection_id = self.request.GET.get('collection_id') if collection_id: try: collection = ModerationCollection.objects.get(pk=int(collection_id)) except (ValueError, ModerationCollection.DoesNotExist, TypeError): raise Http404 else: - moderation_request_list = collection.moderation_requests.all() + moderation_requests = collection.moderation_requests.all() else: - moderation_request_list = [] + moderation_requests = [] model_admin = admin.site._registry[ModerationCollection] context.update({ - 'moderation_request_list': moderation_request_list, + 'moderation_requests': moderation_requests, 'opts': opts_meta, - 'title': _('Add to collection'), 'form': self.get_form(), + 'collection_id': collection_id, 'media': model_admin.media, }) return context diff --git a/tests/test_forms.py b/tests/test_forms.py index 88c23b1b..272000fe 100644 --- a/tests/test_forms.py +++ b/tests/test_forms.py @@ -9,7 +9,6 @@ from djangocms_moderation import constants from djangocms_moderation.forms import ( CancelCollectionForm, - CollectionItemForm, CollectionItemsForm, ModerationRequestActionInlineForm, SubmitCollectionForModerationForm, @@ -150,72 +149,21 @@ def test_action_user_can_change_own_comment(self): self.assertTrue(form.is_valid()) -class CollectionItemFormTestCase(BaseTestCase): - def test_cant_add_to_collection_when_version_lock_is_active(self): - self.collection1.status = constants.COLLECTING - self.collection1.save() - - version = PageVersionFactory(created_by=self.user) - data = { - 'collection': self.collection1.pk, - 'version': version.pk, - } - form = CollectionItemForm(data=data, user=version.created_by) - self.assertTrue(form.is_valid(), form.errors) - - # now lets try to add version locked item - version = PageVersionFactory(created_by=self.user) - data = { - 'collection': self.collection1.pk, - 'version': version.pk, - } - form = CollectionItemForm(data=data, user=self.user3) - self.assertFalse(form.is_valid()) - self.assertIn('version', form.errors) - - def test_cant_add_version_which_is_in_moderation(self): - self.collection1.status = constants.COLLECTING - self.collection1.save() - - # Version is not a part of any moderation request yet - version = PageVersionFactory(created_by=self.user) - data = { - 'collection': self.collection1.pk, - 'version': version.pk, - } - form = CollectionItemForm(data=data, user=version.created_by) - self.assertTrue(form.is_valid(), form.errors) - - # Now lets add the version to an active moderation request - mr = ModerationRequest.objects.create( - collection=self.collection1, version=version, is_active=True, author=self.collection1.author - ) - form = CollectionItemForm(data=data, user=version.created_by) - self.assertFalse(form.is_valid(), form.errors) - self.assertIn('version', form.errors) - - # If mr was inactive, we are good to go - mr.is_active = False - mr.save() - form = CollectionItemForm(data=data, user=version.created_by) - self.assertTrue(form.is_valid(), form.errors) - - class CollectionItemsFormTestCase(BaseTestCase): def test_add_items_to_collection(self): - pg_version1 = PageVersionFactory(created_by=self.user) - pg_version2 = PageVersionFactory(created_by=self.user) + pg1_version = PageVersionFactory(created_by=self.user) + pg2_version = PageVersionFactory(created_by=self.user) ModerationRequest.objects.all().delete() data = { 'collection': self.collection1.pk, - 'versions': [pg_version1, pg_version2], + 'versions': [pg1_version, pg2_version], } form = CollectionItemsForm(data=data, user=self.user) self.assertTrue(form.is_valid()) versions = form.clean_versions() self.assertQuerysetEqual( versions, - Version.objects.filter(pk__in=[pg_version1.pk, pg_version2.pk]), + Version.objects.filter(pk__in=[pg1_version.pk, pg2_version.pk]), transform=lambda x: x, ordered=False, ) @@ -257,3 +205,46 @@ def test_attempt_add_version_locked_version(self): form = CollectionItemsForm(data=data, user=self.user) self.assertFalse(form.is_valid()) self.assertIn('versions', form.errors) + + def test_collection_choice_should_be_limited_to_current_user_and_collecting_status(self): + user = User.objects.create_superuser( + username='matko', email='test@matko.com', password='test',) + pg_version = PageVersionFactory(created_by=user) + + # Create valid collection + collection1 = ModerationCollection.objects.create( + author=user, name='Collection 1', status=constants.COLLECTING, workflow=self.wf1 + ) + collection2 = ModerationCollection.objects.create( + author=user, name='Collection 2', status=constants.COLLECTING, workflow=self.wf2 + ) + # Now invalid collections, either with status in REVIEW, or different author + collection3 = ModerationCollection.objects.create( + author=user, name='Collection 3', status=constants.IN_REVIEW, workflow=self.wf3 + ) + collection4 = ModerationCollection.objects.create( + author=self.user3, name='Collection 4', status=constants.COLLECTING, workflow=self.wf4 + ) + + fixtures = ( + # (collection, should_the_form_be_valid?) + (collection1, True), + (collection2, True), + (collection3, False), + (collection4, False), + ) + + for fixture in fixtures: + data = {'collection': fixture[0].pk, 'versions': [pg_version]} + form = CollectionItemsForm(data=data, user=user) + + self.assertEqual(form.is_valid(), fixture[1], "{} failed".format(fixture[0])) + if not form.is_valid(): + self.assertIn('collection', form.errors) + + self.assertQuerysetEqual( + form.fields['collection'].queryset, + ModerationCollection.objects.filter(pk__in=[collection1.pk, collection2.pk]), + transform=lambda x: x, + ordered=False, + ) diff --git a/tests/test_monkeypatch.py b/tests/test_monkeypatch.py index 7c63a931..f3db130e 100644 --- a/tests/test_monkeypatch.py +++ b/tests/test_monkeypatch.py @@ -58,14 +58,15 @@ def test_get_archive_link(self, _mock): """ VersionAdmin should call moderation's version of _get_archive_link """ + version = PageVersionFactory(created_by=self.user) archive_url = reverse('admin:{app}_{model}version_archive'.format( - app=self.pg1_version._meta.app_label, - model=self.pg1_version.content._meta.model_name, - ), args=(self.pg1_version.pk,)) + app=version._meta.app_label, + model=version.content._meta.model_name, + ), args=(version.pk,)) _mock.return_value = True archive_link = self.version_admin._get_archive_link( - self.pg1_version, self.mock_request + version, self.mock_request ) # We test that moderation check is called when getting an edit link self.assertEqual(1, _mock.call_count) @@ -75,7 +76,7 @@ def test_get_archive_link(self, _mock): _mock.return_value = None archive_link = self.version_admin._get_archive_link( - self.pg1_version, self.mock_request + version, self.mock_request ) # We test that moderation check is called when getting the link self.assertEqual(2, _mock.call_count) diff --git a/tests/test_views.py b/tests/test_views.py index 020b8c7f..6021365a 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -1,256 +1,87 @@ import mock -from mock import patch -from django.contrib.auth.models import User -from django.contrib.contenttypes.models import ContentType from django.contrib.messages import get_messages from django.urls import reverse -from django.utils.translation import ugettext_lazy as _ from cms.utils.urlutils import add_url_parameters from djangocms_versioning.test_utils.factories import PageVersionFactory -from djangocms_moderation import constants, views -from djangocms_moderation.forms import CollectionItemForm from djangocms_moderation.models import ModerationCollection, ModerationRequest from djangocms_moderation.utils import get_admin_url from .utils.base import BaseViewTestCase -class CollectionItemViewTest(BaseViewTestCase): - +class CollectionItemsViewTest(BaseViewTestCase): def setUp(self): - - self.user = User.objects.create_user( - username='test1', email='test1@test.com', password='test1', is_staff=True - ) - - self.collection_1 = ModerationCollection.objects.create( - author=self.user, name='My collection 1', workflow=self.wf1 - ) - self.collection_2 = ModerationCollection.objects.create( - author=self.user, name='My collection 2', workflow=self.wf1 - ) - - self.content_type = ContentType.objects.get_for_model(self.pg1_version) - self.pg_version = PageVersionFactory(created_by=self.user) - - def _assert_render(self, response): - form = response.context_data['form'] - - self.assertIsInstance(form, CollectionItemForm) - self.assertEqual(response.status_code, 200) - self.assertEqual(response.template_name[0], 'djangocms_moderation/item_to_collection.html') - - self.assertEqual(response.context_data['title'], _('Add to collection')) - - def test_version_object_to_collections_from_modal(self): - ModerationRequest.objects.all().delete() + super().setUp() self.client.force_login(self.user) + + def test_no_eligible_items_to_add_to_collection(self): + """ + We try add pg4_version to a collection but expect it to fail + as it is already party of a collection + """ url = add_url_parameters( get_admin_url( - name='cms_moderation_item_to_collection', + name='cms_moderation_items_to_collection', language='en', args=() ), - _modal=1, + return_to_url='http://example.com', + version_ids=self.pg4_version.pk, + collection_id=self.collection1.pk ) response = self.client.post( path=url, data={ - 'collection': self.collection_1.pk, - 'version': self.pg_version.pk, - } - ) - - self.assertEqual(response.status_code, 200) - self.assertContains(response, 'reloadBrowser') - - moderation_request = ModerationRequest.objects.filter( - version=self.pg_version, - )[0] - - self.assertEqual(moderation_request.collection, self.collection_1) - - def test_version_object_to_collections_from_changelist(self): - ModerationRequest.objects.all().delete() - self.client.force_login(self.user) - url = get_admin_url( - name='cms_moderation_item_to_collection', - language='en', - args=() - ) - - with patch.object(views, 'version_list_url') as mock_: - response = self.client.post( - path=url, - data={ - 'collection': self.collection_1.pk, - 'version': self.pg_version.pk, - } - ) - - mock_.assert_called_with(self.pg_version.content) - self.assertEqual(response.status_code, 302) - - moderation_request = ModerationRequest.objects.filter( - version=self.pg_version, - )[0] - - self.assertEqual(moderation_request.collection, self.collection_1) - - def test_invalid_version_already_in_collection(self): - self.collection_1.add_version(self.pg_version) - self.assertEqual(1, ModerationRequest.objects.filter(version=self.pg_version).count()) - - self.client.force_login(self.user) - - response = self.client.post( - get_admin_url( - name='cms_moderation_item_to_collection', - language='en', - args=() - ), {'collection': self.collection_2.pk, - 'version': self.pg_version.pk}) - - self.assertEqual(response.status_code, 200) - self.assertIn( - "is already part of existing moderation request which is part", - response.context_data['form'].errors['version'][0] + 'collection': self.collection1.pk, + 'versions': self.pg4_version.pk, + }, + follow=False ) - self.assertEqual(1, ModerationRequest.objects.filter(version=self.pg_version).count()) - - # make the moderation request inactive, we will be able to submit it - self.collection_1.moderation_requests.all().update(is_active=False) - response = self.client.post( - get_admin_url( - name='cms_moderation_item_to_collection', - language='en', - args=() - ), {'collection': self.collection_2.pk, - 'version': self.pg_version.pk}) - - self.assertEqual(response.status_code, 302) - self.assertEqual(2, ModerationRequest.objects.filter(version=self.pg_version).count()) - - def test_non_existing_version(self): - self.client.force_login(self.user) - response = self.client.post( - get_admin_url( - name='cms_moderation_item_to_collection', - language='en', - args=() - ), {'collection': self.collection_1.pk, - 'version': 9000}) - self.assertEqual(response.status_code, 200) - self.assertIn('version', response.context_data['form'].errors) - - def test_prevent_locked_collections(self): - """ - from being selected when adding to collection - """ - ModerationRequest.objects.all().delete() - self.collection_1.status = constants.IN_REVIEW - self.collection_1.save() - self.client.force_login(self.user) - response = self.client.post( - get_admin_url( - name='cms_moderation_item_to_collection', - language='en', - args=() - ), {'collection': self.collection_1.pk, 'version': self.pg1_version.pk}) - - # locked collection are not part of the list - self.assertEqual( - "Select a valid choice. That choice is not one of the available choices.", - response.context_data['form'].errors['collection'][0] - ) + self.assertIn('versions', response.context['form'].errors) - def test_list_versions_from_collection_id_param(self): + def test_add_items_to_collection_no_return_url_set(self): ModerationRequest.objects.all().delete() - pg2_version = PageVersionFactory() - - self.collection_1.add_version(self.pg_version) - self.collection_2.add_version(pg2_version) - - self.client.force_login(self.user) - response = self.client.get( - add_url_parameters( - get_admin_url( - name='cms_moderation_item_to_collection', - language='en', - args=() - ), collection_id=self.collection_2.pk - ) - ) - - moderation_requests = ModerationRequest.objects.filter(collection=self.collection_2) - # moderation request is content_object - for mod_request in moderation_requests: - self.assertTrue(mod_request in response.context_data['moderation_request_list']) + pg_version = PageVersionFactory(created_by=self.user) - def test_version_id_from_params(self): - self.client.force_login(self.user) - response = self.client.get( - add_url_parameters( - get_admin_url( - name='cms_moderation_item_to_collection', - language='en', - args=() - ), version_id=self.pg1_version.pk - ) - ) - - form = response.context_data['form'] - self.assertEqual(self.pg1_version.pk, int(form.initial['version'])) - - def test_authenticated_users_only(self): - response = self.client.get( - get_admin_url( - name='cms_moderation_item_to_collection', - language='en', - args=() - ) - ) - - self.assertEqual(response.status_code, 302) - - -class CollectionItemsViewTest(BaseViewTestCase): - def test_no_suitable_items_to_add_to_collection(self): - """ - We try add pg4_version to a collection but expect it to fail as it is already party of a collection - """ - self.client.force_login(self.user) url = add_url_parameters( get_admin_url( name='cms_moderation_items_to_collection', language='en', args=() ), - return_to_url='http://example.com', - version_ids=self.pg4_version.pk, + # not return url specified + version_ids=pg_version.pk, collection_id=self.collection1.pk ) response = self.client.post( path=url, data={ 'collection': self.collection1.pk, - 'versions': self.pg4_version.pk, + 'versions': [pg_version.pk], }, follow=False ) + self.assertEqual(response.status_code, 200) - self.assertIn('versions', response.context['form'].errors) + self.assertContains(response, 'reloadBrowser') - def test_add_items_to_collection(self): - pg_version1 = PageVersionFactory(created_by=self.user) - pg_version2 = PageVersionFactory(created_by=self.user) + self.assertTrue( + ModerationRequest.objects.filter( + version=pg_version, collection=self.collection1 + ).exists() + ) + + def test_add_items_to_collection_return_url_set(self): ModerationRequest.objects.all().delete() - self.client.force_login(self.user) + pg1_version = PageVersionFactory(created_by=self.user) + pg2_version = PageVersionFactory(created_by=self.user) + + redirect_to_url = reverse('admin:djangocms_moderation_moderationcollection_changelist') url = add_url_parameters( get_admin_url( @@ -258,30 +89,67 @@ def test_add_items_to_collection(self): language='en', args=() ), - return_to_url='http://example.com', - version_ids=','.join(str(x) for x in [pg_version1.pk, pg_version2.pk]), + return_to_url=redirect_to_url, + version_ids=','.join(str(x) for x in [pg1_version.pk, pg2_version.pk]), collection_id=self.collection1.pk ) response = self.client.post( path=url, data={ 'collection': self.collection1.pk, - 'versions': [pg_version1.pk, pg_version2.pk], + 'versions': [pg1_version.pk, pg2_version.pk], }, follow=False ) - self.assertEqual(response.status_code, 302) + self.assertRedirects(response, redirect_to_url) - moderation_request = ModerationRequest.objects.get(version=pg_version1) + moderation_request = ModerationRequest.objects.get(version=pg1_version) self.assertEqual(moderation_request.collection, self.collection1) - moderation_request = ModerationRequest.objects.get(version=pg_version1) + moderation_request = ModerationRequest.objects.get(version=pg1_version) self.assertEqual(moderation_request.collection, self.collection1) messages = list(get_messages(response.wsgi_request)) - self.assertTrue( - '2 items successfully added to moderation collection' in [message.message for message in messages]) + self.assertIn( + '2 items successfully added to moderation collection', + [message.message for message in messages] + ) + + def test_list_versions_from_collection_id_param(self): + ModerationRequest.objects.all().delete() + + collection1 = ModerationCollection.objects.create( + author=self.user, name='My collection 1', workflow=self.wf1 + ) + collection2 = ModerationCollection.objects.create( + author=self.user, name='My collection 2', workflow=self.wf1 + ) + + pg1_version = PageVersionFactory(created_by=self.user) + pg2_version = PageVersionFactory(created_by=self.user) + + collection1.add_version(pg1_version) + collection2.add_version(pg2_version) + + new_version = PageVersionFactory(created_by=self.user) + url = add_url_parameters( + get_admin_url( + name='cms_moderation_items_to_collection', + language='en', + args=() + ), + return_to_url='http://example.com', + version_ids=new_version.pk, + collection_id=collection1.pk + ) + + response = self.client.get(url) + mr1 = ModerationRequest.objects.get(version=pg1_version, collection=collection1) + mr2 = ModerationRequest.objects.get(version=pg2_version, collection=collection2) + # mr1 is in the list as it belongs to collection1 + self.assertIn(mr1, response.context_data['moderation_requests']) + self.assertNotIn(mr2, response.context_data['moderation_requests']) class SubmitCollectionForModerationViewTest(BaseViewTestCase): From 780e16dce7183985db2c1a65db842ce4eaa43a75 Mon Sep 17 00:00:00 2001 From: Noel da Costa Date: Wed, 10 Oct 2018 14:42:06 +0100 Subject: [PATCH 053/147] Add moderation lock to checks framework (#86) --- djangocms_moderation/monkeypatch.py | 14 +++++++++ tests/test_monkeypatch.py | 44 ++++++++++++++++++++++++++++- 2 files changed, 57 insertions(+), 1 deletion(-) diff --git a/djangocms_moderation/monkeypatch.py b/djangocms_moderation/monkeypatch.py index d367ea01..5d29b532 100644 --- a/djangocms_moderation/monkeypatch.py +++ b/djangocms_moderation/monkeypatch.py @@ -1,6 +1,7 @@ from django.utils.html import format_html from django.utils.translation import ugettext_lazy as _ +from cms.models import fields from cms.utils.urlutils import add_url_parameters from djangocms_versioning.admin import VersionAdmin @@ -87,7 +88,20 @@ def inner(self, version, request, disabled=False): return inner +def _is_placeholder_review_unlocked(placeholder, user): + """ + Register review lock with placeholder checks framework to + prevent users from editing content by directly accessing the URL + """ + if is_registered_for_moderation(placeholder.source): + if is_obj_review_locked(placeholder.source, user): + return False + return True + + VersionAdmin.get_state_actions = get_state_actions(VersionAdmin.get_state_actions) VersionAdmin._get_edit_link = _get_edit_link(VersionAdmin._get_edit_link) VersionAdmin._get_archive_link = _get_archive_link(VersionAdmin._get_archive_link) VersionAdmin._get_moderation_link = _get_moderation_link + +fields.PlaceholderRelationField.default_checks += [_is_placeholder_review_unlocked] diff --git a/tests/test_monkeypatch.py b/tests/test_monkeypatch.py index f3db130e..1bf66f1b 100644 --- a/tests/test_monkeypatch.py +++ b/tests/test_monkeypatch.py @@ -4,11 +4,17 @@ from django.urls import reverse from cms.models import PageContent +from cms.models.fields import PlaceholderRelationField from djangocms_versioning import versionables from djangocms_versioning.admin import VersionAdmin from djangocms_versioning.constants import PUBLISHED -from djangocms_versioning.test_utils.factories import PageVersionFactory +from djangocms_versioning.test_utils.factories import ( + PageVersionFactory, + PlaceholderFactory, +) + +from djangocms_moderation.monkeypatch import _is_placeholder_review_unlocked from .utils.base import BaseTestCase, MockRequest @@ -131,3 +137,39 @@ def test_get_moderation_link_when_not_registered(self, mock_is_registered_for_mo self.pg1_version, self.mock_request ) self.assertEqual('', link) + + +class PlaceholderChecksTestCase(BaseTestCase): + + @mock.patch('djangocms_moderation.monkeypatch.is_registered_for_moderation') + @mock.patch('djangocms_moderation.monkeypatch.is_obj_review_locked') + def test_is_placeholder_review_unlocked(self, mock_is_registered_for_moderation, mock_is_obj_review_locked): + """ + Check that the monkeypatch returns expected value + """ + version = PageVersionFactory() + placeholder = PlaceholderFactory.create(source=version.content) + + mock_is_registered_for_moderation.return_value = True + mock_is_obj_review_locked.return_value = True + + self.assertFalse(_is_placeholder_review_unlocked(placeholder, self.user)) + + mock_is_registered_for_moderation.return_value = True + mock_is_obj_review_locked.return_value = False + + self.assertTrue(_is_placeholder_review_unlocked(placeholder, self.user)) + + mock_is_registered_for_moderation.return_value = False + mock_is_obj_review_locked.return_value = True + + self.assertTrue(_is_placeholder_review_unlocked(placeholder, self.user)) + + def test_function_added_to_checks_framework(self): + """ + Check that the method has been added to the checks framework + """ + self.assertIn( + _is_placeholder_review_unlocked, + PlaceholderRelationField.default_checks, + ) From d099de4121f3506c45011899453a2c966f14dad0 Mon Sep 17 00:00:00 2001 From: Damilare Onajole Date: Wed, 10 Oct 2018 17:47:33 +0100 Subject: [PATCH 054/147] Create collection menu (#88) --- djangocms_moderation/cms_toolbars.py | 17 +++++++++++++++ djangocms_moderation/constants.py | 3 ++- tests/test_cms_toolbars.py | 32 +++++++++++++++++++++++++--- 3 files changed, 48 insertions(+), 4 deletions(-) diff --git a/djangocms_moderation/cms_toolbars.py b/djangocms_moderation/cms_toolbars.py index 770ecd86..f2fbc0c9 100644 --- a/djangocms_moderation/cms_toolbars.py +++ b/djangocms_moderation/cms_toolbars.py @@ -3,6 +3,7 @@ from cms.toolbar_pool import toolbar_pool from cms.utils.urlutils import add_url_parameters +from cms.cms_toolbars import ADMIN_MENU_IDENTIFIER from djangocms_versioning.cms_toolbars import VersioningToolbar from djangocms_versioning.models import Version @@ -91,9 +92,25 @@ def _add_moderation_buttons(self): side=self.toolbar.RIGHT, ) + def _add_moderation_menu(self): + """ + Helper method to add moderation menu in the toolbar + """ + admin_menu = self.toolbar.get_or_create_menu(ADMIN_MENU_IDENTIFIER) + url = get_admin_url('djangocms_moderation_moderationcollection_changelist', + language=self.current_lang, + args=()) + url += '?author__id__exact=%s' % self.request.user.id + admin_menu.add_link_item( + _('Moderation collections'), + url=url, + position=3 + ) + def post_template_populate(self): super().post_template_populate() self._add_moderation_buttons() + self._add_moderation_menu() toolbar_pool.unregister(VersioningToolbar) diff --git a/djangocms_moderation/constants.py b/djangocms_moderation/constants.py index 17b800b2..40262ae4 100644 --- a/djangocms_moderation/constants.py +++ b/djangocms_moderation/constants.py @@ -43,7 +43,7 @@ CONTENT_TYPE_PLAIN = 'plain' CONTENT_TYPE_FORM = 'form' -# masks for Collectoin STATUS +# masks for Collection STATUS COLLECTING = 'COLLECTING' IN_REVIEW = 'IN_REVIEW' ARCHIVED = 'ARCHIVED' @@ -55,3 +55,4 @@ (ARCHIVED, _('Archived')), (CANCELLED, _('Cancelled')), ) + diff --git a/tests/test_cms_toolbars.py b/tests/test_cms_toolbars.py index b01cfea2..2d5d18db 100644 --- a/tests/test_cms_toolbars.py +++ b/tests/test_cms_toolbars.py @@ -1,6 +1,7 @@ import mock from django.test.client import RequestFactory +from django.urls import reverse from cms.middleware.toolbar import ToolbarMiddleware from cms.toolbar.toolbar import CMSToolbar @@ -28,9 +29,7 @@ def _get_page_request(self, page, user): request.toolbar.populate() return request - def _get_toolbar(self, content_obj, user=None, **kwargs): - """Helper method to set up the toolbar - """ + def _get_cms_toolbar(self, content_obj, user=None, **kwargs): if not user: user = UserFactory(is_staff=True) page = PageVersionFactory().content.page @@ -38,6 +37,13 @@ def _get_toolbar(self, content_obj, user=None, **kwargs): page=page, user=user ) cms_toolbar = CMSToolbar(request) + + return cms_toolbar, request + + def _get_toolbar(self, content_obj, user=None, **kwargs): + """Helper method to set up the toolbar + """ + cms_toolbar, request = self._get_cms_toolbar(content_obj, user, **kwargs) toolbar = ModerationToolbar( request, toolbar=cms_toolbar, is_current_app=True, app_path='/') toolbar.toolbar.set_object(content_obj) @@ -65,6 +71,12 @@ def _button_exists(self, button_name, toolbar): found = self._find_buttons(button_name, toolbar) return bool(len(found)) + def _find_menu_item(self, name, toolbar): + for left_item in toolbar.get_left_items(): + for menu_item in left_item.items: + if menu_item.name == name: + return menu_item + def test_submit_for_moderation_not_version_locked(self): ModerationRequest.objects.all().delete() version = PageVersionFactory(created_by=self.user) @@ -188,3 +200,17 @@ def test_add_edit_buttons_when_unregistered(self, mock_is_registered_for_moderat toolbar.post_template_populate() self.assertTrue(self._button_exists('Edit', toolbar.toolbar)) + + def test_add_manage_collection_item_to_moderation_menu(self): + version = PageVersionFactory(created_by=self.user) + toolbar, _ = self._get_cms_toolbar(version.content, preview_mode=True, user=self.user) + toolbar.populate() + toolbar.post_template_populate() + + manage_collection_item = self._find_menu_item('Moderation collections', toolbar) + self.assertIsNotNone(manage_collection_item) + + collection_list_url = reverse('admin:djangocms_moderation_moderationcollection_changelist') + collection_list_url += "?author__id__exact=%s" % self.user.pk + self.assertTrue(manage_collection_item.url, collection_list_url) + From da839b89983a8177e48fa1441a5a8b4adfafcc11 Mon Sep 17 00:00:00 2001 From: Noel da Costa Date: Thu, 11 Oct 2018 10:56:50 +0100 Subject: [PATCH 055/147] Changed icons (#89) --- .../templates/djangocms_moderation/edit_icon.html | 2 +- .../templates/djangocms_moderation/request_icon.html | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/djangocms_moderation/templates/djangocms_moderation/edit_icon.html b/djangocms_moderation/templates/djangocms_moderation/edit_icon.html index d4140b1a..d9ce7be6 100644 --- a/djangocms_moderation/templates/djangocms_moderation/edit_icon.html +++ b/djangocms_moderation/templates/djangocms_moderation/edit_icon.html @@ -1,2 +1,2 @@ {% load static i18n %} - + diff --git a/djangocms_moderation/templates/djangocms_moderation/request_icon.html b/djangocms_moderation/templates/djangocms_moderation/request_icon.html index 5521359a..a358b6b2 100644 --- a/djangocms_moderation/templates/djangocms_moderation/request_icon.html +++ b/djangocms_moderation/templates/djangocms_moderation/request_icon.html @@ -1,2 +1,2 @@ {% load static i18n %} - + From 85cda09824c2f59ffc210631a7f130d1e2ced675 Mon Sep 17 00:00:00 2001 From: Matus Moravcik Date: Fri, 12 Oct 2018 16:27:56 +0200 Subject: [PATCH 056/147] Submit for moderation button to be context aware (#90) --- djangocms_moderation/cms_toolbars.py | 13 +++-- djangocms_moderation/conf.py | 8 +++ djangocms_moderation/constants.py | 1 - djangocms_moderation/helpers.py | 34 +++++++++++++ djangocms_moderation/monkeypatch.py | 10 ++-- tests/test_cms_toolbars.py | 29 +++++++++-- tests/test_helpers.py | 76 ++++++++++++++++++++++++++++ tests/test_monkeypatch.py | 6 ++- 8 files changed, 161 insertions(+), 16 deletions(-) diff --git a/djangocms_moderation/cms_toolbars.py b/djangocms_moderation/cms_toolbars.py index f2fbc0c9..646c2895 100644 --- a/djangocms_moderation/cms_toolbars.py +++ b/djangocms_moderation/cms_toolbars.py @@ -1,15 +1,16 @@ # -*- coding: utf-8 -*- from django.utils.translation import ugettext_lazy as _ +from cms.cms_toolbars import ADMIN_MENU_IDENTIFIER from cms.toolbar_pool import toolbar_pool from cms.utils.urlutils import add_url_parameters -from cms.cms_toolbars import ADMIN_MENU_IDENTIFIER from djangocms_versioning.cms_toolbars import VersioningToolbar from djangocms_versioning.models import Version from .helpers import ( get_active_moderation_request, + get_moderation_button_title_and_url, is_obj_review_locked, is_obj_version_unlocked, is_registered_for_moderation, @@ -66,12 +67,10 @@ def _add_moderation_buttons(self): if self._is_versioned() and self.toolbar.edit_mode_active: moderation_request = get_active_moderation_request(self.toolbar.obj) if moderation_request: - self.toolbar.add_modal_button( - name=_('In Moderation "%(collection_name)s"') % { - 'collection_name': moderation_request.collection.name - }, - url='#', - disabled=True, + title, url = get_moderation_button_title_and_url(moderation_request) + self.toolbar.add_sideframe_button( + name=title, + url=url, side=self.toolbar.RIGHT, ) # Check if the object is not version locked to someone else diff --git a/djangocms_moderation/conf.py b/djangocms_moderation/conf.py index 9c9e6559..1b89e0ae 100644 --- a/djangocms_moderation/conf.py +++ b/djangocms_moderation/conf.py @@ -59,3 +59,11 @@ 'CMS_MODERATION_REQUEST_COMMENTS_ENABLED', True, ) + +# If the collection name length is above this limit, it will get truncated +# in the button texts. `None` means no limit +COLLECTION_NAME_LENGTH_LIMIT = getattr( + settings, + 'CMS_MODERATION_COLLECTION_NAME_LENGTH_LIMIT', + 24, +) diff --git a/djangocms_moderation/constants.py b/djangocms_moderation/constants.py index 40262ae4..e1c4800f 100644 --- a/djangocms_moderation/constants.py +++ b/djangocms_moderation/constants.py @@ -55,4 +55,3 @@ (ARCHIVED, _('Archived')), (CANCELLED, _('Cancelled')), ) - diff --git a/djangocms_moderation/helpers.py b/djangocms_moderation/helpers.py index 4b6aacd3..6775f131 100644 --- a/djangocms_moderation/helpers.py +++ b/djangocms_moderation/helpers.py @@ -1,8 +1,13 @@ from django.apps import apps from django.contrib.contenttypes.models import ContentType +from django.core.urlresolvers import reverse +from django.template.defaultfilters import truncatechars +from django.utils.translation import ugettext_lazy as _ from djangocms_versioning.models import Version +from .conf import COLLECTION_NAME_LENGTH_LIMIT +from .constants import COLLECTING from .models import ConfirmationFormSubmission @@ -81,3 +86,32 @@ def is_registered_for_moderation(content_object): moderation_config = apps.get_app_config('djangocms_moderation') moderated_models = moderation_config.cms_extension.moderated_models return content_object.__class__ in moderated_models + + +def get_moderation_button_title_and_url(moderation_request): + """ + Helper to get the moderation button title and url for an + existing active moderation request + :param moderation_request: + :return: title: , url: + """ + name_length_limit = COLLECTION_NAME_LENGTH_LIMIT + collection_name = moderation_request.collection.name + if name_length_limit: + collection_name = truncatechars(collection_name, name_length_limit) + + if moderation_request.collection.status == COLLECTING: + button_title = _('In collection "%(collection_name)s (%(collection_id)s)"') % { + 'collection_name': collection_name, + 'collection_id': moderation_request.collection_id + } + else: + button_title = _('In moderation "%(collection_name)s (%(collection_id)s)"') % { + 'collection_name': collection_name, + 'collection_id': moderation_request.collection_id + } + url = "{}?collection__id__exact={}".format( + reverse('admin:djangocms_moderation_moderationrequest_changelist'), + moderation_request.collection_id + ) + return button_title, url diff --git a/djangocms_moderation/monkeypatch.py b/djangocms_moderation/monkeypatch.py index 5d29b532..dfa33f1e 100644 --- a/djangocms_moderation/monkeypatch.py +++ b/djangocms_moderation/monkeypatch.py @@ -10,6 +10,7 @@ from .helpers import ( get_active_moderation_request, + get_moderation_button_title_and_url, is_obj_review_locked, is_obj_version_unlocked, is_registered_for_moderation, @@ -40,9 +41,12 @@ def _get_moderation_link(self, version, request): content_object = version.content moderation_request = get_active_moderation_request(content_object) if moderation_request: - return _('In Moderation "%(collection_name)s"') % { - 'collection_name': moderation_request.collection.name - } + title, url = get_moderation_button_title_and_url(moderation_request) + return format_html( + '{}', + url, + title + ) elif is_obj_version_unlocked(content_object, request.user): url = add_url_parameters( get_admin_url( diff --git a/tests/test_cms_toolbars.py b/tests/test_cms_toolbars.py index 2d5d18db..6d20afe0 100644 --- a/tests/test_cms_toolbars.py +++ b/tests/test_cms_toolbars.py @@ -12,6 +12,7 @@ ) from djangocms_moderation.cms_toolbars import ModerationToolbar +from djangocms_moderation.constants import IN_REVIEW from djangocms_moderation.models import ModerationRequest from .utils.base import BaseTestCase @@ -105,7 +106,7 @@ def test_submit_for_moderation_version_locked(self): # No Submit for moderation button has been added self.assertFalse(self._button_exists('Submit for moderation', toolbar.toolbar)) - def test_page_in_moderation(self): + def test_page_in_collection_collection(self): ModerationRequest.objects.all().delete() version = PageVersionFactory() self.collection1.add_version(version=version) @@ -114,7 +115,30 @@ def test_page_in_moderation(self): toolbar.populate() toolbar.post_template_populate() - self.assertTrue(self._button_exists('In Moderation "%s"' % self.collection1.name, toolbar.toolbar)) + self.assertTrue(self._button_exists( + 'In collection "{} ({})"'.format( + self.collection1.name, self.collection1.id + ), + toolbar.toolbar) + ) + + def test_page_in_collection_moderating(self): + ModerationRequest.objects.all().delete() + version = PageVersionFactory() + self.collection1.add_version(version=version) + self.collection1.status = IN_REVIEW + self.collection1.save() + + toolbar = self._get_toolbar(version.content, edit_mode=True) + toolbar.populate() + toolbar.post_template_populate() + + self.assertTrue(self._button_exists( + 'In moderation "{} ({})"'.format( + self.collection1.name, self.collection1.id + ), + toolbar.toolbar) + ) def test_add_edit_button_with_version_lock(self): """ @@ -213,4 +237,3 @@ def test_add_manage_collection_item_to_moderation_menu(self): collection_list_url = reverse('admin:djangocms_moderation_moderationcollection_changelist') collection_list_url += "?author__id__exact=%s" % self.user.pk self.assertTrue(manage_collection_item.url, collection_list_url) - diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 5d3a1ca2..b8b31d31 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -2,16 +2,22 @@ import mock from unittest import skip +from django.core.urlresolvers import reverse + from djangocms_versioning.test_utils.factories import PageVersionFactory +from djangocms_moderation.constants import COLLECTING, IN_REVIEW from djangocms_moderation.helpers import ( get_form_submission_for_step, + get_moderation_button_title_and_url, get_page_or_404, is_obj_version_unlocked, ) from djangocms_moderation.models import ( ConfirmationFormSubmission, ConfirmationPage, + ModerationCollection, + ModerationRequest, ) from .utils.base import BaseTestCase @@ -68,3 +74,73 @@ def test_is_obj_version_unlocked_when_locking_is_not_installed(self): _mock = None # noqa version = PageVersionFactory(created_by=self.user) self.assertTrue(is_obj_version_unlocked(version.content, self.user3)) + + +class ModerationButtonLinkAndUrlTestCase(BaseTestCase): + def setUp(self): + self.collection = ModerationCollection.objects.create( + author=self.user, name='C1', workflow=self.wf1, status=COLLECTING + ) + version = PageVersionFactory(created_by=self.user) + + self.collection.add_version(version) + + self.mr = ModerationRequest.objects.get( + version=version, collection=self.collection + ) + self.expected_url = "{}?collection__id__exact={}".format( + reverse('admin:djangocms_moderation_moderationrequest_changelist'), + self.collection.id, + ) + + def test_get_moderation_button_title_and_url_when_collection(self): + title, url = get_moderation_button_title_and_url(self.mr) + self.assertEqual( + title, + 'In collection "C1 ({})"'.format(self.collection.id) + ) + self.assertEqual(url, self.expected_url) + + def test_get_moderation_button_title_and_url_when_in_review(self): + self.collection.status = IN_REVIEW + self.collection.save() + + title, url = get_moderation_button_title_and_url(self.mr) + self.assertEqual( + title, + 'In moderation "C1 ({})"'.format(self.collection.id) + ) + self.assertEqual(url, self.expected_url) + + def test_get_moderation_button_truncated_title_and_url(self): + self.collection.name = 'Very long collection name so long wow!' + self.collection.save() + title, url = get_moderation_button_title_and_url(self.mr) + self.assertEqual( + title, + # By default, truncate will shorten the name + 'In collection "Very long collection ... ({})"'.format( + self.collection.id, + ) + ) + with mock.patch('djangocms_moderation.helpers.COLLECTION_NAME_LENGTH_LIMIT', 3): + title, url = get_moderation_button_title_and_url(self.mr) + self.assertEqual( + title, + # As the limit is only 3, the truncate will produce `...` + 'In collection "... ({})"'.format( + self.collection.id, + ) + ) + + with mock.patch('djangocms_moderation.helpers.COLLECTION_NAME_LENGTH_LIMIT', None): + # None means no limit + title, url = get_moderation_button_title_and_url(self.mr) + self.assertEqual( + title, + 'In collection "Very long collection name so long wow! ({})"'.format( + self.collection.id, + ) + ) + + self.assertEqual(url, self.expected_url) diff --git a/tests/test_monkeypatch.py b/tests/test_monkeypatch.py index 1bf66f1b..d7d5ff2a 100644 --- a/tests/test_monkeypatch.py +++ b/tests/test_monkeypatch.py @@ -104,8 +104,10 @@ def test_get_moderation_link(self): link = self.version_admin._get_moderation_link( self.pg1_version, self.mock_request ) - self.assertEqual( - 'In Moderation "{}"'.format(self.collection1.name), + self.assertIn( + 'In collection "{} ({})"'.format( + self.collection1.name, self.collection1.id + ), link ) version = PageVersionFactory(state=PUBLISHED) From f85f40b46fdb873987e86b7690c2e9f55dc58061 Mon Sep 17 00:00:00 2001 From: Matus Moravcik Date: Fri, 12 Oct 2018 17:22:54 +0200 Subject: [PATCH 057/147] Don't allow moderation request and collection to be deleted via admin (#91) --- djangocms_moderation/admin.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/djangocms_moderation/admin.py b/djangocms_moderation/admin.py index 949b9b43..81f4de0b 100644 --- a/djangocms_moderation/admin.py +++ b/djangocms_moderation/admin.py @@ -168,6 +168,14 @@ def get_version_author(self, obj): def has_add_permission(self, request): return False + def has_delete_permission(self, request, obj=None): + """ + Hide the delete button from the detail page + """ + if obj: + return False + return super().has_delete_permission(request, obj) + def get_actions(self, request): """ By default, all actions are enabled. But we need to only keep the actions @@ -527,14 +535,14 @@ def list_display_actions(self, obj): def get_list_display_actions(self): actions = [ - self._get_edit_link, + self.get_edit_link, self.get_requests_link, ] if conf.COLLECTION_COMMENTS_ENABLED: actions.append(self.get_comments_link) return actions - def _get_edit_link(self, obj): + def get_edit_link(self, obj): """Helper function to get the html link to the edit action """ url = reverse( @@ -615,6 +623,9 @@ def get_form(self, request, obj=None, **kwargs): form.base_fields['author'].widget = forms.HiddenInput() return form + def has_delete_permission(self, request, obj=None): + return False + class ConfirmationPageAdmin(PlaceholderAdminMixin, admin.ModelAdmin): view_on_site = True From de84c8f86f621db25b3752c4c13855bfe3ec2448 Mon Sep 17 00:00:00 2001 From: Matus Moravcik Date: Fri, 12 Oct 2018 17:24:16 +0200 Subject: [PATCH 058/147] Open Moderation collections menu item in the context of the page (#92) --- djangocms_moderation/cms_toolbars.py | 2 +- tests/test_cms_toolbars.py | 30 +++++++++++++--------------- 2 files changed, 15 insertions(+), 17 deletions(-) diff --git a/djangocms_moderation/cms_toolbars.py b/djangocms_moderation/cms_toolbars.py index 646c2895..ed809e5a 100644 --- a/djangocms_moderation/cms_toolbars.py +++ b/djangocms_moderation/cms_toolbars.py @@ -100,7 +100,7 @@ def _add_moderation_menu(self): language=self.current_lang, args=()) url += '?author__id__exact=%s' % self.request.user.id - admin_menu.add_link_item( + admin_menu.add_sideframe_item( _('Moderation collections'), url=url, position=3 diff --git a/tests/test_cms_toolbars.py b/tests/test_cms_toolbars.py index 6d20afe0..ec557609 100644 --- a/tests/test_cms_toolbars.py +++ b/tests/test_cms_toolbars.py @@ -30,23 +30,17 @@ def _get_page_request(self, page, user): request.toolbar.populate() return request - def _get_cms_toolbar(self, content_obj, user=None, **kwargs): + def _get_toolbar(self, content_obj, user=None, **kwargs): + """Helper method to set up the toolbar + """ if not user: user = UserFactory(is_staff=True) - page = PageVersionFactory().content.page request = self._get_page_request( - page=page, user=user + page=content_obj.page if content_obj else None, user=user ) cms_toolbar = CMSToolbar(request) - - return cms_toolbar, request - - def _get_toolbar(self, content_obj, user=None, **kwargs): - """Helper method to set up the toolbar - """ - cms_toolbar, request = self._get_cms_toolbar(content_obj, user, **kwargs) toolbar = ModerationToolbar( - request, toolbar=cms_toolbar, is_current_app=True, app_path='/') + cms_toolbar.request, toolbar=cms_toolbar, is_current_app=True, app_path='/') toolbar.toolbar.set_object(content_obj) if kwargs.get('edit_mode', False): toolbar.toolbar.edit_mode_active = True @@ -75,8 +69,12 @@ def _button_exists(self, button_name, toolbar): def _find_menu_item(self, name, toolbar): for left_item in toolbar.get_left_items(): for menu_item in left_item.items: - if menu_item.name == name: - return menu_item + try: + if menu_item.name == name: + return menu_item + # Break item has no attribute `name` + except AttributeError: + pass def test_submit_for_moderation_not_version_locked(self): ModerationRequest.objects.all().delete() @@ -227,11 +225,11 @@ def test_add_edit_buttons_when_unregistered(self, mock_is_registered_for_moderat def test_add_manage_collection_item_to_moderation_menu(self): version = PageVersionFactory(created_by=self.user) - toolbar, _ = self._get_cms_toolbar(version.content, preview_mode=True, user=self.user) + toolbar = self._get_toolbar(version.content, preview_mode=True, user=self.user) toolbar.populate() toolbar.post_template_populate() - - manage_collection_item = self._find_menu_item('Moderation collections', toolbar) + cms_toolbar = toolbar.toolbar + manage_collection_item = self._find_menu_item('Moderation collections...', cms_toolbar) self.assertIsNotNone(manage_collection_item) collection_list_url = reverse('admin:djangocms_moderation_moderationcollection_changelist') From 07c87a213ca32be19c4276634e101add7c33e0de Mon Sep 17 00:00:00 2001 From: Matus Moravcik Date: Mon, 15 Oct 2018 17:40:02 +0200 Subject: [PATCH 059/147] Fix overlapping comment admin labels (#93) --- djangocms_moderation/admin.py | 7 ++++++- .../css/comments_changelist.css | 15 +++++---------- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/djangocms_moderation/admin.py b/djangocms_moderation/admin.py index 81f4de0b..e78f8596 100644 --- a/djangocms_moderation/admin.py +++ b/djangocms_moderation/admin.py @@ -308,9 +308,14 @@ class RoleAdmin(admin.ModelAdmin): class CollectionCommentAdmin(admin.ModelAdmin): - list_display = ['message', 'author', 'date_created'] + list_display = ['date_created', 'message', 'author'] fields = ['collection', 'message', 'author'] + class Media: + css = { + 'all': ('djangocms_moderation/css/comments_changelist.css',) + } + def get_changeform_initial_data(self, request): data = { 'author': request.user, diff --git a/djangocms_moderation/static/djangocms_moderation/css/comments_changelist.css b/djangocms_moderation/static/djangocms_moderation/css/comments_changelist.css index 1bb9cba3..90b18637 100644 --- a/djangocms_moderation/static/djangocms_moderation/css/comments_changelist.css +++ b/djangocms_moderation/static/djangocms_moderation/css/comments_changelist.css @@ -1,21 +1,16 @@ td.field-message, th.column-message { - width: 65%; - overflow-wrap: break-word; - word-break: break-all; - white-space: wrap; overflow: hidden; text-overflow: ellipsis; } td.field-date_created, th.column-date_created { - width: 10%; + width: 150px; } -td.field-get_author, th.column-get_author { - width: 15%; +td.field-get_author, th.column-get_author, td.field-author, th.column-author { + width: 20%; } - #changelist-form table { - table-layout:fixed; -} + table-layout:fixed; +} From 07a2c7dbc5fdba2a1a951ee3fe54a6fe0f742797 Mon Sep 17 00:00:00 2001 From: Damilare Onajole Date: Wed, 17 Oct 2018 12:31:33 +0100 Subject: [PATCH 060/147] Use admin change view for content models without a placeholder (#94) --- djangocms_moderation/admin.py | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/djangocms_moderation/admin.py b/djangocms_moderation/admin.py index e78f8596..e2856081 100644 --- a/djangocms_moderation/admin.py +++ b/djangocms_moderation/admin.py @@ -12,6 +12,7 @@ from cms.admin.placeholderadmin import PlaceholderAdminMixin from cms.toolbar.utils import get_object_preview_url +from cms.utils.helpers import is_editable_model from adminsortable2.admin import SortableInlineAdminMixin @@ -144,11 +145,20 @@ def get_title(self, obj): get_title.short_description = _('Title') def get_preview_link(self, obj): + content = obj.version.content + if is_editable_model(content.__class__): + object_preview_url = get_object_preview_url(obj.version.content) + else: + object_preview_url = reverse('admin:{app}_{model}_change'.format( + app=content._meta.app_label, + model=content._meta.model_name, + ), args=[content.pk]) + return format_html( '' '' '', - get_object_preview_url(obj.version.content), + object_preview_url, ) get_preview_link.short_description = _('Preview') From 8ac48bfc7f52ccf7336945af08b2f84267d9cf74 Mon Sep 17 00:00:00 2001 From: Damilare Onajole Date: Mon, 22 Oct 2018 13:35:26 +0100 Subject: [PATCH 061/147] Disable changeview editing when content object is in moderation (#97) --- djangocms_moderation/monkeypatch.py | 24 +++++++++++++++++------- 1 file changed, 17 insertions(+), 7 deletions(-) diff --git a/djangocms_moderation/monkeypatch.py b/djangocms_moderation/monkeypatch.py index dfa33f1e..1ba68e91 100644 --- a/djangocms_moderation/monkeypatch.py +++ b/djangocms_moderation/monkeypatch.py @@ -4,18 +4,18 @@ from cms.models import fields from cms.utils.urlutils import add_url_parameters -from djangocms_versioning.admin import VersionAdmin +from djangocms_versioning import admin from djangocms_versioning.constants import DRAFT from djangocms_versioning.helpers import version_list_url -from .helpers import ( +from djangocms_moderation.helpers import ( get_active_moderation_request, get_moderation_button_title_and_url, is_obj_review_locked, is_obj_version_unlocked, is_registered_for_moderation, ) -from .utils import get_admin_url +from djangocms_moderation.utils import get_admin_url def get_state_actions(func): @@ -103,9 +103,19 @@ def _is_placeholder_review_unlocked(placeholder, user): return True -VersionAdmin.get_state_actions = get_state_actions(VersionAdmin.get_state_actions) -VersionAdmin._get_edit_link = _get_edit_link(VersionAdmin._get_edit_link) -VersionAdmin._get_archive_link = _get_archive_link(VersionAdmin._get_archive_link) -VersionAdmin._get_moderation_link = _get_moderation_link +def _can_modify_version(func): + def inner(self, version, user): + if is_registered_for_moderation(version.content): + if get_active_moderation_request(version.content): + return False + return func(self, version, user) + return inner + + +admin.VersionAdmin.get_state_actions = get_state_actions(admin.VersionAdmin.get_state_actions) +admin.VersionAdmin._get_edit_link = _get_edit_link(admin.VersionAdmin._get_edit_link) +admin.VersionAdmin._get_archive_link = _get_archive_link(admin.VersionAdmin._get_archive_link) +admin.VersionAdmin._get_moderation_link = _get_moderation_link +admin.VersioningAdminMixin._can_modify_version = _can_modify_version(admin.VersioningAdminMixin._can_modify_version) fields.PlaceholderRelationField.default_checks += [_is_placeholder_review_unlocked] From ebb66efa3eaecfebf08ab914262a71601107e53a Mon Sep 17 00:00:00 2001 From: Krzysztof Socha Date: Wed, 24 Oct 2018 16:24:58 +0100 Subject: [PATCH 062/147] Release 1.0.3 (#100) --- djangocms_moderation/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/djangocms_moderation/__init__.py b/djangocms_moderation/__init__.py index dc4c7752..75814343 100644 --- a/djangocms_moderation/__init__.py +++ b/djangocms_moderation/__init__.py @@ -1,3 +1,3 @@ -__version__ = '1.0.2' +__version__ = '1.0.3' default_app_config = 'djangocms_moderation.apps.ModerationConfig' From e5e7583b32cbccd4ba78a2c63c4c01f855c28760 Mon Sep 17 00:00:00 2001 From: Krzysztof Socha Date: Thu, 25 Oct 2018 10:04:24 +0100 Subject: [PATCH 063/147] Use versioning transition checks (#98) --- djangocms_moderation/admin.py | 4 ++-- djangocms_moderation/cms_toolbars.py | 9 ++++---- djangocms_moderation/monkeypatch.py | 34 +++++++++------------------- setup.cfg | 1 + tests/test_cms_toolbars.py | 23 ++++++++++++++----- tests/test_monkeypatch.py | 8 +++---- 6 files changed, 40 insertions(+), 39 deletions(-) diff --git a/djangocms_moderation/admin.py b/djangocms_moderation/admin.py index e2856081..b6078baa 100644 --- a/djangocms_moderation/admin.py +++ b/djangocms_moderation/admin.py @@ -601,12 +601,12 @@ def _url(regex, fn, name, **kwargs): url_patterns = [ _url( - '^(?P\d+)/submit-for-review/$', + r'^(?P\d+)/submit-for-review/$', views.submit_collection_for_moderation, name="cms_moderation_submit_collection_for_moderation", ), _url( - '^(?P\d+)/cancel-collection/$', + r'^(?P\d+)/cancel-collection/$', views.cancel_collection, name="cms_moderation_cancel_collection", ), diff --git a/djangocms_moderation/cms_toolbars.py b/djangocms_moderation/cms_toolbars.py index ed809e5a..35308aa1 100644 --- a/djangocms_moderation/cms_toolbars.py +++ b/djangocms_moderation/cms_toolbars.py @@ -2,10 +2,12 @@ from django.utils.translation import ugettext_lazy as _ from cms.cms_toolbars import ADMIN_MENU_IDENTIFIER -from cms.toolbar_pool import toolbar_pool from cms.utils.urlutils import add_url_parameters -from djangocms_versioning.cms_toolbars import VersioningToolbar +from djangocms_versioning.cms_toolbars import ( + VersioningToolbar, + replace_toolbar, +) from djangocms_versioning.models import Version from .helpers import ( @@ -112,5 +114,4 @@ def post_template_populate(self): self._add_moderation_menu() -toolbar_pool.unregister(VersioningToolbar) -toolbar_pool.register(ModerationToolbar) +replace_toolbar(VersioningToolbar, ModerationToolbar) diff --git a/djangocms_moderation/monkeypatch.py b/djangocms_moderation/monkeypatch.py index 1ba68e91..33afcd2e 100644 --- a/djangocms_moderation/monkeypatch.py +++ b/djangocms_moderation/monkeypatch.py @@ -4,7 +4,7 @@ from cms.models import fields from cms.utils.urlutils import add_url_parameters -from djangocms_versioning import admin +from djangocms_versioning import admin, models from djangocms_versioning.constants import DRAFT from djangocms_versioning.helpers import version_list_url @@ -79,19 +79,6 @@ def inner(self, version, request, disabled=False): return inner -def _get_archive_link(func): - """ - Don't display archive link if the object is in moderation - """ - def inner(self, version, request, disabled=False): - content_object = version.content - if is_registered_for_moderation(content_object): - if get_active_moderation_request(content_object): - disabled = True - return func(self, version, request, disabled) - return inner - - def _is_placeholder_review_unlocked(placeholder, user): """ Register review lock with placeholder checks framework to @@ -103,19 +90,20 @@ def _is_placeholder_review_unlocked(placeholder, user): return True -def _can_modify_version(func): - def inner(self, version, user): - if is_registered_for_moderation(version.content): - if get_active_moderation_request(version.content): - return False - return func(self, version, user) - return inner +def _is_version_review_locked(version, user): + if is_registered_for_moderation(version.content): + if is_obj_review_locked(version.content, user): + return False + return True admin.VersionAdmin.get_state_actions = get_state_actions(admin.VersionAdmin.get_state_actions) admin.VersionAdmin._get_edit_link = _get_edit_link(admin.VersionAdmin._get_edit_link) -admin.VersionAdmin._get_archive_link = _get_archive_link(admin.VersionAdmin._get_archive_link) admin.VersionAdmin._get_moderation_link = _get_moderation_link -admin.VersioningAdminMixin._can_modify_version = _can_modify_version(admin.VersioningAdminMixin._can_modify_version) + +models.Version.can_archive += [_is_version_review_locked] +models.Version.can_revert += [_is_version_review_locked] +models.Version.can_discard += [_is_version_review_locked] +models.Version.can_modify += [_is_version_review_locked] fields.PlaceholderRelationField.default_checks += [_is_placeholder_review_unlocked] diff --git a/setup.cfg b/setup.cfg index e0e74f81..4a1c2cf7 100644 --- a/setup.cfg +++ b/setup.cfg @@ -19,6 +19,7 @@ skip = manage.py, migrations, .tox, node_modules known_standard_library = mock known_django = django known_cms = cms, menus +known_third_party = djangocms_versioning known_first_party = djangocms_moderation sections = FUTURE, STDLIB, DJANGO, CMS, THIRDPARTY, FIRSTPARTY, LIB, LOCALFOLDER diff --git a/tests/test_cms_toolbars.py b/tests/test_cms_toolbars.py index ec557609..53831f09 100644 --- a/tests/test_cms_toolbars.py +++ b/tests/test_cms_toolbars.py @@ -56,14 +56,21 @@ def _get_toolbar(self, content_obj, user=None, **kwargs): toolbar.toolbar.structure_mode_active = False return toolbar - def _find_buttons(self, button_name, toolbar): + def _find_buttons(self, callable_or_name, toolbar): found = [] + + if callable(callable_or_name): + func = callable_or_name + else: + def func(button): + return button.name == callable_or_name + for button_list in toolbar.get_right_items(): - found = found + [button for button in button_list.buttons if button.name == button_name] + found = found + [button for button in button_list.buttons if func(button)] return found - def _button_exists(self, button_name, toolbar): - found = self._find_buttons(button_name, toolbar) + def _button_exists(self, callable_or_name, toolbar): + found = self._find_buttons(callable_or_name, toolbar) return bool(len(found)) def _find_menu_item(self, name, toolbar): @@ -161,9 +168,13 @@ def test_add_edit_button_with_version_lock(self): toolbar.populate() toolbar.post_template_populate() - self.assertTrue(self._button_exists('Edit', toolbar.toolbar)) + self.assertTrue(self._button_exists( + lambda button: button.name.endswith('Edit'), + toolbar.toolbar)) # Edit button should not be clickable - button = self._find_buttons('Edit', toolbar.toolbar) + button = self._find_buttons( + lambda button: button.name.endswith('Edit'), + toolbar.toolbar) self.assertTrue(button[0].disabled) def test_add_edit_button(self): diff --git a/tests/test_monkeypatch.py b/tests/test_monkeypatch.py index d7d5ff2a..b48460bc 100644 --- a/tests/test_monkeypatch.py +++ b/tests/test_monkeypatch.py @@ -8,7 +8,7 @@ from djangocms_versioning import versionables from djangocms_versioning.admin import VersionAdmin -from djangocms_versioning.constants import PUBLISHED +from djangocms_versioning.constants import DRAFT, PUBLISHED from djangocms_versioning.test_utils.factories import ( PageVersionFactory, PlaceholderFactory, @@ -59,12 +59,12 @@ def test_get_edit_link_not_moderation_registered(self, mock_is_obj_review_locked self.assertFalse(mock_is_obj_review_locked.called) self.assertNotEqual(edit_link, '') - @mock.patch('djangocms_moderation.monkeypatch.get_active_moderation_request') + @mock.patch('djangocms_moderation.monkeypatch.is_obj_review_locked') def test_get_archive_link(self, _mock): """ VersionAdmin should call moderation's version of _get_archive_link """ - version = PageVersionFactory(created_by=self.user) + version = PageVersionFactory(state=DRAFT, created_by=self.user) archive_url = reverse('admin:{app}_{model}version_archive'.format( app=version._meta.app_label, model=version.content._meta.model_name, @@ -76,7 +76,7 @@ def test_get_archive_link(self, _mock): ) # We test that moderation check is called when getting an edit link self.assertEqual(1, _mock.call_count) - # Edit link is inactive as `get_active_moderation_request` is True + # Edit link is inactive as `is_obj_review_locked` is True self.assertIn('inactive', archive_link) self.assertNotIn(archive_url, archive_link) From 4978f908a117d5975e6858713dff2cada6624c3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Bartosz=20P=C5=82=C3=B3ciennik?= Date: Thu, 25 Oct 2018 15:27:41 +0200 Subject: [PATCH 064/147] Fix getting content type for file with versioning (#99) --- djangocms_moderation/admin_actions.py | 27 +++++++++++++++++---------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/djangocms_moderation/admin_actions.py b/djangocms_moderation/admin_actions.py index 4e71095b..1f576a69 100644 --- a/djangocms_moderation/admin_actions.py +++ b/djangocms_moderation/admin_actions.py @@ -10,7 +10,9 @@ from cms.utils.urlutils import add_url_parameters from django_fsm import TransitionNotAllowed +from djangocms_versioning.helpers import get_content_types_with_subclasses from djangocms_versioning.models import Version +from filer.models import File, Image from djangocms_moderation import constants from djangocms_moderation.emails import ( @@ -235,24 +237,29 @@ def publish_selected(modeladmin, request, queryset): publish_selected.short_description = _("Publish selected requests") # noqa: E305 -def haystack_queryset_to_version_queryset(queryset): - """Returns the version objects corresponding to the objects in the Haystack queryset""" +def convert_queryset_to_version_queryset(queryset): + if not queryset: + return Version.objects.all() + id_map = defaultdict(list) for obj in queryset: - id_map[obj.model].append(obj.pk) + try: + id_map[obj.model].append(obj.pk) + except AttributeError: + id_map[obj._meta.model].append(obj.pk) q = Q() for obj_model, ids in id_map.items(): - ctype = ContentType.objects.get_for_model(obj_model) - q |= Q(content_type=ctype, object_id__in=ids) + if obj_model in [File, Image]: + ctype = get_content_types_with_subclasses([obj_model]) + else: + ctype = ContentType.objects.get_for_model(obj_model).pk + q |= Q(content_type_id=ctype, object_id__in=ids) return Version.objects.filter(q) def add_items_to_collection(modeladmin, request, queryset): - """ - Action to add queryset to moderation collection. Note that queryset is a - Haystack SearchQuerySet - """ - version_ids = haystack_queryset_to_version_queryset(queryset).values_list('pk', flat=True) + """Action to add queryset to moderation collection.""" + version_ids = convert_queryset_to_version_queryset(queryset).values_list('pk', flat=True) version_ids = [str(x) for x in version_ids] if version_ids: admin_url = add_url_parameters( From aa207ac2ee06e7ec36d2a2b916221f84ed32dc6f Mon Sep 17 00:00:00 2001 From: Krzysztof Socha Date: Thu, 25 Oct 2018 15:26:35 +0100 Subject: [PATCH 065/147] Release 1.0.4 (#101) --- djangocms_moderation/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/djangocms_moderation/__init__.py b/djangocms_moderation/__init__.py index 75814343..ad6d0208 100644 --- a/djangocms_moderation/__init__.py +++ b/djangocms_moderation/__init__.py @@ -1,3 +1,3 @@ -__version__ = '1.0.3' +__version__ = '1.0.4' default_app_config = 'djangocms_moderation.apps.ModerationConfig' From 0caa4dd0c8bf72221e081f796751ef56a86b48e4 Mon Sep 17 00:00:00 2001 From: Noel da Costa Date: Fri, 26 Oct 2018 11:26:48 +0100 Subject: [PATCH 066/147] Filter collection by reviewer and reviewer group (#95) --- djangocms_moderation/admin.py | 34 +++++++- djangocms_moderation/filters.py | 85 +++++++++++++++++++ djangocms_moderation/helpers.py | 11 +++ .../migrations/0012_auto_20181016_1319.py | 22 +++++ djangocms_moderation/models.py | 31 ++++++- tests/test_admin.py | 48 ++++++++++- tests/test_views.py | 2 +- tests/utils/base.py | 18 ++-- 8 files changed, 234 insertions(+), 17 deletions(-) create mode 100644 djangocms_moderation/filters.py create mode 100644 djangocms_moderation/migrations/0012_auto_20181016_1319.py diff --git a/djangocms_moderation/admin.py b/djangocms_moderation/admin.py index b6078baa..20f60e2d 100644 --- a/djangocms_moderation/admin.py +++ b/djangocms_moderation/admin.py @@ -3,8 +3,9 @@ from django import forms from django.conf.urls import url from django.contrib import admin +from django.contrib.auth import get_user_model from django.core.urlresolvers import reverse -from django.http import Http404 +from django.http import Http404, HttpResponseRedirect from django.shortcuts import get_object_or_404 from django.template.loader import render_to_string from django.utils.html import format_html, format_html_join @@ -13,6 +14,7 @@ from cms.admin.placeholderadmin import PlaceholderAdminMixin from cms.toolbar.utils import get_object_preview_url from cms.utils.helpers import is_editable_model +from cms.utils.urlutils import add_url_parameters from adminsortable2.admin import SortableInlineAdminMixin @@ -24,6 +26,7 @@ resubmit_selected, ) from .constants import ARCHIVED, COLLECTING, IN_REVIEW +from .filters import ModeratorFilter, ReviewerFilter from .forms import ( CollectionCommentForm, ModerationRequestActionInlineForm, @@ -46,9 +49,12 @@ from . import conf # isort:skip +from . import helpers # isort:skip from . import utils # isort:skip from . import views # isort:skip +User = get_user_model() + class ModerationRequestActionInline(admin.TabularInline): model = ModerationRequestAction @@ -520,9 +526,10 @@ class Media: actions = None # remove `delete_selected` for now, it will be handled later list_filter = [ - 'author', + ModeratorFilter, 'status', 'date_created', + ReviewerFilter, ] list_display_links = None @@ -533,6 +540,7 @@ def get_list_display(self, request): 'author', 'workflow', 'status', + 'reviewers', 'date_created', 'list_display_actions', ] @@ -641,6 +649,28 @@ def get_form(self, request, obj=None, **kwargs): def has_delete_permission(self, request, obj=None): return False + def changelist_view(self, request, extra_context=None): + """ + If reviewer filter is "All" and current user is reviewer + and there are moderation_request_actions assigned to that user + then set this filter to default to set that filter to show only this reviewer's + pending collections. This is done by redirecting with an adjusted querystring. + """ + if 'reviewer' not in request.GET: + if request.user in helpers.get_all_reviewers(): + querystring = request.GET.dict() + querystring['reviewer'] = request.user.pk + admin_url = add_url_parameters( + utils.get_admin_url( + name='djangocms_moderation_moderationcollection_changelist', + language=request.GET.get('language'), + args=() + ), + querystring + ) + return HttpResponseRedirect(admin_url) + return super().changelist_view(request, extra_context=extra_context) + class ConfirmationPageAdmin(PlaceholderAdminMixin, admin.ModelAdmin): view_on_site = True diff --git a/djangocms_moderation/filters.py b/djangocms_moderation/filters.py new file mode 100644 index 00000000..69888f72 --- /dev/null +++ b/djangocms_moderation/filters.py @@ -0,0 +1,85 @@ + +from django.contrib import admin +from django.contrib.auth import get_user_model +from django.db.models import Q +from django.utils.encoding import force_text +from django.utils.translation import ugettext_lazy as _ + +from . import constants, helpers + + +User = get_user_model() + + +class ModeratorFilter(admin.SimpleListFilter): + """ + Provides a moderator filter limited to those users who have authored collections + """ + title = _("moderator") + parameter_name = "moderator" + + def lookups(self, request, model_admin): + options = [] + moderators = User.objects.filter(moderationcollection__author__isnull=False).distinct() + for user in moderators: + options.append((force_text(user.pk), user.get_full_name() or user.get_username())) + return options + + def queryset(self, request, queryset): + if self.value(): + return queryset.filter( + author=self.value() + ).distinct() + return queryset + + +class ReviewerFilter(admin.SimpleListFilter): + title = _("reviewer") + parameter_name = "reviewer" + currentuser = {} + + def lookups(self, request, model_admin): + """ + Provides a reviewers filter if there are any reviewers + """ + self.currentuser = request.user + + options = [] + # collect all unique users from the three queries + for user in helpers.get_all_reviewers(): + options.append((force_text(user.pk), user.get_full_name() or user.get_username())) + return options + + def queryset(self, request, queryset): + if self.value() and self.value() != 'all': + result = queryset.filter( + Q(moderation_requests__actions__to_user=self.value()) | + # also include those that have been assigned to a role, instead of directly to a user + ( + # include any direct user assignments to actions + Q(moderation_requests__actions__to_user__isnull=True) + # exclude status COLLECTING as these will hot have reviewers assigned + & ~Q(status=constants.COLLECTING) + # include collections with group or role that matches current user + & ( + Q(workflow__steps__role__user=self.value()) | + Q(workflow__steps__role__group__user=self.value()) + ) + ) + ).distinct() + + return result + return queryset + + def choices(self, changelist): + yield { + 'selected': self.value() == 'all', + 'query_string': changelist.get_query_string({self.parameter_name: 'all'}, []), + 'display': _('All'), + } + for lookup, title in self.lookup_choices: + yield { + 'selected': self.value() == force_text(lookup), + 'query_string': changelist.get_query_string({self.parameter_name: lookup}, []), + 'display': title, + } diff --git a/djangocms_moderation/helpers.py b/djangocms_moderation/helpers.py index 6775f131..a9258c61 100644 --- a/djangocms_moderation/helpers.py +++ b/djangocms_moderation/helpers.py @@ -1,6 +1,8 @@ from django.apps import apps +from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.core.urlresolvers import reverse +from django.db.models import Q from django.template.defaultfilters import truncatechars from django.utils.translation import ugettext_lazy as _ @@ -11,6 +13,8 @@ from .models import ConfirmationFormSubmission +User = get_user_model() + try: from djangocms_version_locking.helpers import content_is_unlocked_for_user except ImportError: @@ -115,3 +119,10 @@ def get_moderation_button_title_and_url(moderation_request): moderation_request.collection_id ) return button_title, url + + +def get_all_reviewers(): + return User.objects.filter( + Q(groups__role__workflowstep__workflow__moderation_collections__isnull=False) | + Q(role__workflowstep__workflow__moderation_collections__isnull=False) + ).distinct() diff --git a/djangocms_moderation/migrations/0012_auto_20181016_1319.py b/djangocms_moderation/migrations/0012_auto_20181016_1319.py new file mode 100644 index 00000000..b8cb2b8b --- /dev/null +++ b/djangocms_moderation/migrations/0012_auto_20181016_1319.py @@ -0,0 +1,22 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.13 on 2018-10-16 12:19 +from __future__ import unicode_literals + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('djangocms_moderation', '0011_auto_20181008_1328'), + ] + + operations = [ + migrations.AlterField( + model_name='moderationrequestaction', + name='to_user', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='to user'), + ), + ] diff --git a/djangocms_moderation/models.py b/djangocms_moderation/models.py index 22204077..139f6d48 100644 --- a/djangocms_moderation/models.py +++ b/djangocms_moderation/models.py @@ -180,7 +180,6 @@ class WorkflowStep(models.Model): role = models.ForeignKey( to=Role, verbose_name=_('role'), - related_name='+', ) is_required = models.BooleanField( verbose_name=_('is mandatory'), @@ -228,7 +227,6 @@ class ModerationCollection(models.Model): author = models.ForeignKey( to=settings.AUTH_USER_MODEL, verbose_name=_('moderator'), - related_name='+', on_delete=models.CASCADE, ) workflow = models.ForeignKey( @@ -261,6 +259,30 @@ def job_id(self): def author_name(self): return self.author.get_full_name() or self.author.get_username() + @property + def reviewers(self): + """ + Find all the reviewers assigned to any moderationrequestaction associated with this collection. + If none are associated with a given action, + then get the role for the step in the workflow and include all reviewers within that list. + """ + reviewers = set() + moderation_requests = self.moderation_requests.all() + for mr in moderation_requests: + moderation_request_actions = mr.actions.all() + reviewers_in_actions = set() + for mra in moderation_request_actions: + if mra.to_user: + reviewers_in_actions.add(mra.to_user) + reviewers.add(mra.to_user) + + if not reviewers_in_actions and self.status != constants.COLLECTING: + role = self.workflow.first_step.role + users = role.get_users_queryset() + for user in users: + reviewers.add(user) + return ", ".join(map(get_user_model().get_full_name, reviewers)) + def allow_submit_for_review(self, user): """ Can this collection be submitted for review? @@ -560,7 +582,6 @@ class ModerationRequestAction(models.Model): verbose_name=_('to user'), blank=True, null=True, - related_name='+', on_delete=models.CASCADE, ) # Role which is next in the moderation flow @@ -613,9 +634,13 @@ def __str__(self): ) def get_by_user_name(self): + if not self.to_user: + return '' return self._get_user_name(self.by_user) def get_to_user_name(self): + if not self.to_user: + return '' return self._get_user_name(self.to_user) def _get_user_name(self, user): diff --git a/tests/test_admin.py b/tests/test_admin.py index b5f62c17..bd3c4b6f 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -3,6 +3,8 @@ from django.contrib.contenttypes.models import ContentType from django.urls import reverse +from cms.test_utils.testcases import CMSTestCase + from djangocms_versioning.test_utils import factories from djangocms_moderation import conf, constants @@ -14,6 +16,7 @@ from djangocms_moderation.models import ( ModerationCollection, ModerationRequest, + Role, Workflow, ) @@ -37,7 +40,7 @@ def setUp(self): self.wfst = self.wf.steps.create(role=self.role2, is_required=True, order=1,) # this moderation request is approved - self.mr1.actions.create(by_user=self.user, action=constants.ACTION_STARTED,) + self.mr1.actions.create(to_user=self.user2, by_user=self.user, action=constants.ACTION_STARTED,) self.mr1action2 = self.mr1.actions.create( by_user=self.user, to_user=self.user2, @@ -49,7 +52,7 @@ def setUp(self): self.mr2 = ModerationRequest.objects.create( version=pg2_version, language='en', collection=self.collection, is_active=True, author=self.collection.author,) - self.mr2.actions.create(by_user=self.user, action=constants.ACTION_STARTED,) + self.mr2.actions.create(to_user=self.user2, by_user=self.user, action=constants.ACTION_STARTED,) self.url = reverse('admin:djangocms_moderation_moderationrequest_changelist') self.url_with_filter = "{}?collection__id__exact={}".format( @@ -295,3 +298,44 @@ def test_get_readonly_fields_for_moderation_request_non_review_user(self): fields = mra_inline.get_readonly_fields(mra_inline, mock_request_author, self.mr1) self.assertListEqual(mra_inline.fields, fields) self.assertIn('message', fields) + + +class ModerationReviewerAdminTestCase(CMSTestCase): + def test_moderation_collection_changelist_reviewer_filter(self): + + user = User.objects.create_user( + username='test_reviewer', email='test_reviewer@test.com', password='test_reviewer', is_staff=True) + + user2 = User.objects.create_user( + username='test_non_reviewer', email='test_non_reviewer@test.com', + password='test_non_reviewer', is_staff=True, is_superuser=True) + role = Role.objects.create(name='Role Review', user=user,) + pg = factories.PageVersionFactory() + wf = Workflow.objects.create(name='Workflow Review Test',) + collection = ModerationCollection.objects.create( + author=user, name='Collection Admin Actions Review', workflow=wf, status=constants.IN_REVIEW + ) + + mr = ModerationRequest.objects.create( + version=pg, language='en', collection=collection, + is_active=True, author=collection.author,) + + wfst = wf.steps.create(role=role, is_required=True, order=1,) + + # this moderation request is approved + mr.actions.create(to_user=user, by_user=user, action=constants.ACTION_STARTED,) + mr.actions.create( + by_user=user, + to_user=user, + action=constants.ACTION_APPROVED, + step_approved=wfst, + ) + + url = reverse('admin:djangocms_moderation_moderationcollection_changelist') + with self.login_user_context(user2): + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + self.assertFalse(response.wsgi_request.GET) + with self.login_user_context(user): + response = self.client.get(url) + self.assertEqual(response.status_code, 302) diff --git a/tests/test_views.py b/tests/test_views.py index 6021365a..0617a137 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -102,7 +102,7 @@ def test_add_items_to_collection_return_url_set(self): follow=False ) - self.assertRedirects(response, redirect_to_url) + self.assertEqual(response.status_code, 302) moderation_request = ModerationRequest.objects.get(version=pg1_version) self.assertEqual(moderation_request.collection, self.collection1) diff --git a/tests/utils/base.py b/tests/utils/base.py index 593a1a6c..ecb233e1 100644 --- a/tests/utils/base.py +++ b/tests/utils/base.py @@ -80,7 +80,7 @@ def setUpTestData(cls): cls.moderation_request1 = ModerationRequest.objects.create( version=cls.pg1_version, language='en', collection=cls.collection1, is_active=True, author=cls.collection1.author,) - cls.moderation_request1.actions.create(by_user=cls.user, action=constants.ACTION_STARTED,) + cls.moderation_request1.actions.create(to_user=cls.user2, by_user=cls.user, action=constants.ACTION_STARTED,) ModerationRequest.objects.create( version=cls.pg3_version, language='en', collection=cls.collection1, @@ -93,19 +93,19 @@ def setUpTestData(cls): version=cls.pg3_version, language='en', collection=cls.collection2, is_active=True, author=cls.collection2.author) cls.moderation_request2.actions.create( - by_user=cls.user, action=constants.ACTION_STARTED,) + to_user=cls.user2, by_user=cls.user, action=constants.ACTION_STARTED,) cls.moderation_request2.actions.create( - by_user=cls.user, action=constants.ACTION_APPROVED, step_approved=cls.wf2st1,) + to_user=cls.user2, by_user=cls.user, action=constants.ACTION_APPROVED, step_approved=cls.wf2st1,) cls.moderation_request2.actions.create( - by_user=cls.user, action=constants.ACTION_APPROVED, step_approved=cls.wf2st2,) + to_user=cls.user2, by_user=cls.user, action=constants.ACTION_APPROVED, step_approved=cls.wf2st2,) cls.moderation_request3 = ModerationRequest.objects.create( version=cls.pg4_version, language='en', collection=cls.collection3, is_active=True, author=cls.collection3.author,) - cls.moderation_request3.actions.create(by_user=cls.user, action=constants.ACTION_STARTED,) + cls.moderation_request3.actions.create(to_user=cls.user2, by_user=cls.user, action=constants.ACTION_STARTED,) cls.moderation_request3.actions.create( - by_user=cls.user, to_user=cls.user2, + by_user=cls.user, action=constants.ACTION_APPROVED, step_approved=cls.wf3st1, ) @@ -113,13 +113,13 @@ def setUpTestData(cls): cls.moderation_request4 = ModerationRequest.objects.create( version=cls.pg5_version, language='en', collection=cls.collection3, is_active=True, author=cls.collection3.author,) - cls.moderation_request4.actions.create(by_user=cls.user, action=constants.ACTION_STARTED,) - cls.moderation_request4.actions.create(by_user=cls.user2, action=constants.ACTION_REJECTED) + cls.moderation_request4.actions.create(to_user=cls.user2, by_user=cls.user, action=constants.ACTION_STARTED,) + cls.moderation_request4.actions.create(to_user=cls.user2, by_user=cls.user3, action=constants.ACTION_REJECTED) cls.moderation_request5 = ModerationRequest.objects.create( version=cls.pg6_version, language='en', collection=cls.collection4, is_active=True, author=cls.collection4.author,) - cls.moderation_request5.actions.create(by_user=cls.user, action=constants.ACTION_STARTED,) + cls.moderation_request5.actions.create(to_user=cls.user2, by_user=cls.user, action=constants.ACTION_STARTED,) class BaseViewTestCase(BaseTestCase): From f48c78a1bc3a3fcee5c6d0302c2a12f2e132d73d Mon Sep 17 00:00:00 2001 From: Krzysztof Socha Date: Fri, 26 Oct 2018 15:07:34 +0100 Subject: [PATCH 067/147] Revert "Filter collection by reviewer and reviewer group (#95)" (#104) 0caa4dd0c8bf72221e081f796751ef56a86b48e4 --- djangocms_moderation/admin.py | 34 +------- djangocms_moderation/filters.py | 85 ------------------- djangocms_moderation/helpers.py | 11 --- .../migrations/0012_auto_20181016_1319.py | 22 ----- djangocms_moderation/models.py | 31 +------ tests/test_admin.py | 48 +---------- tests/test_views.py | 2 +- tests/utils/base.py | 18 ++-- 8 files changed, 17 insertions(+), 234 deletions(-) delete mode 100644 djangocms_moderation/filters.py delete mode 100644 djangocms_moderation/migrations/0012_auto_20181016_1319.py diff --git a/djangocms_moderation/admin.py b/djangocms_moderation/admin.py index 20f60e2d..b6078baa 100644 --- a/djangocms_moderation/admin.py +++ b/djangocms_moderation/admin.py @@ -3,9 +3,8 @@ from django import forms from django.conf.urls import url from django.contrib import admin -from django.contrib.auth import get_user_model from django.core.urlresolvers import reverse -from django.http import Http404, HttpResponseRedirect +from django.http import Http404 from django.shortcuts import get_object_or_404 from django.template.loader import render_to_string from django.utils.html import format_html, format_html_join @@ -14,7 +13,6 @@ from cms.admin.placeholderadmin import PlaceholderAdminMixin from cms.toolbar.utils import get_object_preview_url from cms.utils.helpers import is_editable_model -from cms.utils.urlutils import add_url_parameters from adminsortable2.admin import SortableInlineAdminMixin @@ -26,7 +24,6 @@ resubmit_selected, ) from .constants import ARCHIVED, COLLECTING, IN_REVIEW -from .filters import ModeratorFilter, ReviewerFilter from .forms import ( CollectionCommentForm, ModerationRequestActionInlineForm, @@ -49,12 +46,9 @@ from . import conf # isort:skip -from . import helpers # isort:skip from . import utils # isort:skip from . import views # isort:skip -User = get_user_model() - class ModerationRequestActionInline(admin.TabularInline): model = ModerationRequestAction @@ -526,10 +520,9 @@ class Media: actions = None # remove `delete_selected` for now, it will be handled later list_filter = [ - ModeratorFilter, + 'author', 'status', 'date_created', - ReviewerFilter, ] list_display_links = None @@ -540,7 +533,6 @@ def get_list_display(self, request): 'author', 'workflow', 'status', - 'reviewers', 'date_created', 'list_display_actions', ] @@ -649,28 +641,6 @@ def get_form(self, request, obj=None, **kwargs): def has_delete_permission(self, request, obj=None): return False - def changelist_view(self, request, extra_context=None): - """ - If reviewer filter is "All" and current user is reviewer - and there are moderation_request_actions assigned to that user - then set this filter to default to set that filter to show only this reviewer's - pending collections. This is done by redirecting with an adjusted querystring. - """ - if 'reviewer' not in request.GET: - if request.user in helpers.get_all_reviewers(): - querystring = request.GET.dict() - querystring['reviewer'] = request.user.pk - admin_url = add_url_parameters( - utils.get_admin_url( - name='djangocms_moderation_moderationcollection_changelist', - language=request.GET.get('language'), - args=() - ), - querystring - ) - return HttpResponseRedirect(admin_url) - return super().changelist_view(request, extra_context=extra_context) - class ConfirmationPageAdmin(PlaceholderAdminMixin, admin.ModelAdmin): view_on_site = True diff --git a/djangocms_moderation/filters.py b/djangocms_moderation/filters.py deleted file mode 100644 index 69888f72..00000000 --- a/djangocms_moderation/filters.py +++ /dev/null @@ -1,85 +0,0 @@ - -from django.contrib import admin -from django.contrib.auth import get_user_model -from django.db.models import Q -from django.utils.encoding import force_text -from django.utils.translation import ugettext_lazy as _ - -from . import constants, helpers - - -User = get_user_model() - - -class ModeratorFilter(admin.SimpleListFilter): - """ - Provides a moderator filter limited to those users who have authored collections - """ - title = _("moderator") - parameter_name = "moderator" - - def lookups(self, request, model_admin): - options = [] - moderators = User.objects.filter(moderationcollection__author__isnull=False).distinct() - for user in moderators: - options.append((force_text(user.pk), user.get_full_name() or user.get_username())) - return options - - def queryset(self, request, queryset): - if self.value(): - return queryset.filter( - author=self.value() - ).distinct() - return queryset - - -class ReviewerFilter(admin.SimpleListFilter): - title = _("reviewer") - parameter_name = "reviewer" - currentuser = {} - - def lookups(self, request, model_admin): - """ - Provides a reviewers filter if there are any reviewers - """ - self.currentuser = request.user - - options = [] - # collect all unique users from the three queries - for user in helpers.get_all_reviewers(): - options.append((force_text(user.pk), user.get_full_name() or user.get_username())) - return options - - def queryset(self, request, queryset): - if self.value() and self.value() != 'all': - result = queryset.filter( - Q(moderation_requests__actions__to_user=self.value()) | - # also include those that have been assigned to a role, instead of directly to a user - ( - # include any direct user assignments to actions - Q(moderation_requests__actions__to_user__isnull=True) - # exclude status COLLECTING as these will hot have reviewers assigned - & ~Q(status=constants.COLLECTING) - # include collections with group or role that matches current user - & ( - Q(workflow__steps__role__user=self.value()) | - Q(workflow__steps__role__group__user=self.value()) - ) - ) - ).distinct() - - return result - return queryset - - def choices(self, changelist): - yield { - 'selected': self.value() == 'all', - 'query_string': changelist.get_query_string({self.parameter_name: 'all'}, []), - 'display': _('All'), - } - for lookup, title in self.lookup_choices: - yield { - 'selected': self.value() == force_text(lookup), - 'query_string': changelist.get_query_string({self.parameter_name: lookup}, []), - 'display': title, - } diff --git a/djangocms_moderation/helpers.py b/djangocms_moderation/helpers.py index a9258c61..6775f131 100644 --- a/djangocms_moderation/helpers.py +++ b/djangocms_moderation/helpers.py @@ -1,8 +1,6 @@ from django.apps import apps -from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.core.urlresolvers import reverse -from django.db.models import Q from django.template.defaultfilters import truncatechars from django.utils.translation import ugettext_lazy as _ @@ -13,8 +11,6 @@ from .models import ConfirmationFormSubmission -User = get_user_model() - try: from djangocms_version_locking.helpers import content_is_unlocked_for_user except ImportError: @@ -119,10 +115,3 @@ def get_moderation_button_title_and_url(moderation_request): moderation_request.collection_id ) return button_title, url - - -def get_all_reviewers(): - return User.objects.filter( - Q(groups__role__workflowstep__workflow__moderation_collections__isnull=False) | - Q(role__workflowstep__workflow__moderation_collections__isnull=False) - ).distinct() diff --git a/djangocms_moderation/migrations/0012_auto_20181016_1319.py b/djangocms_moderation/migrations/0012_auto_20181016_1319.py deleted file mode 100644 index b8cb2b8b..00000000 --- a/djangocms_moderation/migrations/0012_auto_20181016_1319.py +++ /dev/null @@ -1,22 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by Django 1.11.13 on 2018-10-16 12:19 -from __future__ import unicode_literals - -from django.conf import settings -from django.db import migrations, models -import django.db.models.deletion - - -class Migration(migrations.Migration): - - dependencies = [ - ('djangocms_moderation', '0011_auto_20181008_1328'), - ] - - operations = [ - migrations.AlterField( - model_name='moderationrequestaction', - name='to_user', - field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, verbose_name='to user'), - ), - ] diff --git a/djangocms_moderation/models.py b/djangocms_moderation/models.py index 139f6d48..22204077 100644 --- a/djangocms_moderation/models.py +++ b/djangocms_moderation/models.py @@ -180,6 +180,7 @@ class WorkflowStep(models.Model): role = models.ForeignKey( to=Role, verbose_name=_('role'), + related_name='+', ) is_required = models.BooleanField( verbose_name=_('is mandatory'), @@ -227,6 +228,7 @@ class ModerationCollection(models.Model): author = models.ForeignKey( to=settings.AUTH_USER_MODEL, verbose_name=_('moderator'), + related_name='+', on_delete=models.CASCADE, ) workflow = models.ForeignKey( @@ -259,30 +261,6 @@ def job_id(self): def author_name(self): return self.author.get_full_name() or self.author.get_username() - @property - def reviewers(self): - """ - Find all the reviewers assigned to any moderationrequestaction associated with this collection. - If none are associated with a given action, - then get the role for the step in the workflow and include all reviewers within that list. - """ - reviewers = set() - moderation_requests = self.moderation_requests.all() - for mr in moderation_requests: - moderation_request_actions = mr.actions.all() - reviewers_in_actions = set() - for mra in moderation_request_actions: - if mra.to_user: - reviewers_in_actions.add(mra.to_user) - reviewers.add(mra.to_user) - - if not reviewers_in_actions and self.status != constants.COLLECTING: - role = self.workflow.first_step.role - users = role.get_users_queryset() - for user in users: - reviewers.add(user) - return ", ".join(map(get_user_model().get_full_name, reviewers)) - def allow_submit_for_review(self, user): """ Can this collection be submitted for review? @@ -582,6 +560,7 @@ class ModerationRequestAction(models.Model): verbose_name=_('to user'), blank=True, null=True, + related_name='+', on_delete=models.CASCADE, ) # Role which is next in the moderation flow @@ -634,13 +613,9 @@ def __str__(self): ) def get_by_user_name(self): - if not self.to_user: - return '' return self._get_user_name(self.by_user) def get_to_user_name(self): - if not self.to_user: - return '' return self._get_user_name(self.to_user) def _get_user_name(self, user): diff --git a/tests/test_admin.py b/tests/test_admin.py index bd3c4b6f..b5f62c17 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -3,8 +3,6 @@ from django.contrib.contenttypes.models import ContentType from django.urls import reverse -from cms.test_utils.testcases import CMSTestCase - from djangocms_versioning.test_utils import factories from djangocms_moderation import conf, constants @@ -16,7 +14,6 @@ from djangocms_moderation.models import ( ModerationCollection, ModerationRequest, - Role, Workflow, ) @@ -40,7 +37,7 @@ def setUp(self): self.wfst = self.wf.steps.create(role=self.role2, is_required=True, order=1,) # this moderation request is approved - self.mr1.actions.create(to_user=self.user2, by_user=self.user, action=constants.ACTION_STARTED,) + self.mr1.actions.create(by_user=self.user, action=constants.ACTION_STARTED,) self.mr1action2 = self.mr1.actions.create( by_user=self.user, to_user=self.user2, @@ -52,7 +49,7 @@ def setUp(self): self.mr2 = ModerationRequest.objects.create( version=pg2_version, language='en', collection=self.collection, is_active=True, author=self.collection.author,) - self.mr2.actions.create(to_user=self.user2, by_user=self.user, action=constants.ACTION_STARTED,) + self.mr2.actions.create(by_user=self.user, action=constants.ACTION_STARTED,) self.url = reverse('admin:djangocms_moderation_moderationrequest_changelist') self.url_with_filter = "{}?collection__id__exact={}".format( @@ -298,44 +295,3 @@ def test_get_readonly_fields_for_moderation_request_non_review_user(self): fields = mra_inline.get_readonly_fields(mra_inline, mock_request_author, self.mr1) self.assertListEqual(mra_inline.fields, fields) self.assertIn('message', fields) - - -class ModerationReviewerAdminTestCase(CMSTestCase): - def test_moderation_collection_changelist_reviewer_filter(self): - - user = User.objects.create_user( - username='test_reviewer', email='test_reviewer@test.com', password='test_reviewer', is_staff=True) - - user2 = User.objects.create_user( - username='test_non_reviewer', email='test_non_reviewer@test.com', - password='test_non_reviewer', is_staff=True, is_superuser=True) - role = Role.objects.create(name='Role Review', user=user,) - pg = factories.PageVersionFactory() - wf = Workflow.objects.create(name='Workflow Review Test',) - collection = ModerationCollection.objects.create( - author=user, name='Collection Admin Actions Review', workflow=wf, status=constants.IN_REVIEW - ) - - mr = ModerationRequest.objects.create( - version=pg, language='en', collection=collection, - is_active=True, author=collection.author,) - - wfst = wf.steps.create(role=role, is_required=True, order=1,) - - # this moderation request is approved - mr.actions.create(to_user=user, by_user=user, action=constants.ACTION_STARTED,) - mr.actions.create( - by_user=user, - to_user=user, - action=constants.ACTION_APPROVED, - step_approved=wfst, - ) - - url = reverse('admin:djangocms_moderation_moderationcollection_changelist') - with self.login_user_context(user2): - response = self.client.get(url) - self.assertEqual(response.status_code, 200) - self.assertFalse(response.wsgi_request.GET) - with self.login_user_context(user): - response = self.client.get(url) - self.assertEqual(response.status_code, 302) diff --git a/tests/test_views.py b/tests/test_views.py index 0617a137..6021365a 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -102,7 +102,7 @@ def test_add_items_to_collection_return_url_set(self): follow=False ) - self.assertEqual(response.status_code, 302) + self.assertRedirects(response, redirect_to_url) moderation_request = ModerationRequest.objects.get(version=pg1_version) self.assertEqual(moderation_request.collection, self.collection1) diff --git a/tests/utils/base.py b/tests/utils/base.py index ecb233e1..593a1a6c 100644 --- a/tests/utils/base.py +++ b/tests/utils/base.py @@ -80,7 +80,7 @@ def setUpTestData(cls): cls.moderation_request1 = ModerationRequest.objects.create( version=cls.pg1_version, language='en', collection=cls.collection1, is_active=True, author=cls.collection1.author,) - cls.moderation_request1.actions.create(to_user=cls.user2, by_user=cls.user, action=constants.ACTION_STARTED,) + cls.moderation_request1.actions.create(by_user=cls.user, action=constants.ACTION_STARTED,) ModerationRequest.objects.create( version=cls.pg3_version, language='en', collection=cls.collection1, @@ -93,19 +93,19 @@ def setUpTestData(cls): version=cls.pg3_version, language='en', collection=cls.collection2, is_active=True, author=cls.collection2.author) cls.moderation_request2.actions.create( - to_user=cls.user2, by_user=cls.user, action=constants.ACTION_STARTED,) + by_user=cls.user, action=constants.ACTION_STARTED,) cls.moderation_request2.actions.create( - to_user=cls.user2, by_user=cls.user, action=constants.ACTION_APPROVED, step_approved=cls.wf2st1,) + by_user=cls.user, action=constants.ACTION_APPROVED, step_approved=cls.wf2st1,) cls.moderation_request2.actions.create( - to_user=cls.user2, by_user=cls.user, action=constants.ACTION_APPROVED, step_approved=cls.wf2st2,) + by_user=cls.user, action=constants.ACTION_APPROVED, step_approved=cls.wf2st2,) cls.moderation_request3 = ModerationRequest.objects.create( version=cls.pg4_version, language='en', collection=cls.collection3, is_active=True, author=cls.collection3.author,) - cls.moderation_request3.actions.create(to_user=cls.user2, by_user=cls.user, action=constants.ACTION_STARTED,) + cls.moderation_request3.actions.create(by_user=cls.user, action=constants.ACTION_STARTED,) cls.moderation_request3.actions.create( - to_user=cls.user2, by_user=cls.user, + to_user=cls.user2, action=constants.ACTION_APPROVED, step_approved=cls.wf3st1, ) @@ -113,13 +113,13 @@ def setUpTestData(cls): cls.moderation_request4 = ModerationRequest.objects.create( version=cls.pg5_version, language='en', collection=cls.collection3, is_active=True, author=cls.collection3.author,) - cls.moderation_request4.actions.create(to_user=cls.user2, by_user=cls.user, action=constants.ACTION_STARTED,) - cls.moderation_request4.actions.create(to_user=cls.user2, by_user=cls.user3, action=constants.ACTION_REJECTED) + cls.moderation_request4.actions.create(by_user=cls.user, action=constants.ACTION_STARTED,) + cls.moderation_request4.actions.create(by_user=cls.user2, action=constants.ACTION_REJECTED) cls.moderation_request5 = ModerationRequest.objects.create( version=cls.pg6_version, language='en', collection=cls.collection4, is_active=True, author=cls.collection4.author,) - cls.moderation_request5.actions.create(to_user=cls.user2, by_user=cls.user, action=constants.ACTION_STARTED,) + cls.moderation_request5.actions.create(by_user=cls.user, action=constants.ACTION_STARTED,) class BaseViewTestCase(BaseTestCase): From 19abada339e1057dfe8de7c615f1f805badf4698 Mon Sep 17 00:00:00 2001 From: Mateusz Kamycki Date: Fri, 26 Oct 2018 17:04:50 +0200 Subject: [PATCH 068/147] Fix `convert_queryset_to_version_queryset` to support polymorphic/model inheritance (#102) --- djangocms_moderation/admin_actions.py | 30 +++++++++++++++++---------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/djangocms_moderation/admin_actions.py b/djangocms_moderation/admin_actions.py index 1f576a69..69748e3e 100644 --- a/djangocms_moderation/admin_actions.py +++ b/djangocms_moderation/admin_actions.py @@ -10,9 +10,7 @@ from cms.utils.urlutils import add_url_parameters from django_fsm import TransitionNotAllowed -from djangocms_versioning.helpers import get_content_types_with_subclasses from djangocms_versioning.models import Version -from filer.models import File, Image from djangocms_moderation import constants from djangocms_moderation.emails import ( @@ -239,20 +237,30 @@ def publish_selected(modeladmin, request, queryset): def convert_queryset_to_version_queryset(queryset): if not queryset: - return Version.objects.all() + return Version.objects.none() id_map = defaultdict(list) for obj in queryset: - try: - id_map[obj.model].append(obj.pk) - except AttributeError: - id_map[obj._meta.model].append(obj.pk) + model = getattr(obj, 'model', obj._meta.model) + + from django.db.models.base import ModelBase, Model + model_bases = [ModelBase, Model] + if hasattr(model, 'polymorphic_ctype_id'): + from polymorphic.base import PolymorphicModelBase + model_bases.append(PolymorphicModelBase) + model = next( + m for m in reversed(model.mro()) + if ( + isinstance(m, tuple(model_bases)) + and m not in model_bases + and not m._meta.abstract + ) + ) + + id_map[model].append(obj.pk) q = Q() for obj_model, ids in id_map.items(): - if obj_model in [File, Image]: - ctype = get_content_types_with_subclasses([obj_model]) - else: - ctype = ContentType.objects.get_for_model(obj_model).pk + ctype = ContentType.objects.get_for_model(obj_model).pk q |= Q(content_type_id=ctype, object_id__in=ids) return Version.objects.filter(q) From 5130ae4b3abd0cd3a46d559de1a00c8d99b571c4 Mon Sep 17 00:00:00 2001 From: Krzysztof Socha Date: Fri, 26 Oct 2018 16:05:17 +0100 Subject: [PATCH 069/147] Adapt to conditions using exceptions (#103) --- djangocms_moderation/monkeypatch.py | 70 +++++++++++++++++++---------- 1 file changed, 47 insertions(+), 23 deletions(-) diff --git a/djangocms_moderation/monkeypatch.py b/djangocms_moderation/monkeypatch.py index 33afcd2e..65b829f9 100644 --- a/djangocms_moderation/monkeypatch.py +++ b/djangocms_moderation/monkeypatch.py @@ -6,7 +6,9 @@ from djangocms_versioning import admin, models from djangocms_versioning.constants import DRAFT +from djangocms_versioning.exceptions import ConditionFailed from djangocms_versioning.helpers import version_list_url +from djangocms_versioning.models import Version from djangocms_moderation.helpers import ( get_active_moderation_request, @@ -66,19 +68,6 @@ def _get_moderation_link(self, version, request): return '' -def _get_edit_link(func): - """ - Don't display edit link if the object is review locked - """ - def inner(self, version, request, disabled=False): - content_object = version.content - if is_registered_for_moderation(content_object): - if is_obj_review_locked(content_object, request.user): - disabled = True - return func(self, version, request, disabled) - return inner - - def _is_placeholder_review_unlocked(placeholder, user): """ Register review lock with placeholder checks framework to @@ -90,20 +79,55 @@ def _is_placeholder_review_unlocked(placeholder, user): return True -def _is_version_review_locked(version, user): - if is_registered_for_moderation(version.content): - if is_obj_review_locked(version.content, user): - return False - return True +def _is_version_review_locked(message): + def inner(version, user): + if ( + is_registered_for_moderation(version.content) and + is_obj_review_locked(version.content, user) + ): + raise ConditionFailed(message) + return inner + + +def get_latest_draft_version(version): + """Get latest draft version of version object + """ + drafts = ( + Version.objects + .filter_by_content_grouping_values(version.content) + .filter(state=DRAFT) + ) + return drafts.first() + + +def _is_draft_version_review_locked(message): + def inner(version, user): + draft_version = get_latest_draft_version(version) + if ( + is_registered_for_moderation(draft_version.content) and + is_obj_review_locked(draft_version.content, user) + ): + raise ConditionFailed(message) + return inner admin.VersionAdmin.get_state_actions = get_state_actions(admin.VersionAdmin.get_state_actions) -admin.VersionAdmin._get_edit_link = _get_edit_link(admin.VersionAdmin._get_edit_link) admin.VersionAdmin._get_moderation_link = _get_moderation_link -models.Version.can_archive += [_is_version_review_locked] -models.Version.can_revert += [_is_version_review_locked] -models.Version.can_discard += [_is_version_review_locked] -models.Version.can_modify += [_is_version_review_locked] +models.Version.check_archive += [ + _is_version_review_locked(_('Cannot archive a version in an active moderation collection')) +] +models.Version.check_revert += [ + _is_draft_version_review_locked(_('Cannot revert when draft version is in an active moderation collection')) +] +models.Version.check_discard += [ + _is_version_review_locked(_('Cannot archive a version in an active moderation collection')) +] +models.Version.check_modify += [ + _is_version_review_locked(_('Version is in an active moderation collection')) +] +models.Version.check_edit_redirect += [ + _is_version_review_locked(_('Cannot edit a version in an active moderation collection')) +] fields.PlaceholderRelationField.default_checks += [_is_placeholder_review_unlocked] From dcc58296ef87c8d4ee1aff20d6c50c785e23d564 Mon Sep 17 00:00:00 2001 From: Rizwan Date: Mon, 29 Oct 2018 10:50:59 +0000 Subject: [PATCH 070/147] Test fix: amended page version creation using mock request user (#106) --- tests/test_monkeypatch.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_monkeypatch.py b/tests/test_monkeypatch.py index b48460bc..e6ea3d36 100644 --- a/tests/test_monkeypatch.py +++ b/tests/test_monkeypatch.py @@ -32,9 +32,10 @@ def test_get_edit_link(self, mock_is_obj_review_locked): """ VersionAdmin should call moderation's version of _get_edit_link """ + pg1_version = PageVersionFactory(created_by=self.mock_request.user) mock_is_obj_review_locked.return_value = True edit_link = self.version_admin._get_edit_link( - self.pg1_version, self.mock_request, disabled=False + pg1_version, self.mock_request, disabled=False ) # We test that moderation check is called when getting an edit link self.assertTrue(mock_is_obj_review_locked.called) @@ -48,10 +49,11 @@ def test_get_edit_link_not_moderation_registered(self, mock_is_obj_review_locked """ VersionAdmin should *not* call moderation's version of _get_edit_link """ + pg1_version = PageVersionFactory(created_by=self.mock_request.user) mock_is_registered_for_moderation.return_value = False mock_is_obj_review_locked.return_value = True edit_link = self.version_admin._get_edit_link( - self.pg1_version, self.mock_request, disabled=False + pg1_version, self.mock_request, disabled=False ) # Edit link is not blanked out because moderation is not registered From 33f0e3491b7050c8feef75634cff00e276b8f75a Mon Sep 17 00:00:00 2001 From: Krzysztof Socha Date: Mon, 29 Oct 2018 10:51:20 +0000 Subject: [PATCH 071/147] Release 1.0.5 (#105) --- djangocms_moderation/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/djangocms_moderation/__init__.py b/djangocms_moderation/__init__.py index ad6d0208..23847d65 100644 --- a/djangocms_moderation/__init__.py +++ b/djangocms_moderation/__init__.py @@ -1,3 +1,3 @@ -__version__ = '1.0.4' +__version__ = '1.0.5' default_app_config = 'djangocms_moderation.apps.ModerationConfig' From 4413d86d2c16e6be3cf4b2aacba6671aeae5f56c Mon Sep 17 00:00:00 2001 From: Rizwan Date: Mon, 29 Oct 2018 11:19:59 +0000 Subject: [PATCH 072/147] Added draft version check (#107) --- djangocms_moderation/monkeypatch.py | 1 + 1 file changed, 1 insertion(+) diff --git a/djangocms_moderation/monkeypatch.py b/djangocms_moderation/monkeypatch.py index 65b829f9..43b29088 100644 --- a/djangocms_moderation/monkeypatch.py +++ b/djangocms_moderation/monkeypatch.py @@ -104,6 +104,7 @@ def _is_draft_version_review_locked(message): def inner(version, user): draft_version = get_latest_draft_version(version) if ( + draft_version and is_registered_for_moderation(draft_version.content) and is_obj_review_locked(draft_version.content, user) ): From f1b03da5591ce6fbeb2691bde3972f7f00d6c016 Mon Sep 17 00:00:00 2001 From: Krzysztof Socha Date: Mon, 29 Oct 2018 11:21:15 +0000 Subject: [PATCH 073/147] Release 1.0.6 (#108) --- djangocms_moderation/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/djangocms_moderation/__init__.py b/djangocms_moderation/__init__.py index 23847d65..6fe067b5 100644 --- a/djangocms_moderation/__init__.py +++ b/djangocms_moderation/__init__.py @@ -1,3 +1,3 @@ -__version__ = '1.0.5' +__version__ = '1.0.6' default_app_config = 'djangocms_moderation.apps.ModerationConfig' From 140cdabe7739ba3fce9427dabe2a295a77072f3c Mon Sep 17 00:00:00 2001 From: Rizwan Date: Mon, 12 Nov 2018 10:19:38 +0000 Subject: [PATCH 074/147] Added attribute check (#109) --- djangocms_moderation/admin_actions.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/djangocms_moderation/admin_actions.py b/djangocms_moderation/admin_actions.py index 69748e3e..272621f6 100644 --- a/djangocms_moderation/admin_actions.py +++ b/djangocms_moderation/admin_actions.py @@ -241,7 +241,9 @@ def convert_queryset_to_version_queryset(queryset): id_map = defaultdict(list) for obj in queryset: - model = getattr(obj, 'model', obj._meta.model) + model = getattr(obj, 'model', None) + if model is None: + model = obj._meta.model from django.db.models.base import ModelBase, Model model_bases = [ModelBase, Model] From 1d55bfa424697998a1295106ea59d92cac55f302 Mon Sep 17 00:00:00 2001 From: Krzysztof Socha Date: Tue, 13 Nov 2018 12:30:32 +0100 Subject: [PATCH 075/147] Release 1.0.7 (#111) --- djangocms_moderation/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/djangocms_moderation/__init__.py b/djangocms_moderation/__init__.py index 6fe067b5..d50f821a 100644 --- a/djangocms_moderation/__init__.py +++ b/djangocms_moderation/__init__.py @@ -1,3 +1,3 @@ -__version__ = '1.0.6' +__version__ = '1.0.7' default_app_config = 'djangocms_moderation.apps.ModerationConfig' From 00cfb27eab2aa635d7064f4b6f311b5a438a0a79 Mon Sep 17 00:00:00 2001 From: Rizwan Date: Wed, 14 Nov 2018 12:33:12 +0000 Subject: [PATCH 076/147] Amended labels (#110) --- djangocms_moderation/admin.py | 17 +++++++++++++---- djangocms_moderation/migrations/0001_initial.py | 2 +- .../migrations/0005_auto_20180919_1348.py | 2 +- .../migrations/0006_auto_20181001_1840.py | 3 ++- .../migrations/0011_auto_20181008_1328.py | 1 + djangocms_moderation/models.py | 5 +++-- .../collectioncomment/change_form.html | 2 +- .../collectioncomment/change_list.html | 2 +- .../moderationrequest/change_form.html | 2 +- .../requestcomment/change_form.html | 2 +- .../requestcomment/change_list.html | 2 +- .../moderation_request_change_list.html | 4 ++-- 12 files changed, 28 insertions(+), 16 deletions(-) diff --git a/djangocms_moderation/admin.py b/djangocms_moderation/admin.py index b6078baa..3db4dcf3 100644 --- a/djangocms_moderation/admin.py +++ b/djangocms_moderation/admin.py @@ -3,6 +3,7 @@ from django import forms from django.conf.urls import url from django.contrib import admin +from django.contrib.contenttypes.models import ContentType from django.core.urlresolvers import reverse from django.http import Http404 from django.shortcuts import get_object_or_404 @@ -137,7 +138,7 @@ def get_list_display(self, request): return list_display def get_content_type(self, obj): - return obj.version.content_type + return ContentType.objects.get_for_model(obj.version.versionable.grouper_model) get_content_type.short_description = _('Content type') def get_title(self, obj): @@ -173,7 +174,7 @@ def get_comments_link(self, obj): def get_version_author(self, obj): return obj.version.created_by - get_version_author.short_description = _('Version author') + get_version_author.short_description = _('Author') def has_add_permission(self, request): return False @@ -414,7 +415,7 @@ class Media: def get_author(self, obj): return obj.author_name - get_author.short_description = _('Author') + get_author.short_description = _('User') def get_changeform_initial_data(self, request): data = { @@ -528,7 +529,7 @@ class Media: def get_list_display(self, request): list_display = [ - 'id', + 'job_id', 'name', 'author', 'workflow', @@ -538,6 +539,9 @@ def get_list_display(self, request): ] return list_display + def job_id(self, obj): + return obj.pk + def list_display_actions(self, obj): """Display links to state change endpoints """ @@ -557,6 +561,11 @@ def get_list_display_actions(self): actions.append(self.get_comments_link) return actions + def change_view(self, request, object_id, form_url='', extra_context=None): + extra_context = {'title': _('Modify collection')} + return super().change_view( + request, object_id, form_url, extra_context=extra_context) + def get_edit_link(self, obj): """Helper function to get the html link to the edit action """ diff --git a/djangocms_moderation/migrations/0001_initial.py b/djangocms_moderation/migrations/0001_initial.py index 5a435fe6..3219768d 100644 --- a/djangocms_moderation/migrations/0001_initial.py +++ b/djangocms_moderation/migrations/0001_initial.py @@ -55,7 +55,7 @@ class Migration(migrations.Migration): name='ModerationCollection', fields=[ ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('name', models.CharField(max_length=128, verbose_name='name')), + ('name', models.CharField(max_length=128, verbose_name='collection name')), ('status', models.CharField(choices=STATUS_CHOICES, db_index=True, default='COLLECTING', max_length=10)), ('date_created', models.DateTimeField(auto_now_add=True)), ('date_modified', models.DateTimeField(auto_now=True)), diff --git a/djangocms_moderation/migrations/0005_auto_20180919_1348.py b/djangocms_moderation/migrations/0005_auto_20180919_1348.py index 29829e31..ed0f492a 100644 --- a/djangocms_moderation/migrations/0005_auto_20180919_1348.py +++ b/djangocms_moderation/migrations/0005_auto_20180919_1348.py @@ -17,7 +17,7 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='moderationcollection', name='author', - field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='moderator'), + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='+', to=settings.AUTH_USER_MODEL, verbose_name='owner'), ), migrations.AlterField( model_name='moderationrequest', diff --git a/djangocms_moderation/migrations/0006_auto_20181001_1840.py b/djangocms_moderation/migrations/0006_auto_20181001_1840.py index d1a6888d..76c77db7 100644 --- a/djangocms_moderation/migrations/0006_auto_20181001_1840.py +++ b/djangocms_moderation/migrations/0006_auto_20181001_1840.py @@ -17,7 +17,8 @@ class Migration(migrations.Migration): operations = [ migrations.AlterModelOptions( name='moderationcollection', - options={'permissions': (('can_change_author', 'Can change collection author'),)}, + options={'permissions': (('can_change_author', 'Can change collection author'),), + 'verbose_name': 'collection'}, ), migrations.AddField( model_name='moderationrequest', diff --git a/djangocms_moderation/migrations/0011_auto_20181008_1328.py b/djangocms_moderation/migrations/0011_auto_20181008_1328.py index 2835759b..c0aeccd8 100644 --- a/djangocms_moderation/migrations/0011_auto_20181008_1328.py +++ b/djangocms_moderation/migrations/0011_auto_20181008_1328.py @@ -2,6 +2,7 @@ # Generated by Django 1.11.13 on 2018-10-08 12:28 from __future__ import unicode_literals +from django.conf import settings from django.db import migrations from django.db import migrations, models import django.db.models.deletion diff --git a/djangocms_moderation/models.py b/djangocms_moderation/models.py index 22204077..d8b75312 100644 --- a/djangocms_moderation/models.py +++ b/djangocms_moderation/models.py @@ -224,10 +224,10 @@ def get_next_required(self): class ModerationCollection(models.Model): - name = models.CharField(verbose_name=_('name'), max_length=128) + name = models.CharField(verbose_name=_('collection name'), max_length=128) author = models.ForeignKey( to=settings.AUTH_USER_MODEL, - verbose_name=_('moderator'), + verbose_name=_('owner'), related_name='+', on_delete=models.CASCADE, ) @@ -246,6 +246,7 @@ class ModerationCollection(models.Model): date_modified = models.DateTimeField(auto_now=True) class Meta: + verbose_name = _("collection") permissions = ( ("can_change_author", _("Can change collection author")), ) diff --git a/djangocms_moderation/templates/admin/djangocms_moderation/collectioncomment/change_form.html b/djangocms_moderation/templates/admin/djangocms_moderation/collectioncomment/change_form.html index 6882c08e..044e27ba 100644 --- a/djangocms_moderation/templates/admin/djangocms_moderation/collectioncomment/change_form.html +++ b/djangocms_moderation/templates/admin/djangocms_moderation/collectioncomment/change_form.html @@ -6,7 +6,7 @@ diff --git a/djangocms_moderation/templates/admin/djangocms_moderation/collectioncomment/change_list.html b/djangocms_moderation/templates/admin/djangocms_moderation/collectioncomment/change_list.html index 9e67890f..f054c937 100644 --- a/djangocms_moderation/templates/admin/djangocms_moderation/collectioncomment/change_list.html +++ b/djangocms_moderation/templates/admin/djangocms_moderation/collectioncomment/change_list.html @@ -15,7 +15,7 @@

      {% endblock %} diff --git a/djangocms_moderation/templates/admin/djangocms_moderation/moderationrequest/change_form.html b/djangocms_moderation/templates/admin/djangocms_moderation/moderationrequest/change_form.html index 281bb4ff..8475d5e4 100644 --- a/djangocms_moderation/templates/admin/djangocms_moderation/moderationrequest/change_form.html +++ b/djangocms_moderation/templates/admin/djangocms_moderation/moderationrequest/change_form.html @@ -6,7 +6,7 @@ diff --git a/djangocms_moderation/templates/admin/djangocms_moderation/requestcomment/change_form.html b/djangocms_moderation/templates/admin/djangocms_moderation/requestcomment/change_form.html index 52212124..f89927d0 100644 --- a/djangocms_moderation/templates/admin/djangocms_moderation/requestcomment/change_form.html +++ b/djangocms_moderation/templates/admin/djangocms_moderation/requestcomment/change_form.html @@ -6,7 +6,7 @@ {% endblock %} diff --git a/djangocms_moderation/templates/admin/djangocms_moderation/treebeard/tree_change_list.html b/djangocms_moderation/templates/admin/djangocms_moderation/treebeard/tree_change_list.html new file mode 100644 index 00000000..401ee10b --- /dev/null +++ b/djangocms_moderation/templates/admin/djangocms_moderation/treebeard/tree_change_list.html @@ -0,0 +1,22 @@ +{# Used for MP and NS trees #} +{% extends "admin/change_list.html" %} +{% load admin_list admin_tree i18n %} + +{% block extrastyle %} + {{ block.super }} + {% treebeard_css %} +{% endblock %} + +{% block extrahead %} + {{ block.super }} +{% endblock %} + +{% block result_list %} + {% if action_form and actions_on_top and cl.full_result_count %} + {% admin_actions %} + {% endif %} + {% result_tree cl request %} + {% if action_form and actions_on_bottom and cl.full_result_count %} + {% admin_actions %} + {% endif %} +{% endblock %} diff --git a/djangocms_moderation/templates/djangocms_moderation/moderation_request_change_list.html b/djangocms_moderation/templates/djangocms_moderation/moderation_request_change_list.html index c99b2dbd..5cc7f11c 100644 --- a/djangocms_moderation/templates/djangocms_moderation/moderation_request_change_list.html +++ b/djangocms_moderation/templates/djangocms_moderation/moderation_request_change_list.html @@ -1,4 +1,4 @@ - {% extends "admin/change_list.html" %} +{% extends "admin/djangocms_moderation/treebeard/tree_change_list.html" %} {% load i18n cms_static %} {% block extrahead %} diff --git a/djangocms_moderation/views.py b/djangocms_moderation/views.py index 096bfad1..6bcccfcd 100644 --- a/djangocms_moderation/views.py +++ b/djangocms_moderation/views.py @@ -1,9 +1,11 @@ from __future__ import unicode_literals from django.contrib import admin, messages +from django.db import transaction from django.http import Http404, HttpResponseRedirect from django.shortcuts import get_object_or_404, render from django.urls import reverse +from django.utils.decorators import method_decorator from django.utils.http import is_safe_url from django.utils.translation import ugettext_lazy as _, ungettext from django.views.generic import FormView @@ -18,7 +20,6 @@ CollectionItemsForm, SubmitCollectionForModerationForm, ) -from .helpers import get_moderated_children_from_placeholder from .models import ConfirmationPage, ModerationCollection from .utils import get_admin_url @@ -26,6 +27,7 @@ from . import constants # isort:skip +@method_decorator(transaction.atomic, name="post") class CollectionItemsView(FormView): template_name = "djangocms_moderation/items_to_collection.html" form_class = CollectionItemsForm @@ -51,25 +53,25 @@ def form_valid(self, form): versions = form.cleaned_data["versions"] collection = form.cleaned_data["collection"] + total_added = 0 for version in versions: - collection.add_version(version) - - # If the version is a page type look at it's contents for draft moderatable objects - # and the current user is the user who modified the page - if ( + include_children = ( isinstance(version.content, PageContent) and version.created_by == self.request.user - ): - self._add_children_to_collection(collection, version) + ) + moderation_request, added_items = collection.add_version( + version, include_children=include_children + ) + total_added += added_items messages.success( self.request, ungettext( "%(count)d item successfully added to moderation collection", "%(count)d items successfully added to moderation collection", - len(versions), + total_added, ) - % {"count": len(versions)}, + % {"count": total_added}, ) return self._get_success_redirect() @@ -93,28 +95,6 @@ def _get_success_redirect(self): success_template = "djangocms_moderation/request_finalized.html" return render(self.request, success_template, {}) - def _add_children_to_collection(self, collection, version): - """ - Finds all of the moderated children and adds them to the collection - """ - parent = version.content - for placeholder in parent.get_placeholders(): - for child_version in get_moderated_children_from_placeholder( - placeholder, parent.language - ): - # Don't add the version if it's already part of the collection or another users item - if ( - version.created_by == child_version.created_by - and not collection.moderation_requests.filter( - version=child_version - ).exists() - ): - collection.add_version(child_version) - - # If the child also has children, traverse that tree - if hasattr(child_version.content, "placeholder"): - self._add_children_to_collection(collection, child_version) - def get_form(self, **kwargs): form = super().get_form(**kwargs) form.set_collection_widget(self.request) @@ -219,11 +199,10 @@ def form_valid(self, form): self.request, _("Your collection has been submitted for review") ) # Redirect back to the collection filtered moderation request change list - redirect_url = reverse( - "admin:djangocms_moderation_moderationrequest_changelist" - ) - redirect_url = "{}?collection__id__exact={}".format( - redirect_url, self.collection.id + redirect_url = reverse('admin:djangocms_moderation_moderationrequest_changelist') + redirect_url = "{}?moderation_request__collection__id={}".format( + redirect_url, + self.collection.id ) return HttpResponseRedirect(redirect_url) diff --git a/tests/requirements.txt b/tests/requirements.txt index 9e31f0dd..5399285e 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -1,6 +1,7 @@ djangocms-helper tox coverage +pyflakes>=2.1.1 flake8 isort factory-boy diff --git a/tests/test_admin.py b/tests/test_admin.py index aa5e18df..50780d9e 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -1,26 +1,32 @@ from django.contrib import admin from django.contrib.auth.models import Permission, User from django.contrib.contenttypes.models import ContentType +from django.test.client import RequestFactory from django.urls import reverse from djangocms_versioning.test_utils import factories from djangocms_moderation import conf, constants -from djangocms_moderation.admin import ModerationCollectionAdmin, ModerationRequestAdmin -from djangocms_moderation.constants import ACTION_REJECTED -from djangocms_moderation.models import ( - ModerationCollection, - ModerationRequest, - Workflow, +from djangocms_moderation.admin import ( + ModerationCollectionAdmin, + ModerationRequestAdmin, + ModerationRequestTreeAdmin, ) +from djangocms_moderation.constants import ACTION_REJECTED +from djangocms_moderation.models import ModerationCollection, ModerationRequest from .utils.base import BaseTestCase, MockRequest +from .utils.factories import ( + ModerationCollectionFactory, + RootModerationRequestTreeNodeFactory, + WorkflowFactory, +) class ModerationAdminTestCase(BaseTestCase): def setUp(self): - self.wf = Workflow.objects.create(name="Workflow Test") - self.collection = ModerationCollection.objects.create( + self.wf = WorkflowFactory(name="Workflow Test") + self.collection = ModerationCollectionFactory( author=self.user, name="Collection Admin Actions", workflow=self.wf, @@ -30,13 +36,12 @@ def setUp(self): pg1_version = factories.PageVersionFactory() pg2_version = factories.PageVersionFactory() - self.mr1 = ModerationRequest.objects.create( - version=pg1_version, - language="en", - collection=self.collection, - is_active=True, - author=self.collection.author, + self.mr1n = RootModerationRequestTreeNodeFactory( + moderation_request__version=pg1_version, + moderation_request__collection=self.collection, + moderation_request__is_active=True, ) + self.mr1 = self.mr1n.moderation_request self.wfst = self.wf.steps.create(role=self.role2, is_required=True, order=1) @@ -52,21 +57,23 @@ def setUp(self): ) # this moderation request is not approved - self.mr2 = ModerationRequest.objects.create( - version=pg2_version, - language="en", - collection=self.collection, - is_active=True, - author=self.collection.author, + self.mr2n = RootModerationRequestTreeNodeFactory( + moderation_request__version=pg2_version, + moderation_request__collection=self.collection, + moderation_request__is_active=True, ) + self.mr2 = self.mr2n.moderation_request self.mr2.actions.create( to_user=self.user2, by_user=self.user, action=constants.ACTION_STARTED ) self.url = reverse("admin:djangocms_moderation_moderationrequest_changelist") - self.url_with_filter = "{}?collection__id__exact={}".format( + self.url_with_filter = "{}?moderation_request__collection__id={}".format( self.url, self.collection.pk ) + self.mr_tree_admin = ModerationRequestTreeAdmin( + ModerationRequest, admin.AdminSite() + ) self.mra = ModerationRequestAdmin(ModerationRequest, admin.AdminSite()) self.mca = ModerationCollectionAdmin(ModerationCollection, admin.AdminSite()) @@ -74,13 +81,13 @@ def test_delete_selected_action_visibility(self): mock_request = MockRequest() mock_request.user = self.user mock_request._collection = self.collection - actions = self.mra.get_actions(request=mock_request) + actions = self.mr_tree_admin.get_actions(request=mock_request) self.assertIn("delete_selected", actions) # user2 won't be able to delete requests, as they are not the collection # author mock_request.user = self.user2 - actions = self.mra.get_actions(request=mock_request) + actions = self.mr_tree_admin.get_actions(request=mock_request) self.assertNotIn("delete_selected", actions) def test_publish_selected_action_visibility_when_version_is_published(self): @@ -88,40 +95,40 @@ def test_publish_selected_action_visibility_when_version_is_published(self): mock_request.user = self.user mock_request._collection = self.collection - actions = self.mra.get_actions(request=mock_request) + actions = self.mr_tree_admin.get_actions(request=mock_request) # mr1 request is approved so user can see the publish_selected action self.assertIn("publish_selected", actions) # Now, when version becomes published, they shouldn't see it self.mr1.version._set_publish(self.user) self.mr1.version.save() - actions = self.mra.get_actions(request=mock_request) + actions = self.mr_tree_admin.get_actions(request=mock_request) self.assertNotIn("publish_selected", actions) def test_publish_selected_action_visibility(self): mock_request = MockRequest() mock_request.user = self.user mock_request._collection = self.collection - actions = self.mra.get_actions(request=mock_request) + actions = self.mr_tree_admin.get_actions(request=mock_request) # mr1 request is approved, so user1 can see the publish selected option self.assertIn("publish_selected", actions) # user2 should not be able to see it mock_request.user = self.user2 - actions = self.mra.get_actions(request=mock_request) + actions = self.mr_tree_admin.get_actions(request=mock_request) self.assertNotIn("publish_selected", actions) # if there are no approved requests, user can't see the button either mock_request.user = self.user self.mr1.get_last_action().delete() - actions = self.mra.get_actions(request=mock_request) + actions = self.mr_tree_admin.get_actions(request=mock_request) self.assertNotIn("publish_selected", actions) def test_approve_and_reject_selected_action_visibility(self): mock_request = MockRequest() mock_request.user = self.user mock_request._collection = self.collection - actions = self.mra.get_actions(request=mock_request) + actions = self.mr_tree_admin.get_actions(request=mock_request) # mr1 is not a moderator for collection1 so he can't approve or reject # anything self.assertNotIn("approve_selected", actions) @@ -129,13 +136,13 @@ def test_approve_and_reject_selected_action_visibility(self): # user2 is moderator and there is 1 unapproved request mock_request.user = self.user2 - actions = self.mra.get_actions(request=mock_request) + actions = self.mr_tree_admin.get_actions(request=mock_request) self.assertIn("approve_selected", actions) self.assertIn("reject_selected", actions) # now everything is approved, so not even user2 can see the actions self.mr2.delete() - actions = self.mra.get_actions(request=mock_request) + actions = self.mr_tree_admin.get_actions(request=mock_request) self.assertNotIn("approve_selected", actions) self.assertNotIn("reject_selected", actions) @@ -143,19 +150,19 @@ def test_resubmit_selected_action_visibility(self): mock_request = MockRequest() mock_request.user = self.user mock_request._collection = self.collection - actions = self.mra.get_actions(request=mock_request) + actions = self.mr_tree_admin.get_actions(request=mock_request) # There is nothing set to re-work, so user can't see the resubmit action self.assertNotIn("resubmit_selected", actions) self.mr1action2.action = ACTION_REJECTED self.mr1action2.save() - actions = self.mra.get_actions(request=mock_request) + actions = self.mr_tree_admin.get_actions(request=mock_request) # There is 1 mr to rework now, so user can do it self.assertIn("resubmit_selected", actions) # user2 can't, as they are not the author of the request mock_request.user = self.user2 - actions = self.mra.get_actions(request=mock_request) + actions = self.mr_tree_admin.get_actions(request=mock_request) self.assertNotIn("resubmit_selected", actions) def test_in_review_status_is_considered(self): @@ -165,20 +172,20 @@ def test_in_review_status_is_considered(self): self.collection.status = constants.ARCHIVED self.collection.save() - actions = self.mra.get_actions(request=mock_request) + actions = self.mr_tree_admin.get_actions(request=mock_request) # for self.user, the publish_selected should be available even if # collection status is ARCHIVED self.assertIn("publish_selected", actions) mock_request.user = self.user2 - actions = self.mra.get_actions(request=mock_request) + actions = self.mr_tree_admin.get_actions(request=mock_request) # mr2 request is not approved, so user2 should see the # approve_selected option, but the collection is not in IN_REVIEW self.assertNotIn("approve_selected", actions) self.collection.status = constants.IN_REVIEW self.collection.save() - actions = self.mra.get_actions(request=mock_request) + actions = self.mr_tree_admin.get_actions(request=mock_request) self.assertIn("approve_selected", actions) def test_change_list_view_should_respect_conf(self): @@ -204,11 +211,11 @@ def test_change_list_view_should_respect_conf(self): # test ModerationRequests conf.REQUEST_COMMENTS_ENABLED = False - list_display = self.mra.get_list_display(mock_request) + list_display = self.mr_tree_admin.get_list_display(mock_request) self.assertNotIn("get_comments_link", list_display) conf.REQUEST_COMMENTS_ENABLED = True - list_display = self.mra.get_list_display(mock_request) + list_display = self.mr_tree_admin.get_list_display(mock_request) self.assertIn("get_comments_link", list_display) # test ModerationCollections @@ -275,7 +282,7 @@ def test_get_readonly_fields_for_moderation_collection(self): self.collection.status = constants.COLLECTING self.collection.save() - mock_request_author = MockRequest() + mock_request_author = RequestFactory() mock_request_author.user = self.collection.author mock_request_non_author = MockRequest() @@ -309,7 +316,7 @@ def test_get_readonly_fields_for_moderation_collection(self): self.assertListEqual(["status", "workflow"], fields) def test_get_readonly_fields_for_moderation_request_review_user(self): - mock_request_author = MockRequest() + mock_request_author = RequestFactory() mock_request_author.user = self.user2 # user2 is a reviewer mra_inline = self.mra.inlines[0] fields = mra_inline.get_readonly_fields( @@ -318,7 +325,7 @@ def test_get_readonly_fields_for_moderation_request_review_user(self): self.assertListEqual(["show_user", "date_taken", "form_submission"], fields) def test_get_readonly_fields_for_moderation_request_non_review_user(self): - mock_request_author = MockRequest() + mock_request_author = RequestFactory() mock_request_author.user = self.user3 # user3 is not a reviewer mra_inline = self.mra.inlines[0] fields = mra_inline.get_readonly_fields( @@ -326,3 +333,18 @@ def test_get_readonly_fields_for_moderation_request_non_review_user(self): ) self.assertListEqual(mra_inline.fields, fields) self.assertIn("message", fields) + + def test_tree_admin_list_links_to_moderation_request_change_view(self): + mock_request = RequestFactory() + mock_request.user = self.user + mock_request._collection = self.collection + result = self.mr_tree_admin.get_id(self.mr1n) + self.assertIn("get_id", self.mr_tree_admin.get_list_display(mock_request)) + expected = '{}'.format( + reverse( + "admin:djangocms_moderation_moderationrequest_change", + args=(self.mr1.pk,), + ), + self.mr1.pk, + ) + self.assertHTMLEqual(result, expected) diff --git a/tests/test_admin_actions.py b/tests/test_admin_actions.py index 46030cea..64511cac 100644 --- a/tests/test_admin_actions.py +++ b/tests/test_admin_actions.py @@ -1,26 +1,32 @@ import mock from django.contrib.admin import ACTION_CHECKBOX_NAME +from django.test import TransactionTestCase from django.urls import reverse +from cms.test_utils.testcases import CMSTestCase + from djangocms_versioning.constants import DRAFT, PUBLISHED from djangocms_versioning.models import Version from djangocms_versioning.test_utils.factories import PageVersionFactory from djangocms_moderation import constants -from djangocms_moderation.admin import ModerationRequestAdmin +from djangocms_moderation.admin import ModerationRequestTreeAdmin from djangocms_moderation.constants import ACTION_REJECTED from djangocms_moderation.models import ( ModerationCollection, ModerationRequest, + ModerationRequestTreeNode, Role, Workflow, ) +from .utils import factories from .utils.base import BaseTestCase class AdminActionTest(BaseTestCase): + def setUp(self): self.wf = Workflow.objects.create(name="Workflow Test") self.collection = ModerationCollection.objects.create( @@ -34,12 +40,8 @@ def setUp(self): pg2_version = PageVersionFactory() self.mr1 = ModerationRequest.objects.create( - version=pg1_version, - language="en", - collection=self.collection, - is_active=True, - author=self.collection.author, - ) + version=pg1_version, language="en", collection=self.collection, + is_active=True, author=self.collection.author) self.wfst1 = self.wf.steps.create(role=self.role1, is_required=True, order=1) self.wfst2 = self.wf.steps.create(role=self.role2, is_required=True, order=1) @@ -51,80 +53,17 @@ def setUp(self): # this moderation request is not approved self.mr2 = ModerationRequest.objects.create( - version=pg2_version, - language="en", - collection=self.collection, - is_active=True, - author=self.collection.author, - ) + version=pg2_version, language="en", collection=self.collection, + is_active=True, author=self.collection.author) self.mr2.actions.create(by_user=self.user, action=constants.ACTION_STARTED) - self.url = reverse("admin:djangocms_moderation_moderationrequest_changelist") - self.url_with_filter = "{}?collection__id__exact={}".format( + self.url = reverse("admin:djangocms_moderation_moderationrequesttreenode_changelist") + self.url_with_filter = "{}?moderation_request__collection__id={}".format( self.url, self.collection.pk ) self.client.force_login(self.user) - @mock.patch.object(ModerationRequestAdmin, "has_delete_permission") - @mock.patch("djangocms_moderation.admin.notify_collection_moderators") - @mock.patch("djangocms_moderation.admin.notify_collection_author") - def test_delete_selected( - self, notify_author_mock, notify_moderators_mock, mock_has_delete_permission - ): - mock_has_delete_permission.return_value = True - self.assertEqual( - ModerationRequest.objects.filter(collection=self.collection).count(), 2 - ) - - fixtures = [self.mr1, self.mr2] - data = { - "action": "delete_selected", - ACTION_CHECKBOX_NAME: [str(f.pk) for f in fixtures], - } - # This user is not the collection author - self.client.force_login(self.user2) - self.client.post(self.url_with_filter, data) - # Nothing is deleted - self.assertEqual( - ModerationRequest.objects.filter(collection=self.collection).count(), 2 - ) - - # Now lets try with collection author, but without delete permission - mock_has_delete_permission.return_value = False - self.client.force_login(self.user) - response = self.client.post(self.url_with_filter, data) - self.assertEqual(response.status_code, 403) - # Nothing is deleted - self.assertEqual( - ModerationRequest.objects.filter(collection=self.collection).count(), 2 - ) - - self.collection.refresh_from_db() - self.assertEqual(self.collection.status, constants.IN_REVIEW) - - mock_has_delete_permission.return_value = True - self.client.force_login(self.user) - response = self.client.post(self.url_with_filter, data) - self.assertEqual(response.status_code, 302) - self.client.post(response.url) - self.assertEqual( - ModerationRequest.objects.filter(collection=self.collection).count(), 0 - ) - - notify_author_mock.assert_called_once_with( - collection=self.collection, - moderation_requests=[self.mr1, self.mr2], - action=constants.ACTION_CANCELLED, - by_user=self.user, - ) - - self.assertFalse(notify_moderators_mock.called) - - # All moderation requests were deleted, so collection should be archived - self.collection.refresh_from_db() - self.assertEqual(self.collection.status, constants.ARCHIVED) - def test_publish_selected(self): fixtures = [self.mr1, self.mr2] @@ -138,7 +77,7 @@ def test_publish_selected(self): data = { "action": "publish_selected", - ACTION_CHECKBOX_NAME: [str(f.pk) for f in fixtures], + ACTION_CHECKBOX_NAME: [str(f.pk) for f in fixtures] } response = self.client.post(self.url_with_filter, data) self.client.post(response.url) @@ -161,7 +100,7 @@ def test_approve_selected(self, notify_author_mock, notify_moderators_mock): fixtures = [self.mr1, self.mr2] data = { "action": "approve_selected", - ACTION_CHECKBOX_NAME: [str(f.pk) for f in fixtures], + ACTION_CHECKBOX_NAME: [str(f.pk) for f in fixtures] } self.assertFalse(self.mr2.is_approved()) self.assertTrue(self.mr1.is_approved()) @@ -213,7 +152,7 @@ def test_reject_selected(self, notify_author_mock, notify_moderators_mock): fixtures = [self.mr1, self.mr2] data = { "action": "reject_selected", - ACTION_CHECKBOX_NAME: [str(f.pk) for f in fixtures], + ACTION_CHECKBOX_NAME: [str(f.pk) for f in fixtures] } self.assertFalse(self.mr2.is_approved()) self.assertFalse(self.mr2.is_rejected()) @@ -241,12 +180,15 @@ def test_reject_selected(self, notify_author_mock, notify_moderators_mock): @mock.patch("djangocms_moderation.admin.notify_collection_moderators") @mock.patch("djangocms_moderation.admin.notify_collection_author") def test_resubmit_selected(self, notify_author_mock, notify_moderators_mock): - self.mr2.update_status(action=ACTION_REJECTED, by_user=self.user) + self.mr2.update_status( + action=ACTION_REJECTED, + by_user=self.user + ) fixtures = [self.mr1, self.mr2] data = { "action": "resubmit_selected", - ACTION_CHECKBOX_NAME: [str(f.pk) for f in fixtures], + ACTION_CHECKBOX_NAME: [str(f.pk) for f in fixtures] } self.assertTrue(self.mr2.is_rejected()) self.assertTrue(self.mr1.is_approved()) @@ -278,12 +220,8 @@ def test_approve_selected_sends_correct_emails(self, notify_moderators_mock): # Add one more, partially approved request pg3_version = PageVersionFactory() self.mr3 = ModerationRequest.objects.create( - version=pg3_version, - language="en", - collection=self.collection, - is_active=True, - author=self.collection.author, - ) + version=pg3_version, language="en", collection=self.collection, + is_active=True, author=self.collection.author) self.mr3.actions.create(by_user=self.user, action=constants.ACTION_STARTED) self.mr3.update_status(by_user=self.user, action=constants.ACTION_APPROVED) self.mr3.update_status(by_user=self.user2, action=constants.ACTION_APPROVED) @@ -293,7 +231,7 @@ def test_approve_selected_sends_correct_emails(self, notify_moderators_mock): fixtures = [self.mr1, self.mr2, self.mr3] data = { "action": "approve_selected", - ACTION_CHECKBOX_NAME: [str(f.pk) for f in fixtures], + ACTION_CHECKBOX_NAME: [str(f.pk) for f in fixtures] } # First post as `self.user` should notify mr1 and mr2 and mr3 moderators @@ -305,20 +243,18 @@ def test_approve_selected_sends_correct_emails(self, notify_moderators_mock): self.assertEqual(notify_moderators_mock.call_count, 2) self.assertEqual( notify_moderators_mock.call_args_list[0], - mock.call( - collection=self.collection, - moderation_requests=[self.mr1, self.mr3], - action_obj=self.mr1.get_last_action(), - ), + mock.call(collection=self.collection, + moderation_requests=[self.mr1, self.mr3], + action_obj=self.mr1.get_last_action() + ) ) self.assertEqual( notify_moderators_mock.call_args_list[1], - mock.call( - collection=self.collection, - moderation_requests=[self.mr2], - action_obj=self.mr2.get_last_action(), - ), + mock.call(collection=self.collection, + moderation_requests=[self.mr2], + action_obj=self.mr2.get_last_action(), + ) ) self.assertFalse(self.mr1.is_approved()) self.assertFalse(self.mr3.is_approved()) @@ -348,3 +284,206 @@ def test_approve_selected_sends_correct_emails(self, notify_moderators_mock): # Not all request have been fully approved self.collection.refresh_from_db() self.assertEqual(self.collection.status, constants.IN_REVIEW) + + +class DeleteSelectedTest(CMSTestCase): + def setUp(self): + self.user = factories.UserFactory(is_staff=True, is_superuser=True) + self.collection = factories.ModerationCollectionFactory( + author=self.user, status=constants.IN_REVIEW) + self.moderation_request1 = factories.ModerationRequestFactory( + collection=self.collection) + self.moderation_request2 = factories.ModerationRequestFactory( + collection=self.collection) + self.root1 = factories.RootModerationRequestTreeNodeFactory( + moderation_request=self.moderation_request1) + self.root2 = factories.RootModerationRequestTreeNodeFactory( + moderation_request=self.moderation_request2) + factories.ChildModerationRequestTreeNodeFactory( + moderation_request=self.moderation_request1, parent=self.root1) + + @mock.patch.object(ModerationRequestTreeAdmin, "has_delete_permission", mock.Mock(return_value=True)) + def test_delete_selected_action_cannot_be_accessed_if_not_collection_author(self): + # Login as a user who is not the collection author + self.client.force_login(self.get_superuser()) + # Set up action url + url = reverse("admin:djangocms_moderation_moderationrequesttreenode_changelist") + url += "?moderation_request__collection__id={}".format(self.collection.pk) + + # Choose the delete_selected action from the dropdown + data = { + "action": "delete_selected", + ACTION_CHECKBOX_NAME: [str(self.moderation_request1.pk), str(self.moderation_request2.pk)] + } + response = self.client.post(url, data) + + # The action is not on the page as available to somebody who is not + # the author, therefore django will just return 200 as you're + # trying to choose an action that isn't in the dropdown + # (if anything had been deleted it would have been a 302) + self.assertEqual(response.status_code, 200) + + @mock.patch.object(ModerationRequestTreeAdmin, "has_delete_permission", mock.Mock(return_value=True)) + @mock.patch("djangocms_moderation.admin.notify_collection_author") + def test_delete_selected_view_cannot_be_accessed_if_not_collection_author(self, notify_author_mock): + # Login as a user who is not the collection author + self.client.force_login(self.get_superuser()) + # Set up the url + url = reverse("admin:djangocms_moderation_moderationrequesttreenode_delete") + url += "?ids={tree_ids}&collection_id={collection_id}".format( + tree_ids=",".join([str(self.root1.pk), str(self.root2.pk)]), + collection_id=str(self.collection.pk) + ) + + # POST directly to the view, don't go through actions + response = self.client.post(url) + + self.assertEqual(response.status_code, 403) + # Nothing is deleted + self.assertEqual(ModerationRequest.objects.filter(collection=self.collection).count(), 2) + # Collection not modified + self.collection.refresh_from_db() + self.assertEqual(self.collection.status, constants.IN_REVIEW) + # Notifications not sent + self.assertFalse(notify_author_mock.called) + + @mock.patch.object(ModerationRequestTreeAdmin, "has_delete_permission", mock.Mock(return_value=False)) + def test_delete_selected_action_cannot_be_accessed_without_delete_permission(self): + # Login as the collection author + self.client.force_login(self.user) + # Choose the delete_selected action from the dropdown + url = reverse("admin:djangocms_moderation_moderationrequesttreenode_changelist") + url += "?moderation_request__collection__id={}".format(self.collection.pk) + data = { + "action": "delete_selected", + ACTION_CHECKBOX_NAME: [str(self.moderation_request1.pk), str(self.moderation_request2.pk)] + } + + response = self.client.post(url, data) + + self.assertEqual(response.status_code, 403) + + @mock.patch.object(ModerationRequestTreeAdmin, "has_delete_permission", mock.Mock(return_value=False)) + @mock.patch("djangocms_moderation.admin.notify_collection_author") + def test_delete_selected_view_cannot_be_accessed_without_delete_permission(self, notify_author_mock): + # Login as the collection author + self.client.force_login(self.user) + # Set up url + url = reverse("admin:djangocms_moderation_moderationrequesttreenode_delete") + url += "?ids={tree_ids}&collection_id={collection_id}".format( + tree_ids=",".join([str(self.root1.pk), str(self.root2.pk)]), + collection_id=str(self.collection.pk) + ) + + # POST directly to the view, don't go through actions + response = self.client.post(url) + + self.assertEqual(response.status_code, 403) + # Nothing is deleted + self.assertEqual(ModerationRequest.objects.filter(collection=self.collection).count(), 2) + # Collection not modified + self.collection.refresh_from_db() + self.assertEqual(self.collection.status, constants.IN_REVIEW) + # Notifications not sent + self.assertFalse(notify_author_mock.called) + + @mock.patch.object(ModerationRequestTreeAdmin, "has_delete_permission", mock.Mock(return_value=True)) + @mock.patch("djangocms_moderation.admin.notify_collection_moderators") + @mock.patch("djangocms_moderation.admin.notify_collection_author") + def test_delete_selected_deletes_all_relevant_objects(self, notify_author_mock, notify_moderators_mock): + """The selected ModerationRequest and ModerationRequestTreeNode objects should be deleted.""" + # Login as the collection author + self.client.force_login(self.user) + # Choose the delete_selected action from the dropdown + url = reverse("admin:djangocms_moderation_moderationrequesttreenode_changelist") + url += "?moderation_request__collection__id={}".format(self.collection.pk) + data = { + "action": "delete_selected", + ACTION_CHECKBOX_NAME: [str(self.moderation_request1.pk), str(self.moderation_request2.pk)] + } + response = self.client.post(url, data) + self.assertEqual(response.status_code, 302) + + # Now do the request the delete_selected action has led us to + response = self.client.post(response.url) + self.assertRedirects(response, url) + # And check the requests have indeed been deleted + self.assertEqual(ModerationRequest.objects.filter(collection=self.collection).count(), 0) + # And correct notifications sent out + notify_author_mock.assert_called_once_with( + collection=self.collection, + moderation_requests=[self.moderation_request1, self.moderation_request2], + action=constants.ACTION_CANCELLED, + by_user=self.user, + ) + self.assertFalse(notify_moderators_mock.called) + # All moderation requests were deleted, so collection should be archived + self.collection.refresh_from_db() + self.assertEqual(self.collection.status, constants.ARCHIVED) + + +class DeletedSelectedTransactionTest(TransactionTestCase): + + def setUp(self): + # Create db data + self.user = factories.UserFactory(is_staff=True, is_superuser=True) + self.collection = factories.ModerationCollectionFactory( + author=self.user, status=constants.IN_REVIEW) + self.moderation_request1 = factories.ModerationRequestFactory( + collection=self.collection) + self.moderation_request2 = factories.ModerationRequestFactory( + collection=self.collection) + self.root1 = factories.RootModerationRequestTreeNodeFactory( + moderation_request=self.moderation_request1) + self.root2 = factories.RootModerationRequestTreeNodeFactory( + moderation_request=self.moderation_request2) + factories.ChildModerationRequestTreeNodeFactory( + moderation_request=self.moderation_request1, parent=self.root1) + + # Login + self.client.force_login(self.user) + + # Generate url and POST data + self.url = reverse("admin:djangocms_moderation_moderationrequesttreenode_changelist") + self.url += "?moderation_request__collection__id={}".format(self.collection.pk) + self.data = { + "action": "delete_selected", + ACTION_CHECKBOX_NAME: [str(self.moderation_request1.pk), str(self.moderation_request2.pk)] + } + + def tearDown(self): + """Clear content type cache for page content's versionable. + + This is necessary, because TransactionTestCase clears the + entire database after each test, meaning ContentType objects + are recreated with new IDs. Cache kept old IDs, causing + inability to retrieve versions for a given object. + """ + del self.moderation_request1.version.versionable.content_types + + @mock.patch("djangocms_moderation.admin.messages.success") + def test_deleting_is_wrapped_in_db_transaction(self, messages_mock): + class FakeError(Exception): + pass + # Throw an exception to cause a db rollback. + # Throwing FakeError as no actual code will ever throw it and + # therefore catching this later in the test will not cover up a + # genuine issue + messages_mock.side_effect = FakeError + + # Choose the delete_selected action from the dropdown + response = self.client.post(self.url, self.data) + self.assertEqual(response.status_code, 302) + + # Now do the request the delete_selected action has led us to + try: + self.client.post(response.url, self.data) + except FakeError: + # This is what messages_mock should have thrown, + # but we don't want the test to throw it. + pass + + # Check neither the tree nodes nor the requests have been deleted. + # The db transaction should have rolled back. + self.assertEqual(ModerationRequestTreeNode.objects.all().count(), 3) + self.assertEqual(ModerationRequest.objects.all().count(), 2) diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 6a29eb24..6d92c265 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -101,8 +101,8 @@ def setUp(self): self.mr = ModerationRequest.objects.get( version=version, collection=self.collection ) - self.expected_url = "{}?collection__id__exact={}".format( - reverse("admin:djangocms_moderation_moderationrequest_changelist"), + self.expected_url = "{}?moderation_request__collection__id={}".format( + reverse('admin:djangocms_moderation_moderationrequest_changelist'), self.collection.id, ) @@ -177,8 +177,10 @@ def test_get_moderated_children_from_placeholder_has_only_registered_model(self) placeholder=placeholder, poll=none_moderated_poll_version.content.poll ) - moderated_children = get_moderated_children_from_placeholder( - placeholder, pg_version.content.language + moderated_children = list( + get_moderated_children_from_placeholder( + placeholder, {"language": pg_version.content.language} + ) ) self.assertEqual(moderated_children, [poll_version]) @@ -214,11 +216,15 @@ def test_get_moderated_children_from_placeholder_gets_correct_versions(self): placeholder=pg_2_placeholder, poll=pg_2_poll_version.content.poll ) - page_1_moderated_children = get_moderated_children_from_placeholder( - pg_1_placeholder, pg_1_version.content.language + page_1_moderated_children = list( + get_moderated_children_from_placeholder( + pg_1_placeholder, {"language": pg_1_version.content.language} + ) ) - page_2_moderated_children = get_moderated_children_from_placeholder( - pg_2_placeholder, pg_2_version.content.language + page_2_moderated_children = list( + get_moderated_children_from_placeholder( + pg_2_placeholder, {"language": pg_2_version.content.language} + ) ) self.assertEqual(page_1_moderated_children, [pg_1_poll_version]) diff --git a/tests/test_models.py b/tests/test_models.py index c455a312..e6865543 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -374,9 +374,7 @@ def test_compliance_number_sequential_number_backend(self): self.assertEqual(request.compliance_number, expected) def test_compliance_number_sequential_number_with_identifier_prefix_backend(self): - self.wf2.compliance_number_backend = ( - "djangocms_moderation.backends.sequential_number_with_identifier_prefix_backend" - ) + self.wf2.compliance_number_backend = "djangocms_moderation.backends.sequential_number_with_identifier_prefix_backend" # noqa:E501 self.wf2.identifier = "SSO" request = ModerationRequest.objects.create( diff --git a/tests/test_views.py b/tests/test_views.py index 7994d734..d3da4932 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -1,76 +1,116 @@ import mock +from django.contrib.admin.widgets import RelatedFieldWidgetWrapper from django.contrib.messages import get_messages +from django.test import TransactionTestCase from django.urls import reverse +from cms.test_utils.testcases import CMSTestCase from cms.utils.urlutils import add_url_parameters from djangocms_versioning.test_utils.factories import PageVersionFactory -from djangocms_moderation.models import ModerationCollection, ModerationRequest +from djangocms_moderation.models import ( + ModerationCollection, + ModerationRequest, + ModerationRequestTreeNode, +) from djangocms_moderation.utils import get_admin_url from .utils.base import BaseViewTestCase -from .utils.factories import PlaceholderFactory, PollPluginFactory, PollVersionFactory - - -class CollectionItemsViewTest(BaseViewTestCase): - def setUp(self): - super().setUp() - self.client.force_login(self.user) - +from .utils.factories import ( + ChildModerationRequestTreeNodeFactory, + ModerationCollectionFactory, + ModerationRequestFactory, + PlaceholderFactory, + PollPluginFactory, + PollVersionFactory, + RootModerationRequestTreeNodeFactory, + UserFactory, +) + + +class CollectionItemsViewAddingRequestsTestCase(CMSTestCase): def test_no_eligible_items_to_add_to_collection(self): """ - We try add pg4_version to a collection but expect it to fail + We try add page_version to a collection but expect it to fail as it is already party of a collection """ + user = self.get_superuser() + existing_collection = ModerationCollectionFactory( + author=user + ) + collection = ModerationCollectionFactory(author=user) + page_version = PageVersionFactory(created_by=user) + RootModerationRequestTreeNodeFactory( + moderation_request__collection=existing_collection, + moderation_request__version=page_version, + ) url = add_url_parameters( get_admin_url( name="cms_moderation_items_to_collection", language="en", args=() ), return_to_url="http://example.com", - version_ids=self.pg4_version.pk, - collection_id=self.collection1.pk, - ) - response = self.client.post( - path=url, - data={"collection": self.collection1.pk, "versions": self.pg4_version.pk}, - follow=False, + version_ids=page_version.pk, + collection_id=collection.pk, ) + with self.login_user_context(user): + response = self.client.post( + path=url, + data={"collection": collection.pk, "versions": page_version.pk}, + follow=False, + ) self.assertEqual(response.status_code, 200) self.assertIn("versions", response.context["form"].errors) + # No new nodes were created on validation error + self.assertEqual(ModerationRequest.objects.all().count(), 1) + self.assertEqual(ModerationRequestTreeNode.objects.all().count(), 1) def test_add_items_to_collection_no_return_url_set(self): - ModerationRequest.objects.all().delete() - pg_version = PageVersionFactory(created_by=self.user) + user = self.get_superuser() + collection = ModerationCollectionFactory(author=user) + page_version = PageVersionFactory(created_by=user) url = add_url_parameters( get_admin_url( name="cms_moderation_items_to_collection", language="en", args=() ), - # not return url specified - version_ids=pg_version.pk, - collection_id=self.collection1.pk, - ) - response = self.client.post( - path=url, - data={"collection": self.collection1.pk, "versions": [pg_version.pk]}, - follow=False, + # no return url specified + version_ids=page_version.pk, + collection_id=collection.pk, ) + with self.login_user_context(user): + response = self.client.post( + path=url, + data={"collection": collection.pk, "versions": [page_version.pk]}, + follow=False, + ) self.assertEqual(response.status_code, 200) self.assertContains(response, "reloadBrowser") + # check using success template + self.assertListEqual( + [t.name for t in response.templates], + ["djangocms_moderation/request_finalized.html"], + ) - self.assertTrue( - ModerationRequest.objects.filter( - version=pg_version, collection=self.collection1 - ).exists() + # Moderation requests and related nodes were created + moderation_requests = ModerationRequest.objects.filter( + version=page_version, collection=collection + ) + self.assertEqual(moderation_requests.count(), 1) + self.assertEqual( + ModerationRequestTreeNode.objects.filter( + moderation_request=moderation_requests.get() + ).count(), + 1, ) def test_add_items_to_collection_return_url_set(self): - ModerationRequest.objects.all().delete() - pg1_version = PageVersionFactory(created_by=self.user) - pg2_version = PageVersionFactory(created_by=self.user) + user = self.get_superuser() + collection = ModerationCollectionFactory(author=user) + page1_version = PageVersionFactory(created_by=user) + page2_version = PageVersionFactory(created_by=user) redirect_to_url = reverse( "admin:djangocms_moderation_moderationcollection_changelist" @@ -81,25 +121,38 @@ def test_add_items_to_collection_return_url_set(self): name="cms_moderation_items_to_collection", language="en", args=() ), return_to_url=redirect_to_url, - version_ids=",".join(str(x) for x in [pg1_version.pk, pg2_version.pk]), - collection_id=self.collection1.pk, - ) - response = self.client.post( - path=url, - data={ - "collection": self.collection1.pk, - "versions": [pg1_version.pk, pg2_version.pk], - }, - follow=False, + version_ids=",".join(str(x) for x in [page1_version.pk, page2_version.pk]), + collection_id=collection.pk, ) + with self.login_user_context(user): + response = self.client.post( + path=url, + data={ + "collection": collection.pk, + "versions": [page1_version.pk, page2_version.pk], + }, + follow=False, + ) self.assertEqual(response.status_code, 302) - moderation_request = ModerationRequest.objects.get(version=pg1_version) - self.assertEqual(moderation_request.collection, self.collection1) + moderation_request = ModerationRequest.objects.get(version=page1_version) + self.assertEqual(moderation_request.collection, collection) + self.assertEqual( + ModerationRequestTreeNode.objects.filter( + moderation_request=moderation_request + ).count(), + 1, + ) - moderation_request = ModerationRequest.objects.get(version=pg1_version) - self.assertEqual(moderation_request.collection, self.collection1) + moderation_request = ModerationRequest.objects.get(version=page2_version) + self.assertEqual(moderation_request.collection, collection) + self.assertEqual( + ModerationRequestTreeNode.objects.filter( + moderation_request=moderation_request + ).count(), + 1, + ) messages = list(get_messages(response.wsgi_request)) self.assertIn( @@ -108,22 +161,23 @@ def test_add_items_to_collection_return_url_set(self): ) def test_list_versions_from_collection_id_param(self): - ModerationRequest.objects.all().delete() + user = self.get_superuser() + collection1 = ModerationCollectionFactory(author=user) + collection2 = ModerationCollectionFactory(author=user) + + page1_version = PageVersionFactory(created_by=user) + page2_version = PageVersionFactory(created_by=user) - collection1 = ModerationCollection.objects.create( - author=self.user, name="My collection 1", workflow=self.wf1 + RootModerationRequestTreeNodeFactory( + moderation_request__collection=collection1, + moderation_request__version=page1_version, ) - collection2 = ModerationCollection.objects.create( - author=self.user, name="My collection 2", workflow=self.wf1 + RootModerationRequestTreeNodeFactory( + moderation_request__collection=collection2, + moderation_request__version=page2_version, ) - pg1_version = PageVersionFactory(created_by=self.user) - pg2_version = PageVersionFactory(created_by=self.user) - - collection1.add_version(pg1_version) - collection2.add_version(pg2_version) - - new_version = PageVersionFactory(created_by=self.user) + new_version = PageVersionFactory(created_by=user) url = add_url_parameters( get_admin_url( name="cms_moderation_items_to_collection", language="en", args=() @@ -133,9 +187,14 @@ def test_list_versions_from_collection_id_param(self): collection_id=collection1.pk, ) - response = self.client.get(url) - mr1 = ModerationRequest.objects.get(version=pg1_version, collection=collection1) - mr2 = ModerationRequest.objects.get(version=pg2_version, collection=collection2) + with self.login_user_context(user): + response = self.client.get(url) + mr1 = ModerationRequest.objects.get( + version=page1_version, collection=collection1 + ) + mr2 = ModerationRequest.objects.get( + version=page2_version, collection=collection2 + ) # mr1 is in the list as it belongs to collection1 self.assertIn(mr1, response.context_data["moderation_requests"]) self.assertNotIn(mr2, response.context_data["moderation_requests"]) @@ -144,22 +203,18 @@ def test_add_pages_moderated_children_to_collection(self): """ A page with multiple moderatable children automatically adds them to the collection """ - collection = ModerationCollection.objects.create( - author=self.user, name="My collection 1", workflow=self.wf1 - ) - pg_version = PageVersionFactory(created_by=self.user) - language = pg_version.content.language + user = self.get_superuser() + collection = ModerationCollectionFactory(author=user) + + page_version = PageVersionFactory(created_by=user) + language = page_version.content.language # Populate page - placeholder = PlaceholderFactory(source=pg_version.content) - poll_1_version = PollVersionFactory( - created_by=self.user, content__language=language - ) - poll_2_version = PollVersionFactory( - created_by=self.user, content__language=language - ) - PollPluginFactory(placeholder=placeholder, poll=poll_1_version.content.poll) - PollPluginFactory(placeholder=placeholder, poll=poll_2_version.content.poll) + placeholder = PlaceholderFactory(source=page_version.content) + poll1_version = PollVersionFactory(created_by=user, content__language=language) + poll2_version = PollVersionFactory(created_by=user, content__language=language) + PollPluginFactory(placeholder=placeholder, poll=poll1_version.content.poll) + PollPluginFactory(placeholder=placeholder, poll=poll2_version.content.poll) admin_endpoint = get_admin_url( name="cms_moderation_items_to_collection", language="en", args=() @@ -167,14 +222,15 @@ def test_add_pages_moderated_children_to_collection(self): url = add_url_parameters( admin_endpoint, return_to_url="http://example.com", - version_ids=pg_version.pk, + version_ids=page_version.pk, collection_id=collection.pk, ) - response = self.client.post( - path=url, - data={"collection": collection.pk, "versions": [pg_version.pk]}, - follow=False, - ) + with self.login_user_context(user): + response = self.client.post( + path=url, + data={"collection": collection.pk, "versions": [page_version.pk]}, + follow=False, + ) # Match collection and versions in the DB stored_collection = ModerationRequest.objects.filter(collection=collection) @@ -182,23 +238,26 @@ def test_add_pages_moderated_children_to_collection(self): self.assertEqual(302, response.status_code) self.assertEqual(admin_endpoint, response.url) self.assertEqual(stored_collection.count(), 3) + mr = ModerationRequest.objects.filter( + collection=collection, version=page_version + ) + self.assertEqual(mr.count(), 1) self.assertEqual( - pg_version, - ModerationRequest.objects.get( - collection=collection, version=pg_version - ).version, + ModerationRequestTreeNode.objects.filter(moderation_request=mr).count(), 1 + ) + mr1 = ModerationRequest.objects.filter( + collection=collection, version=poll1_version ) + self.assertEqual(mr1.count(), 1) self.assertEqual( - poll_1_version, - ModerationRequest.objects.get( - collection=collection, version=poll_1_version - ).version, + ModerationRequestTreeNode.objects.filter(moderation_request=mr1).count(), 1 + ) + mr2 = ModerationRequest.objects.filter( + collection=collection, version=poll2_version ) + self.assertEqual(mr2.count(), 1) self.assertEqual( - poll_2_version, - ModerationRequest.objects.get( - collection=collection, version=poll_2_version - ).version, + ModerationRequestTreeNode.objects.filter(moderation_request=mr2).count(), 1 ) def test_add_pages_moderated_duplicated_children_to_collection(self): @@ -206,17 +265,15 @@ def test_add_pages_moderated_duplicated_children_to_collection(self): A page with multiple instances of the same version added to the collection should only add it to the collection once! """ - collection = ModerationCollection.objects.create( - author=self.user, name="My collection 1", workflow=self.wf1 - ) - pg_version = PageVersionFactory(created_by=self.user) - language = pg_version.content.language + user = self.get_superuser() + collection = ModerationCollectionFactory(author=user) + + page_version = PageVersionFactory(created_by=user) + language = page_version.content.language # Populate page - placeholder = PlaceholderFactory(source=pg_version.content) - poll_version = PollVersionFactory( - created_by=self.user, content__language=language - ) + placeholder = PlaceholderFactory(source=page_version.content) + poll_version = PollVersionFactory(created_by=user, content__language=language) PollPluginFactory(placeholder=placeholder, poll=poll_version.content.poll) PollPluginFactory(placeholder=placeholder, poll=poll_version.content.poll) @@ -226,29 +283,33 @@ def test_add_pages_moderated_duplicated_children_to_collection(self): url = add_url_parameters( admin_endpoint, return_to_url="http://example.com", - version_ids=pg_version.pk, + version_ids=page_version.pk, collection_id=collection.pk, ) - response = self.client.post( - path=url, data={"collection": collection.pk, "versions": [pg_version.pk]} - ) + with self.login_user_context(user): + response = self.client.post( + path=url, + data={"collection": collection.pk, "versions": [page_version.pk]}, + ) stored_collection = ModerationRequest.objects.filter(collection=collection) self.assertEqual(302, response.status_code) self.assertEqual(admin_endpoint, response.url) - self.assertEqual(stored_collection.count(), 2) + self.assertEqual(stored_collection.filter(version=page_version).count(), 1) self.assertEqual( - pg_version, - ModerationRequest.objects.get( - collection=collection, version=pg_version - ).version, + ModerationRequestTreeNode.objects.filter( + moderation_request=stored_collection.get(version=page_version) + ).count(), + 1, ) + self.assertEqual(stored_collection.filter(version=poll_version).count(), 1) + # TODO: Check with Andrew that this should definitely have 2 self.assertEqual( - poll_version, - ModerationRequest.objects.get( - collection=collection, version=poll_version - ).version, + ModerationRequestTreeNode.objects.filter( + moderation_request=stored_collection.get(version=poll_version) + ).count(), + 2, ) def test_add_pages_moderated_duplicated_children_to_collection_for_author_only( @@ -257,21 +318,18 @@ def test_add_pages_moderated_duplicated_children_to_collection_for_author_only( """ A page with moderatable children created by different authors only automatically adds the current users items """ - collection = ModerationCollection.objects.create( - author=self.user, name="My collection 1", workflow=self.wf1 - ) - pg_version = PageVersionFactory(created_by=self.user) - language = pg_version.content.language + user = self.get_superuser() + user2 = UserFactory() + collection = ModerationCollectionFactory(author=user) + + page_version = PageVersionFactory(created_by=user) + language = page_version.content.language # Populate page - placeholder = PlaceholderFactory(source=pg_version.content) - poll_1_version = PollVersionFactory( - created_by=self.user, content__language=language - ) - poll_2_version = PollVersionFactory( - created_by=self.user2, content__language=language - ) - PollPluginFactory(placeholder=placeholder, poll=poll_1_version.content.poll) - PollPluginFactory(placeholder=placeholder, poll=poll_2_version.content.poll) + placeholder = PlaceholderFactory(source=page_version.content) + poll1_version = PollVersionFactory(created_by=user, content__language=language) + poll2_version = PollVersionFactory(created_by=user2, content__language=language) + PollPluginFactory(placeholder=placeholder, poll=poll1_version.content.poll) + PollPluginFactory(placeholder=placeholder, poll=poll2_version.content.poll) admin_endpoint = get_admin_url( name="cms_moderation_items_to_collection", language="en", args=() @@ -279,47 +337,68 @@ def test_add_pages_moderated_duplicated_children_to_collection_for_author_only( url = add_url_parameters( admin_endpoint, return_to_url="http://example.com", - version_ids=pg_version.pk, + version_ids=page_version.pk, collection_id=collection.pk, ) - response = self.client.post( - path=url, data={"collection": collection.pk, "versions": [pg_version.pk]} - ) + with self.login_user_context(user): + response = self.client.post( + path=url, + data={"collection": collection.pk, "versions": [page_version.pk]}, + ) stored_collection = ModerationRequest.objects.filter(collection=collection) self.assertEqual(302, response.status_code) self.assertEqual(admin_endpoint, response.url) self.assertEqual(stored_collection.count(), 2) + self.assertEqual(stored_collection.filter(version=page_version).count(), 1) + self.assertEqual( + ModerationRequestTreeNode.objects.filter( + moderation_request=stored_collection.get(version=page_version) + ).count(), + 1, + ) + self.assertEqual(stored_collection.filter(version=poll1_version).count(), 1) + self.assertEqual( + ModerationRequestTreeNode.objects.filter( + moderation_request=stored_collection.get(version=poll1_version) + ).count(), + 1, + ) + self.assertEqual(stored_collection.filter(version=poll2_version).count(), 0) + self.assertEqual( + ModerationRequestTreeNode.objects.filter( + moderation_request__version=poll2_version + ).count(), + 0, + ) def test_add_pages_moderated_traversed_children_to_collection(self): """ A page with moderatable children that also have moderatable children: child within a child are added to a collection """ - collection = ModerationCollection.objects.create( - author=self.user, name="My collection 1", workflow=self.wf1 - ) - pg_version = PageVersionFactory(created_by=self.user) - language = pg_version.content.language + user = self.get_superuser() + collection = ModerationCollectionFactory(author=user) + + page_version = PageVersionFactory(created_by=user) + language = page_version.content.language # Populate page - pg_placeholder = PlaceholderFactory(source=pg_version.content) - poll_version = PollVersionFactory( - created_by=self.user, content__language=language - ) + placeholder = PlaceholderFactory(source=page_version.content) + poll_version = PollVersionFactory(created_by=user, content__language=language) poll_plugin = PollPluginFactory( - placeholder=pg_placeholder, poll=poll_version.content.poll + placeholder=placeholder, poll=poll_version.content.poll ) # Populate page poll child layer 1 poll_child_1_version = PollVersionFactory( - created_by=self.user, content__language=language + created_by=user, content__language=language ) poll_child_1_plugin = PollPluginFactory( placeholder=poll_plugin.placeholder, poll=poll_child_1_version.content.poll ) # Populate page poll child layer 2 poll_child_2_version = PollVersionFactory( - created_by=self.user, content__language=language + created_by=user, content__language=language ) PollPluginFactory( placeholder=poll_child_1_plugin.placeholder, @@ -332,18 +411,319 @@ def test_add_pages_moderated_traversed_children_to_collection(self): url = add_url_parameters( admin_endpoint, return_to_url="http://example.com", - version_ids=pg_version.pk, + version_ids=page_version.pk, collection_id=collection.pk, ) - response = self.client.post( - path=url, data={"collection": collection.pk, "versions": [pg_version.pk]} - ) + with self.login_user_context(user): + response = self.client.post( + path=url, + data={"collection": collection.pk, "versions": [page_version.pk]}, + ) stored_collection = ModerationRequest.objects.filter(collection=collection) self.assertEqual(302, response.status_code) self.assertEqual(admin_endpoint, response.url) self.assertEqual(stored_collection.count(), 4) + self.assertEqual(stored_collection.filter(version=page_version).count(), 1) + self.assertEqual( + ModerationRequestTreeNode.objects.filter( + moderation_request=stored_collection.get(version=page_version) + ).count(), + 1, + ) + self.assertEqual(stored_collection.filter(version=poll_version).count(), 1) + self.assertEqual( + ModerationRequestTreeNode.objects.filter( + moderation_request=stored_collection.get(version=poll_version) + ).count(), + 1, + ) + self.assertEqual( + stored_collection.filter(version=poll_child_1_version).count(), 1 + ) + self.assertEqual( + ModerationRequestTreeNode.objects.filter( + moderation_request=stored_collection.get(version=poll_child_1_version) + ).count(), + 1, + ) + self.assertEqual( + stored_collection.filter(version=poll_child_2_version).count(), 1 + ) + self.assertEqual( + ModerationRequestTreeNode.objects.filter( + moderation_request=stored_collection.get(version=poll_child_2_version) + ).count(), + 1, + ) + + def test_adding_non_page_item_doesnt_trigger_nested_collection_mechanism(self): + user = self.get_superuser() + collection = ModerationCollectionFactory(author=user) + + poll_version = PollVersionFactory(created_by=user) + language = poll_version.content.language + + # Populate page + placeholder = PlaceholderFactory(source=poll_version.content) + poll1_version = PollVersionFactory(created_by=user, content__language=language) + PollPluginFactory(placeholder=placeholder, poll=poll1_version.content.poll) + + admin_endpoint = get_admin_url( + name="cms_moderation_items_to_collection", language="en", args=() + ) + url = add_url_parameters( + admin_endpoint, + return_to_url="http://example.com", + version_ids=poll_version.pk, + collection_id=collection.pk, + ) + with self.login_user_context(user): + response = self.client.post( + path=url, + data={"collection": collection.pk, "versions": [poll_version.pk]}, + ) + + stored_collection = ModerationRequest.objects.filter(collection=collection) + + self.assertEqual(302, response.status_code) + self.assertEqual(admin_endpoint, response.url) + self.assertEqual(stored_collection.filter(version=poll_version).count(), 1) + self.assertEqual( + ModerationRequestTreeNode.objects.filter( + moderation_request=stored_collection.get(version=poll_version) + ).count(), + 1, + ) + self.assertEqual(stored_collection.filter(version=poll_version).count(), 1) + + def test_adding_page_not_by_the_author_doesnt_trigger_nested_collection_mechanism( + self + ): + user = self.get_superuser() + user2 = UserFactory() + collection = ModerationCollectionFactory(author=user) + + page_version = PageVersionFactory(created_by=user2) + language = page_version.content.language + + # Populate page + placeholder = PlaceholderFactory(source=page_version.content) + poll1_version = PollVersionFactory(created_by=user2, content__language=language) + PollPluginFactory(placeholder=placeholder, poll=poll1_version.content.poll) + + admin_endpoint = get_admin_url( + name="cms_moderation_items_to_collection", language="en", args=() + ) + url = add_url_parameters( + admin_endpoint, + return_to_url="http://example.com", + version_ids=page_version.pk, + collection_id=collection.pk, + ) + with self.login_user_context(user), mock.patch( + "djangocms_moderation.forms.is_obj_version_unlocked" + ): + response = self.client.post( + path=url, + data={"collection": collection.pk, "versions": [page_version.pk]}, + follow=False, + ) + + # Match collection and versions in the DB + stored_collection = ModerationRequest.objects.filter(collection=collection) + self.assertEqual(302, response.status_code) + self.assertEqual(admin_endpoint, response.url) + self.assertEqual(stored_collection.count(), 1) + mr = ModerationRequest.objects.filter( + collection=collection, version=page_version + ) + self.assertEqual(mr.count(), 1) + self.assertEqual( + ModerationRequestTreeNode.objects.filter(moderation_request=mr).count(), 1 + ) + mr1 = ModerationRequest.objects.filter( + collection=collection, version=poll1_version + ) + self.assertEqual(mr1.count(), 0) + self.assertEqual( + ModerationRequestTreeNode.objects.filter(moderation_request=mr1).count(), 0 + ) + + +class CollectionItemsViewTest(CMSTestCase): + def setUp(self): + self.client.force_login(self.get_superuser()) + self.url = get_admin_url( + name="cms_moderation_items_to_collection", language="en", args=() + ) + + def test_404_if_no_collection_with_specified_id(self): + self.url += "?collection_id=15" + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, 404) + + def test_404_if_collection_id_not_an_int(self): + self.url += "?collection_id=aaa" + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, 404) + + def test_moderation_requests_empty_in_context_if_no_collection_id_specified(self): + response = self.client.get(self.url) + + self.assertListEqual(response.context["moderation_requests"], []) + + def test_initial_form_values_when_collection_id_passed(self): + collection = ModerationCollectionFactory() + pg_version = PageVersionFactory() + poll_version = PollVersionFactory() + self.url += "?collection_id=" + str(collection.pk) + self.url += "&version_ids={},{}".format(pg_version.pk, poll_version.pk) + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.context["form"].initial.keys()), 2) + self.assertEqual( + response.context["form"].initial["collection"], str(collection.pk) + ) + self.assertQuerysetEqual( + response.context["form"].initial["versions"], + [pg_version.pk, poll_version.pk], + transform=lambda o: o.pk, + ordered=False, + ) + + def test_initial_form_values_when_collection_id_not_passed(self): + pg_version = PageVersionFactory() + poll_version = PollVersionFactory() + self.url += "?version_ids={},{}".format(pg_version.pk, poll_version.pk) + + response = self.client.get(self.url) + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.context["form"].initial.keys()), 1) + self.assertQuerysetEqual( + response.context["form"].initial["versions"], + [pg_version.pk, poll_version.pk], + transform=lambda o: o.pk, + ordered=False, + ) + + def test_initial_form_values_when_no_version_ids_passed(self): + response = self.client.get(self.url) + + self.assertEqual(response.status_code, 200) + self.assertEqual(len(response.context["form"].initial.keys()), 1) + self.assertEqual(response.context["form"].initial["versions"].count(), 0) + + def test_collection_widget_gets_set_on_form(self): + response = self.client.get(self.url) + + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.context["form"].fields["collection"].widget.__class__, + RelatedFieldWidgetWrapper, + ) + + def test_tree_nodes_are_created(self): + """ + Moderation request nodes are created with the correct structure + + Created node structure: + + page + poll + poll_child + poll_grandchild + poll_child + poll_grandchild + """ + user = self.get_superuser() + admin_endpoint = get_admin_url( + name="cms_moderation_items_to_collection", language="en", args=() + ) + collection = ModerationCollectionFactory(author=user) + page_version = PageVersionFactory(created_by=user) + placeholder = PlaceholderFactory(source=page_version.content) + language = page_version.content.language + + # Populate poll + poll_version = PollVersionFactory(created_by=user, content__language=language) + PollPluginFactory( + placeholder=placeholder, poll=poll_version.content.poll + ) + + # Populate poll child + poll_child_version = PollVersionFactory( + created_by=user, content__language=language + ) + PollPluginFactory( + placeholder=poll_version.content.placeholder, + poll=poll_child_version.content.poll, + ) + + # Populate grand child + poll_grandchild_version = PollVersionFactory( + created_by=user, content__language=language + ) + PollPluginFactory( + placeholder=poll_child_version.content.placeholder, + poll=poll_grandchild_version.content.poll, + ) + + # Add poll_child directly to page as well + PollPluginFactory( + placeholder=placeholder, poll=poll_child_version.content.poll + ) + + url = add_url_parameters( + admin_endpoint, + return_to_url="http://example.com", + version_ids=page_version.pk, + collection_id=collection.pk, + ) + with self.login_user_context(user): + response = self.client.post( + path=url, + data={"collection": collection.pk, "versions": [page_version.pk]}, + ) + + self.assertEqual(302, response.status_code) + self.assertEqual(admin_endpoint, response.url) + + nodes = ModerationRequestTreeNode.objects.filter( + moderation_request__collection_id=collection.pk + ) + + # The correct amount of nodes exist + self.assertEqual(nodes.count(), 6) + # Now assert the tree structure... + # Check root refers to correct version & has correct number of children + root = ModerationRequestTreeNode.get_root_nodes().get() + self.assertEqual(root.moderation_request.version, page_version) + self.assertEqual(root.get_children_count(), 2) + # Check first child of root has correct tree + poll_node = root.get_children().get(moderation_request__version=poll_version) + self.assertEqual(poll_node.get_children_count(), 1) + poll_child_node = poll_node.get_children().get() + self.assertEqual(poll_child_node.moderation_request.version, poll_child_version) + self.assertEqual(poll_child_node.get_children_count(), 1) + poll_grandchild_node = poll_child_node.get_children().get() + self.assertEqual(poll_grandchild_node.moderation_request.version, poll_grandchild_version) + # Check second child of root has correct tree + poll_child_node2 = root.get_children().get(moderation_request__version=poll_child_version) + self.assertNotEqual(poll_child_node, poll_child_node2) + self.assertEqual(poll_child_node2.moderation_request.version, poll_child_version) + self.assertEqual(poll_child_node2.get_children_count(), 1) + poll_grandchild_node2 = poll_child_node2.get_children().get() + self.assertNotEqual(poll_grandchild_node, poll_grandchild_node2) + self.assertEqual(poll_grandchild_node2.moderation_request.version, poll_grandchild_version) class SubmitCollectionForModerationViewTest(BaseViewTestCase): @@ -356,7 +736,7 @@ def setUp(self): request_change_list_url = reverse( "admin:djangocms_moderation_moderationrequest_changelist" ) - self.request_change_list_url = "{}?collection__id__exact={}".format( + self.request_change_list_url = "{}?moderation_request__collection__id={}".format( request_change_list_url, self.collection2.pk ) @@ -400,7 +780,7 @@ def setUp(self): args=(self.collection2.pk,), ) self.url = reverse("admin:djangocms_moderation_moderationrequest_changelist") - self.url_with_filter = "{}?collection__id__exact={}".format( + self.url_with_filter = "{}?moderation_request__collection__id={}".format( self.url, self.collection2.pk ) @@ -427,3 +807,64 @@ def test_change_list_view_should_contain_submit_collection_url( allow_submit_mock.return_value = True response = self.client.get(self.url_with_filter) self.assertIn("submit_for_review_url", response.context) + + +class TransactionCollectionItemsViewTestCase(TransactionTestCase): + def setUp(self): + # Create db data + self.user = UserFactory(is_staff=True, is_superuser=True) + self.collection = ModerationCollectionFactory(author=self.user) + self.moderation_request1 = ModerationRequestFactory(collection=self.collection) + self.moderation_request2 = ModerationRequestFactory(collection=self.collection) + self.root1 = RootModerationRequestTreeNodeFactory( + moderation_request=self.moderation_request1 + ) + self.root2 = RootModerationRequestTreeNodeFactory( + moderation_request=self.moderation_request2 + ) + ChildModerationRequestTreeNodeFactory( + moderation_request=self.moderation_request1, parent=self.root1 + ) + + self.page_version = PageVersionFactory(created_by=self.user) + + # Login + self.client.force_login(self.user) + + # Generate url and POST data + self.url = add_url_parameters( + reverse("admin:cms_moderation_items_to_collection"), + return_to_url="http://example.com", + version_ids=self.page_version, + collection_id=self.collection.pk, + ) + + self.data = {"collection": self.collection.pk, "versions": self.page_version.pk} + + def tearDown(self): + # clear content type cache for page content's versionable + del self.moderation_request1.version.versionable.content_types + + @mock.patch("djangocms_moderation.admin.messages.success") + def test_add_to_collection_view_is_wrapped_in_db_transaction(self, messages_mock): + class FakeError(Exception): + pass + + # Throw an exception to cause a db rollback. + # Throwing FakeError as no actual code will ever throw it and + # therefore catching this later in the test will not cover up a + # genuine issue + messages_mock.side_effect = FakeError + + # Do the request to add to collection view + try: + self.client.post(self.url, self.data) + except FakeError: + # This is what messages_mock should have thrown, + # but we don't want the test to throw it. + pass + + # Check neither the tree nodes nor the requests have been added. + # The db transaction should have rolled back. + self.assertEqual(ModerationRequestTreeNode.objects.all().count(), 3) + self.assertEqual(ModerationRequest.objects.all().count(), 2) diff --git a/tests/utils/factories.py b/tests/utils/factories.py index d1fb833e..74a44fe4 100644 --- a/tests/utils/factories.py +++ b/tests/utils/factories.py @@ -6,10 +6,18 @@ import factory from djangocms_versioning.models import Version -from djangocms_versioning.test_utils.factories import AbstractVersionFactory +from djangocms_versioning.test_utils.factories import ( + AbstractVersionFactory, + PageVersionFactory, +) from factory.fuzzy import FuzzyChoice, FuzzyInteger, FuzzyText -from djangocms_moderation.models import ModerationCollection, Workflow +from djangocms_moderation.models import ( + ModerationCollection, + ModerationRequest, + ModerationRequestTreeNode, + Workflow, +) from .moderated_polls.models import Poll, PollContent, PollPlugin from .versioned_none_moderated_app.models import ( @@ -151,3 +159,39 @@ class ModerationCollectionFactory(factory.django.DjangoModelFactory): class Meta: model = ModerationCollection + + +class ModerationRequestFactory(factory.django.DjangoModelFactory): + collection = factory.SubFactory(ModerationCollectionFactory) + version = factory.SubFactory(PageVersionFactory) + language = 'en' + author = factory.LazyAttribute(lambda o: o.collection.author) + + class Meta: + model = ModerationRequest + + +class RootModerationRequestTreeNodeFactory(factory.django.DjangoModelFactory): + moderation_request = factory.SubFactory(ModerationRequestFactory) + + class Meta: + model = ModerationRequestTreeNode + + @classmethod + def _create(cls, model_class, *args, **kwargs): + """Make sure this is the root of a tree""" + return model_class.add_root(*args, **kwargs) + + +class ChildModerationRequestTreeNodeFactory(factory.django.DjangoModelFactory): + moderation_request = factory.SubFactory(ModerationRequestFactory) + parent = factory.SubFactory(RootModerationRequestTreeNodeFactory) + + class Meta: + model = ModerationRequestTreeNode + inline_args = ("parent",) + + @classmethod + def _create(cls, model_class, parent, *args, **kwargs): + """Make sure this is the child of a parent node""" + return parent.add_child(*args, **kwargs) diff --git a/tests/utils/moderated_polls/models.py b/tests/utils/moderated_polls/models.py index 5c7fb93f..32437bb6 100644 --- a/tests/utils/moderated_polls/models.py +++ b/tests/utils/moderated_polls/models.py @@ -1,8 +1,9 @@ from django.db import models from django.urls import reverse +from django.utils.functional import cached_property -from cms.models import CMSPlugin -from cms.models.fields import PlaceholderField +from cms.models import CMSPlugin, Placeholder +from cms.models.fields import PlaceholderRelationField class Poll(models.Model): @@ -16,7 +17,16 @@ class PollContent(models.Model): poll = models.ForeignKey(Poll, on_delete=models.CASCADE) language = models.TextField() text = models.TextField() - placeholder = PlaceholderField("placeholder") + placeholders = PlaceholderRelationField() + + @cached_property + def placeholder(self): + try: + return self.placeholders.get(slot='content') + except Placeholder.DoesNotExist: + from cms.utils.placeholder import rescan_placeholders_for_obj + rescan_placeholders_for_obj(self) + return self.placeholders.get(slot='content') def __str__(self): return self.text @@ -27,6 +37,9 @@ def get_absolute_url(self): def get_placeholders(self): return [self.placeholder] + def get_template(self): + return 'polls/poll_content.html' + class Answer(models.Model): poll_content = models.ForeignKey(PollContent, on_delete=models.CASCADE) diff --git a/tests/utils/moderated_polls/templates/polls/poll_content.html b/tests/utils/moderated_polls/templates/polls/poll_content.html new file mode 100644 index 00000000..9b7f41c7 --- /dev/null +++ b/tests/utils/moderated_polls/templates/polls/poll_content.html @@ -0,0 +1 @@ +{% load cms_tags %}{% placeholder 'content' %} From f2ea1c996c86ef4786bdd84a3dd71ea6abeefe9d Mon Sep 17 00:00:00 2001 From: Krzysztof Socha Date: Thu, 4 Apr 2019 10:42:29 +0200 Subject: [PATCH 108/147] Release 1.0.19 (#146) --- djangocms_moderation/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/djangocms_moderation/__init__.py b/djangocms_moderation/__init__.py index 01b39116..47a05848 100644 --- a/djangocms_moderation/__init__.py +++ b/djangocms_moderation/__init__.py @@ -1,3 +1,3 @@ -__version__ = "1.0.18" +__version__ = "1.0.19" default_app_config = "djangocms_moderation.apps.ModerationConfig" From b3e29f573a184166cd1c94e1300ed6170fc11d39 Mon Sep 17 00:00:00 2001 From: Krzysztof Socha Date: Thu, 4 Apr 2019 11:03:59 +0200 Subject: [PATCH 109/147] Fix migration graph (#147) --- ...tionrequesttreenode.py => 0016_moderationrequesttreenode.py} | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) rename djangocms_moderation/migrations/{0014_moderationrequesttreenode.py => 0016_moderationrequesttreenode.py} (94%) diff --git a/djangocms_moderation/migrations/0014_moderationrequesttreenode.py b/djangocms_moderation/migrations/0016_moderationrequesttreenode.py similarity index 94% rename from djangocms_moderation/migrations/0014_moderationrequesttreenode.py rename to djangocms_moderation/migrations/0016_moderationrequesttreenode.py index 74478e6b..209b7a1b 100644 --- a/djangocms_moderation/migrations/0014_moderationrequesttreenode.py +++ b/djangocms_moderation/migrations/0016_moderationrequesttreenode.py @@ -9,7 +9,7 @@ class Migration(migrations.Migration): dependencies = [ - ('djangocms_moderation', '0013_auto_20181122_1110'), + ('djangocms_moderation', '0014_auto_20190315_1723'), ] operations = [ From 3a35786b6bcae31b832c01674233b426153ab795 Mon Sep 17 00:00:00 2001 From: Krzysztof Socha Date: Thu, 4 Apr 2019 11:04:59 +0200 Subject: [PATCH 110/147] Release 1.0.20 (#148) --- djangocms_moderation/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/djangocms_moderation/__init__.py b/djangocms_moderation/__init__.py index 47a05848..f1667f31 100644 --- a/djangocms_moderation/__init__.py +++ b/djangocms_moderation/__init__.py @@ -1,3 +1,3 @@ -__version__ = "1.0.19" +__version__ = "1.0.20" default_app_config = "djangocms_moderation.apps.ModerationConfig" From 7b2e9d7d9ed9f792129c7d01529eea7e7168270c Mon Sep 17 00:00:00 2001 From: monikasulik Date: Thu, 4 Apr 2019 13:53:01 +0100 Subject: [PATCH 111/147] Correctly handle duplicates in collection.add_version (#145) --- djangocms_moderation/models.py | 22 ++++-- tests/test_models.py | 119 +++++++++++++++++++++++++++++++++ tests/test_views.py | 3 +- 3 files changed, 138 insertions(+), 6 deletions(-) diff --git a/djangocms_moderation/models.py b/djangocms_moderation/models.py index 9fb495fc..c9993333 100644 --- a/djangocms_moderation/models.py +++ b/djangocms_moderation/models.py @@ -340,17 +340,31 @@ def add_version(self, version, parent=None, include_children=False): Requires validation from .forms.CollectionItemForm :return: """ + added_items = 0 moderation_request, created = self.moderation_requests.get_or_create( version=version, collection=self, author=self.author ) + if created: + added_items += 1 + + # if no parent and a root node with that moderation request + # doesn't exist, it should be created + create_root_node = ( + parent is None and + not ModerationRequestTreeNode.get_root_nodes().filter(moderation_request=moderation_request).exists() + ) + # if parent passed and a child node with that moderation request + # doesn't exist under the parent, it should be created + create_child_node = ( + parent is not None and + not parent.get_children().filter(moderation_request=moderation_request).exists() + ) node = ModerationRequestTreeNode(moderation_request=moderation_request) - if parent is None: + if create_root_node: ModerationRequestTreeNode.add_root(instance=node) - else: + elif create_child_node: parent.add_child(instance=node) - added_items = 1 - if include_children: added_items += self._add_nested_children(version, node) diff --git a/tests/test_models.py b/tests/test_models.py index e6865543..af655d42 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -3,6 +3,7 @@ from django.contrib.auth.models import Permission, User from django.core.exceptions import ValidationError +from django.test import TestCase from django.urls import reverse from djangocms_moderation import constants @@ -12,6 +13,7 @@ ModerationCollection, ModerationRequest, ModerationRequestAction, + ModerationRequestTreeNode, Role, Workflow, WorkflowStep, @@ -657,3 +659,120 @@ def test_cancel(self): ) self.assertEqual(1, actions.count()) self.assertEqual(actions[0].moderation_request, active_request) + + +class AddVersionTestCase(TestCase): + + def setUp(self): + self.collection = factories.ModerationCollectionFactory() + + def test_add_version_as_parent(self): + version = factories.PollVersionFactory() + + moderation_request, added_items = self.collection.add_version(version) + + self.assertEqual(ModerationRequest.objects.all().count(), 1) + self.assertEqual(ModerationRequest.objects.get(), moderation_request) + self.assertEqual(moderation_request.version, version) + self.assertEqual(ModerationRequestTreeNode.objects.all().count(), 1) + self.assertEqual( + ModerationRequestTreeNode.objects.get().moderation_request, + moderation_request + ) + self.assertEqual(added_items, 1) + + def test_add_version_with_parent(self): + version = factories.PollVersionFactory() + parent = factories.RootModerationRequestTreeNodeFactory( + moderation_request__collection=self.collection) + + moderation_request, added_items = self.collection.add_version(version, parent) + + self.assertEqual(ModerationRequest.objects.all().count(), 2) + self.assertEqual( + ModerationRequest.objects.exclude(pk=parent.moderation_request.pk).get(), + moderation_request + ) + self.assertEqual(moderation_request.version, version) + self.assertEqual(ModerationRequestTreeNode.objects.all().count(), 2) + self.assertEqual( + ModerationRequestTreeNode.objects.exclude(pk=parent.pk).get().moderation_request, + moderation_request + ) + self.assertEqual(added_items, 1) + + def test_add_version_duplicate_with_same_parent(self): + version = factories.PollVersionFactory() + parent = factories.RootModerationRequestTreeNodeFactory( + moderation_request__collection=self.collection) + child = factories.ChildModerationRequestTreeNodeFactory( + parent=parent, + moderation_request__version=version, + moderation_request__collection=self.collection + ) + + # Add the same version to the same collection under the same parent + moderation_request, added_items = self.collection.add_version(version, parent) + + self.assertEqual(ModerationRequest.objects.all().count(), 2) + self.assertQuerysetEqual( + ModerationRequest.objects.all(), + [parent.moderation_request.pk, child.moderation_request.pk], + transform=lambda o: o.pk + ) + self.assertEqual(ModerationRequestTreeNode.objects.all().count(), 2) + self.assertQuerysetEqual( + ModerationRequestTreeNode.objects.all(), + [parent.pk, child.pk], + transform=lambda o: o.pk + ) + self.assertEqual(added_items, 0) + self.assertEqual(moderation_request, child.moderation_request) + + def test_add_version_duplicate_with_different_parent(self): + version = factories.PollVersionFactory() + root = factories.RootModerationRequestTreeNodeFactory() + child = factories.ChildModerationRequestTreeNodeFactory( + parent=root, + moderation_request__version=version, + moderation_request__collection=self.collection + ) + parent = factories.RootModerationRequestTreeNodeFactory( + moderation_request__collection=self.collection) + + # Add the same version to the same collection under a different parent + moderation_request, added_items = self.collection.add_version(version, parent) + + self.assertEqual(ModerationRequest.objects.all().count(), 3) + self.assertQuerysetEqual( + ModerationRequest.objects.all(), + [root.moderation_request.pk, child.moderation_request.pk, parent.moderation_request.pk], + transform=lambda o: o.pk + ) + all_nodes = ModerationRequestTreeNode.objects.all() + self.assertEqual(all_nodes.count(), 4) + self.assertIn(parent, all_nodes) + self.assertIn(child, all_nodes) + self.assertIn(root, all_nodes) + added_node = ModerationRequestTreeNode.objects.exclude( + pk__in=[child.pk, parent.pk, root.pk]).get() + self.assertEqual(added_node.moderation_request, child.moderation_request) + self.assertEqual(added_items, 0) + self.assertEqual(moderation_request, child.moderation_request) + + def test_add_version_duplicate_for_parent(self): + version = factories.PollVersionFactory() + parent = factories.RootModerationRequestTreeNodeFactory( + moderation_request__collection=self.collection, + moderation_request__version=version + ) + + # Add the same version to the same collection as parent + moderation_request, added_items = self.collection.add_version(version) + + self.assertEqual(ModerationRequest.objects.all().count(), 1) + self.assertEqual(ModerationRequest.objects.get(), parent.moderation_request) + self.assertEqual(ModerationRequestTreeNode.objects.all().count(), 1) + self.assertEqual(ModerationRequestTreeNode.objects.get(), parent) + self.assertEqual(added_items, 0) + self.assertEqual(moderation_request, parent.moderation_request) diff --git a/tests/test_views.py b/tests/test_views.py index d3da4932..cc45319e 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -304,12 +304,11 @@ def test_add_pages_moderated_duplicated_children_to_collection(self): 1, ) self.assertEqual(stored_collection.filter(version=poll_version).count(), 1) - # TODO: Check with Andrew that this should definitely have 2 self.assertEqual( ModerationRequestTreeNode.objects.filter( moderation_request=stored_collection.get(version=poll_version) ).count(), - 2, + 1, ) def test_add_pages_moderated_duplicated_children_to_collection_for_author_only( From 786a095b6d037975316b96648a954bdbeea42775 Mon Sep 17 00:00:00 2001 From: Aiky30 Date: Wed, 10 Apr 2019 15:20:31 +0100 Subject: [PATCH 112/147] Tests for deleting moderation requests from complex trees (#135) --- tests/test_views.py | 384 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 380 insertions(+), 4 deletions(-) diff --git a/tests/test_views.py b/tests/test_views.py index cc45319e..39d59cb3 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -706,20 +706,20 @@ def test_tree_nodes_are_created(self): # Check root refers to correct version & has correct number of children root = ModerationRequestTreeNode.get_root_nodes().get() self.assertEqual(root.moderation_request.version, page_version) - self.assertEqual(root.get_children_count(), 2) + self.assertEqual(root.get_children().count(), 2) # Check first child of root has correct tree poll_node = root.get_children().get(moderation_request__version=poll_version) - self.assertEqual(poll_node.get_children_count(), 1) + self.assertEqual(poll_node.get_children().count(), 1) poll_child_node = poll_node.get_children().get() self.assertEqual(poll_child_node.moderation_request.version, poll_child_version) - self.assertEqual(poll_child_node.get_children_count(), 1) + self.assertEqual(poll_child_node.get_children().count(), 1) poll_grandchild_node = poll_child_node.get_children().get() self.assertEqual(poll_grandchild_node.moderation_request.version, poll_grandchild_version) # Check second child of root has correct tree poll_child_node2 = root.get_children().get(moderation_request__version=poll_child_version) self.assertNotEqual(poll_child_node, poll_child_node2) self.assertEqual(poll_child_node2.moderation_request.version, poll_child_version) - self.assertEqual(poll_child_node2.get_children_count(), 1) + self.assertEqual(poll_child_node2.get_children().count(), 1) poll_grandchild_node2 = poll_child_node2.get_children().get() self.assertNotEqual(poll_grandchild_node, poll_grandchild_node2) self.assertEqual(poll_grandchild_node2.moderation_request.version, poll_grandchild_version) @@ -867,3 +867,379 @@ class FakeError(Exception): # The db transaction should have rolled back. self.assertEqual(ModerationRequestTreeNode.objects.all().count(), 3) self.assertEqual(ModerationRequest.objects.all().count(), 2) + + +class CollectionItemsViewModerationIntegrationTest(CMSTestCase): + + def setUp(self): + self.user = self.get_superuser() + self.client.force_login(self.user) + self.collection = ModerationCollectionFactory(author=self.user) + self._set_up_initial_page_data() + + def _set_up_initial_page_data(self): + """ + This should create the following tree structure when added to collection: + page_1_version + poll_version + poll_child_version + page_2_version + poll_child_version + """ + # Page 1 + self.page_1_version = PageVersionFactory(created_by=self.user) + language = self.page_1_version.content.language + page_1_placeholder = PlaceholderFactory(source=self.page_1_version.content) + self.poll_version = PollVersionFactory(created_by=self.user, content__language=language) + PollPluginFactory(placeholder=page_1_placeholder, poll=self.poll_version.content.poll) + self.poll_child_version = PollVersionFactory(created_by=self.user, content__language=language) + PollPluginFactory( + placeholder=self.poll_version.content.placeholder, poll=self.poll_child_version.content.poll) + + # Page 2 + self.page_2_version = PageVersionFactory(created_by=self.user, content__language=language) + page_2_placeholder = PlaceholderFactory(source=self.page_2_version.content) + PollPluginFactory(placeholder=page_2_placeholder, poll=self.poll_child_version.content.poll) + + def _add_pages_to_collection(self): + """ + As this is an integration test, adding the pages to collection + via an http call. This ensures the tree is exactly how the add + http call would create it. + """ + admin_endpoint = get_admin_url( + name='cms_moderation_items_to_collection', + language='en', + args=() + ) + url = add_url_parameters( + admin_endpoint, + return_to_url='http://example.com', + version_ids=[self.page_1_version.pk, self.page_2_version.pk], + collection_id=self.collection.pk + ) + response = self.client.post( + path=url, + data={ + 'collection': self.collection.pk, + 'versions': [self.page_1_version.pk, self.page_2_version.pk], + }, + ) + # smoke check the response + self.assertEqual(302, response.status_code) + self.assertEqual(admin_endpoint, response.url) + # The correct amount of moderation requests has been created + self.assertEqual( + ModerationRequest.objects.filter(collection=self.collection).count(), + 4 + ) + # The correct amount of tree nodes has been created + # Poll is repeated twice and will therefore have an additional node + self.assertEqual( + ModerationRequestTreeNode.objects.filter(moderation_request__collection=self.collection).count(), + 5 + ) + # The tree structure for page_1_version is correct + root_1 = ModerationRequestTreeNode.get_root_nodes().get( + moderation_request__version=self.page_1_version) + self.assertEqual(root_1.get_children().count(), 1) + child_1 = root_1.get_children().get() + self.assertEqual(child_1.moderation_request.version, self.poll_version) + self.assertEqual(child_1.get_children().count(), 1) + grandchild = child_1.get_children().get() + self.assertEqual( + grandchild.moderation_request.version, self.poll_child_version) + # The tree structure for page_2_version is correct + root_2 = ModerationRequestTreeNode.get_root_nodes().get( + moderation_request__version=self.page_2_version) + self.assertEqual(root_2.get_children().count(), 1) + child_2 = root_2.get_children().get() + self.assertEqual(child_2.moderation_request.version, self.poll_child_version) + self.assertEqual(grandchild.moderation_request, child_2.moderation_request) + + def test_moderation_workflow_node_deletion_1(self): + """ + Add pages to a collection to create a tree structure like so: + + page_1_version + poll_version + poll_child_version + page_2_version + poll_child_version + + Then delete page_2 version, which should make the tree like so: + + page_1_version + poll_version + + (i.e. poll_child_version should be removed from both pages) + """ + # Do an http call to add all the versions to collection + # and assert the created tree is what is in the docstring + self._add_pages_to_collection() + + # Now remove page_2_version from the collection + page_2_root = ModerationRequestTreeNode.get_root_nodes().get( + moderation_request__version=self.page_2_version) + delete_url = "{}?ids={}&collection_id={}".format( + reverse('admin:djangocms_moderation_moderationrequesttreenode_delete'), + ",".join([str(page_2_root.pk)]), + self.collection.pk, + ) + response = self.client.post(delete_url, follow=True) + self.assertEqual(response.status_code, 200) + + # Load the changelist and check that the page loads without an error + changelist_url = reverse('admin:djangocms_moderation_moderationrequesttreenode_changelist') + changelist_url += "?moderation_request__collection__id={}".format(self.collection.pk) + response = self.client.get(changelist_url) + self.assertEqual(response.status_code, 200) + + # Check the data + # The whole of the page_2_version tree should have been removed. + # Additionally, poll_child_version should have been removed from + # the page_1_version tree. + self.assertEqual( + ModerationRequest.objects.filter(collection=self.collection).count(), + 2 + ) + self.assertEqual( + ModerationRequestTreeNode.objects.filter(moderation_request__collection=self.collection).count(), + 2 + ) + self.assertEqual(ModerationRequestTreeNode.get_root_nodes().count(), 1) + root = ModerationRequestTreeNode.get_root_nodes().get() + self.assertEqual(root.moderation_request.version, self.page_1_version) + self.assertEqual(root.get_children().count(), 1) + self.assertEqual(root.get_children().get().moderation_request.version, self.poll_version) + + def test_moderation_workflow_node_deletion_2(self): + """ + Add pages to a collection to create a tree structure like so: + + page_1_version + poll_version + poll_child_version + page_2_version + poll_child_version + + Then delete page_1_version, which should make the tree like so: + + page_2_version + + (i.e. poll_child_version should be removed from both pages) + """ + # Do an http call to add all the versions to collection + # and assert the created tree is what is in the docstring + self._add_pages_to_collection() + + # Now remove page_1_version from the collection + page_1_root = ModerationRequestTreeNode.get_root_nodes().get( + moderation_request__version=self.page_1_version) + delete_url = "{}?ids={}&collection_id={}".format( + reverse('admin:djangocms_moderation_moderationrequesttreenode_delete'), + ",".join([str(page_1_root.pk)]), + self.collection.pk, + ) + response = self.client.post(delete_url, follow=True) + self.assertEqual(response.status_code, 200) + + # Load the changelist and check that the page loads without an error + changelist_url = reverse('admin:djangocms_moderation_moderationrequesttreenode_changelist') + changelist_url += "?moderation_request__collection__id={}".format(self.collection.pk) + response = self.client.get(changelist_url) + self.assertEqual(response.status_code, 200) + + # Check the data + # The whole of the page_1_version tree should have been removed. + # Additionally, poll_child_version should have been removed from + # the page_2_version tree. + self.assertEqual( + ModerationRequest.objects.filter(collection=self.collection).count(), + 1 + ) + self.assertEqual( + ModerationRequestTreeNode.objects.filter(moderation_request__collection=self.collection).count(), + 1 + ) + self.assertEqual(ModerationRequestTreeNode.get_root_nodes().count(), 1) + root = ModerationRequestTreeNode.get_root_nodes().get() + self.assertEqual(root.moderation_request.version, self.page_2_version) + self.assertEqual(root.get_children().count(), 0) + + def test_moderation_workflow_node_deletion_3(self): + """ + Add pages to a collection to create a tree structure like so: + + page_1_version + poll_version + poll_child_version + page_2_version + poll_child_version + + Then delete poll_version, which should make the tree like so: + + page_1_version + page_2_version + + (i.e. poll_child_version should be removed from both pages) + """ + # Do an http call to add all the versions to collection + # and assert the created tree is what is in the docstring + self._add_pages_to_collection() + + # Now remove poll_version from the collection + page_1_root = ModerationRequestTreeNode.get_root_nodes().get( + moderation_request__version=self.page_1_version) + poll_1_node = page_1_root.get_children().get() + delete_url = "{}?ids={}&collection_id={}".format( + reverse('admin:djangocms_moderation_moderationrequesttreenode_delete'), + ",".join([str(poll_1_node.pk)]), + self.collection.pk, + ) + response = self.client.post(delete_url, follow=True) + self.assertEqual(response.status_code, 200) + + # Load the changelist and check that the page loads without an error + changelist_url = reverse('admin:djangocms_moderation_moderationrequesttreenode_changelist') + changelist_url += "?moderation_request__collection__id={}".format(self.collection.pk) + response = self.client.get(changelist_url) + self.assertEqual(response.status_code, 200) + + # Check the data + # Only the roots (page_1_version and page_2_version) should remain + self.assertEqual( + ModerationRequest.objects.filter(collection=self.collection).count(), + 2 + ) + self.assertEqual( + ModerationRequestTreeNode.objects.filter(moderation_request__collection=self.collection).count(), + 2 + ) + self.assertEqual(ModerationRequestTreeNode.get_root_nodes().count(), 2) + self.assertEqual( + ModerationRequestTreeNode.objects.filter(moderation_request__version=self.page_1_version).count(), + 1 + ) + self.assertEqual( + ModerationRequestTreeNode.objects.filter(moderation_request__version=self.page_2_version).count(), + 1 + ) + + def test_moderation_workflow_node_deletion_4(self): + """ + Add pages to a collection to create a tree structure like so: + + page_1_version + poll_version + poll_child_version + page_2_version + poll_child_version + + Then delete poll_child_version from the page 1 tree, which should make the tree like so: + + page_1_version + poll_version + page_2_version + + (i.e. poll_child_version should be removed from both pages) + """ + # Do an http call to add all the versions to collection + # and assert the created tree is what is in the docstring + self._add_pages_to_collection() + + # Now remove poll_version from the collection + page_1_root = ModerationRequestTreeNode.get_root_nodes().get( + moderation_request__version=self.page_1_version) + poll_grandchild_node = page_1_root.get_children().get().get_children().get() + delete_url = "{}?ids={}&collection_id={}".format( + reverse('admin:djangocms_moderation_moderationrequesttreenode_delete'), + ",".join([str(poll_grandchild_node.pk)]), + self.collection.pk, + ) + response = self.client.post(delete_url, follow=True) + self.assertEqual(response.status_code, 200) + + # Load the changelist and check that the page loads without an error + changelist_url = reverse('admin:djangocms_moderation_moderationrequesttreenode_changelist') + changelist_url += "?moderation_request__collection__id={}".format(self.collection.pk) + response = self.client.get(changelist_url) + self.assertEqual(response.status_code, 200) + + # Check the data + # Only the roots (page_1_version and page_2_version) should remain + self.assertEqual( + ModerationRequest.objects.filter(collection=self.collection).count(), + 3 + ) + self.assertEqual( + ModerationRequestTreeNode.objects.filter(moderation_request__collection=self.collection).count(), + 3 + ) + self.assertEqual(ModerationRequestTreeNode.get_root_nodes().count(), 2) + root_1 = ModerationRequestTreeNode.get_root_nodes().filter( + moderation_request__version=self.page_1_version).get() + root_2 = ModerationRequestTreeNode.get_root_nodes().filter( + moderation_request__version=self.page_2_version).get() + self.assertEqual(root_1.get_children().count(), 1) + self.assertEqual(root_2.get_children().count(), 0) + self.assertEqual(root_1.get_children().get().moderation_request.version, self.poll_version) + + def test_moderation_workflow_node_deletion_5(self): + """ + Add pages to a collection to create a tree structure like so: + + page_1_version + poll_version + poll_child_version + page_2_version + poll_child_version + + Then delete poll_child_version from the page 2 tree, which should make the tree like so: + + page_1_version + poll_version + page_2_version + + (i.e. poll_child_version should be removed from both pages) + """ + # Do an http call to add all the versions to collection + # and assert the created tree is what is in the docstring + self._add_pages_to_collection() + + # Now remove poll_version from the collection + page_2_root = ModerationRequestTreeNode.get_root_nodes().get( + moderation_request__version=self.page_2_version) + poll_child_node = page_2_root.get_children().get() + delete_url = "{}?ids={}&collection_id={}".format( + reverse('admin:djangocms_moderation_moderationrequesttreenode_delete'), + ",".join([str(poll_child_node.pk)]), + self.collection.pk, + ) + response = self.client.post(delete_url, follow=True) + self.assertEqual(response.status_code, 200) + + # Load the changelist and check that the page loads without an error + changelist_url = reverse('admin:djangocms_moderation_moderationrequesttreenode_changelist') + changelist_url += "?moderation_request__collection__id={}".format(self.collection.pk) + response = self.client.get(changelist_url) + self.assertEqual(response.status_code, 200) + + # Check the data + # Only the roots (page_1_version and page_2_version) should remain + self.assertEqual( + ModerationRequest.objects.filter(collection=self.collection).count(), + 3 + ) + self.assertEqual( + ModerationRequestTreeNode.objects.filter(moderation_request__collection=self.collection).count(), + 3 + ) + self.assertEqual(ModerationRequestTreeNode.get_root_nodes().count(), 2) + root_1 = ModerationRequestTreeNode.get_root_nodes().filter( + moderation_request__version=self.page_1_version).get() + root_2 = ModerationRequestTreeNode.get_root_nodes().filter( + moderation_request__version=self.page_2_version).get() + self.assertEqual(root_1.get_children().count(), 1) + self.assertEqual(root_2.get_children().count(), 0) + self.assertEqual(root_1.get_children().get().moderation_request.version, self.poll_version) From b535a10dbb590d11013b6eb9683ddca14e314f9e Mon Sep 17 00:00:00 2001 From: Noel da Costa Date: Fri, 12 Apr 2019 13:38:17 +0100 Subject: [PATCH 113/147] Added Sphinx documentation (#140) --- .gitignore | 1 + djangocms_moderation/monkeypatch.py | 6 ++ docs/_static/nested-layout.jpg | Bin 0 -> 224429 bytes docs/admin_moderation.rst | 20 ++++++ docs/comment.rst | 8 +++ docs/index.rst | 47 +++++++++++++- docs/internals.rst | 13 ++++ docs/introduction.rst | 10 +++ docs/lock.rst | 9 +++ docs/moderation_collection.rst | 95 ++++++++++++++++++++++++++++ docs/moderation_integration.rst | 44 +++++++++++++ docs/moderation_request.rst | 29 +++++++++ docs/moderation_request_action.rst | 6 ++ docs/overview.rst | 16 +++++ docs/references.rst | 5 ++ docs/role.rst | 30 +++++++++ docs/tree_admin.rst | 27 ++++++++ docs/workflow.rst | 7 ++ docs/workflow_step.rst | 11 ++++ 19 files changed, 383 insertions(+), 1 deletion(-) create mode 100644 docs/_static/nested-layout.jpg create mode 100644 docs/admin_moderation.rst create mode 100644 docs/comment.rst create mode 100644 docs/internals.rst create mode 100644 docs/introduction.rst create mode 100644 docs/lock.rst create mode 100644 docs/moderation_collection.rst create mode 100644 docs/moderation_integration.rst create mode 100644 docs/moderation_request.rst create mode 100644 docs/moderation_request_action.rst create mode 100644 docs/overview.rst create mode 100644 docs/references.rst create mode 100644 docs/role.rst create mode 100644 docs/tree_admin.rst create mode 100644 docs/workflow.rst create mode 100644 docs/workflow_step.rst diff --git a/.gitignore b/.gitignore index c3b1fe07..8b8a0d67 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ build/ .python-version node_modules/ yarn.lock +docs/_build/ diff --git a/djangocms_moderation/monkeypatch.py b/djangocms_moderation/monkeypatch.py index f9844a2c..00d0694f 100644 --- a/djangocms_moderation/monkeypatch.py +++ b/djangocms_moderation/monkeypatch.py @@ -69,6 +69,9 @@ def _is_placeholder_review_unlocked(placeholder, user): def _is_version_review_locked(message): + """ + Checks if version is in review + """ def inner(version, user): if is_registered_for_moderation(version.content) and is_obj_review_locked( version.content, user @@ -89,6 +92,9 @@ def get_latest_draft_version(version): def _is_draft_version_review_locked(message): def inner(version, user): + """ + Checks if version is a draft and in review + """ draft_version = get_latest_draft_version(version) if ( draft_version diff --git a/docs/_static/nested-layout.jpg b/docs/_static/nested-layout.jpg new file mode 100644 index 0000000000000000000000000000000000000000..85ee6f3cdde8725b70ad98510457b3ef82d5bc18 GIT binary patch literal 224429 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

      a;>x%*hQ=t6K~241 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 literal 0 HcmV?d00001 diff --git a/docs/admin_moderation.rst b/docs/admin_moderation.rst new file mode 100644 index 00000000..12466449 --- /dev/null +++ b/docs/admin_moderation.rst @@ -0,0 +1,20 @@ +Admin Moderation +========================== + + +monkeypatch.py +-------------------------- +Moderation monkeypatches some of Versioning's admin pages. +`get_state_actions`:- this adds a "Submit for moderation" link next to draft versions in the Version table for a given content-type. + +It also adds some checks to the `checks-framework` checks registered in Versioning, to prevent certain Versioning functions at certain stages of moderation. + +admin.py +------------------------- +Aside from the usual, there are a number of bulk-action confirmation views that are generated here:- `delete_selected`, `approve`, `rework`, `publish`, `resubmit`. These each provide additional information whilst facilitating confirmation and the `admin_actions.py` redirect to these views. + +The available bulk actions are also controlled by the internal permissions system within Moderation which links Users or Groups to Roles. Each :ref:`moderation_request_action` within a :ref:`workflow` has a single :ref:`role` assigned to it. Each :ref:`moderation_collection` has a :ref:`workflow`. The result is that not all bulk-actions will be available to every user and some will appear only when the :ref:`moderation_request` is in a particular inferred state. + +cms_toolbars.py +------------------------- +Replaces the VersioningToolbar object with the ModerationToolbar object in order to show Versioning-related buttons at the correct part of the :ref:`workflow`. diff --git a/docs/comment.rst b/docs/comment.rst new file mode 100644 index 00000000..8bdc8c25 --- /dev/null +++ b/docs/comment.rst @@ -0,0 +1,8 @@ +.. _comment: + +Comment +================================================ +Comments may be added to various moderation entities: + * :ref:`moderation_collection` + * :ref:`moderation_request` + * :ref:`moderation_request_action` \ No newline at end of file diff --git a/docs/index.rst b/docs/index.rst index cdac9387..e97881fa 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -8,8 +8,53 @@ Welcome to djangocms-moderation's documentation! .. toctree:: :maxdepth: 2 - :caption: Contents: + :caption: User Documentation: + overview + moderation_collection + comment + lock + moderation_request + moderation_request_action + role + references + workflow + workflow_step + + +.. toctree:: + :maxdepth: 2 + :caption: Developer Documentation: + + introduction + internals + + +Glossary +-------- + +.. glossary:: + + Moderation + A process by which a draft version (see docs for `djangocms-versioning `_) goes through an approval process before it can be published. + + Moderation Collection + A collection (or batch) of drafts ready for moderation. + + Moderation Request + Each draft in a :ref:`moderation_collection` is wrapped as a :ref:`moderation_request` in order to associate additional :ref:`Workflow` -related data with that draft. Each request may also have comments added to it and may send out notifications + + Workflow + Each :ref:`moderation_collection` is associated with a :ref:`workflow`. The workflow determines through what steps the moderation process needs to go and may provide a differing moderation UX for each Workflow. + + WorkflowStep + Each :ref:`workflow` has at least one :ref:`workflow_step`. + + Moderation Request Action + Each :ref:`moderation_request` will have a number of actions associated with it. The number of these is defined as part of the :ref:`workflow`. A :ref:`moderation_request_action` is the action taken by an actor who is part of the moderation process. E.g. "mark as approved", "request rework", "publish". + + Role + Each :ref:`moderation_request_action` step in a :ref:`workflow` is associated with a Role. The Role consists either of a single User or a single Group. The users associated with that Role are required to act at that stage of the :ref:`workflow`. Indices and tables diff --git a/docs/internals.rst b/docs/internals.rst new file mode 100644 index 00000000..bbffba46 --- /dev/null +++ b/docs/internals.rst @@ -0,0 +1,13 @@ +.. _internals: + +Internals +================================================ + + +.. toctree:: + :maxdepth: 3 + :caption: Internals: + + admin_moderation + tree_admin + references \ No newline at end of file diff --git a/docs/introduction.rst b/docs/introduction.rst new file mode 100644 index 00000000..4251dfe6 --- /dev/null +++ b/docs/introduction.rst @@ -0,0 +1,10 @@ +.. _introduction: + +Introduction +================================================ + +.. toctree:: + :maxdepth: 3 + :caption: Introduction: + + moderation_integration \ No newline at end of file diff --git a/docs/lock.rst b/docs/lock.rst new file mode 100644 index 00000000..06e7a0b9 --- /dev/null +++ b/docs/lock.rst @@ -0,0 +1,9 @@ +.. _lock: + +Moderation Review Lock +================================================ +As soon as a :ref:`moderation_collection` status becomes in review then its drafts are automatically locked, in the sense that their content can no longer be edited (not at all, not by anyone, not even the collection author). Also once a collection is in Review then content versions cannot be added to the collection. This means that once you’ve clicked “Submit for review”: + * Collection Lock: New drafts cannot be added to the :ref:`moderation_collection` + * Version Lock: Drafts in the :ref:`moderation_collection` cannot be edited unless rejected + +Once a version is published the Moderation Version Lock is removed automatically. \ No newline at end of file diff --git a/docs/moderation_collection.rst b/docs/moderation_collection.rst new file mode 100644 index 00000000..cdddf4fc --- /dev/null +++ b/docs/moderation_collection.rst @@ -0,0 +1,95 @@ +.. _moderation_collection: + +Moderation Collection +================================================ + +A Moderation Collection is primarily intended as a way of being able to group draft content versions together for: +a) review and +b) publishing + +The rules for adding items to a Collection, removing items from a Collection and the actions that can be taken on items the Collection may vary by :ref:`workflow`. + +Publishing is a `djangocms-versioning` feature, thus `djangocms-moderation` depends on and extends the functionality made available by the Versioning addon. + +Collections are stateful. The available states are: + * Collecting + * In review + * Archived + * Cancelled + +Drafts can only be added to a Collection during the `Collecting` phase (see :ref:`lock`) + +Buttons +------------------------------------------------- + +Add Collection +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Those with permissions can create new collections. The author is auto-assigned as the current user. A :ref:`workflow` must be selected. A name must be given to the collection. If there are already items in the Collection, these will be shown on the confirmation screen. + +Edit Collection +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +The selected workflow can only be changed whilst the Collection is in `collecting` state. + + +Add draft to Collection ("Submit for moderation") +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +The CMSToolbar (for content-types with a preview end-point) will be modified to add the "Submit for moderation" button for draft versions. Doing so allows one to select which Collection to add the draft to. + +Actions +------------------------------------------------- + +Submit for review +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Moves the collection state from `collecting` to `in review`. Only available whilst collection phase is `collecting`. Sends out notifications to the selected reviewers. + +Cancel collection +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Changes the collection state to `cancelled`. + +Archive collection +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Changes the collection state to `archived`. Only available if every :ref:`moderation_request` in the Collection has been approved. + + + +States +------------------------------------------------- + +Collecting +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Once a Collection is created it is in this initial state, which allows draft versions to be added to a Collection by its author only. + +In Review +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +A collection is submitted for review by the collection author. Reviewers (see :ref:`role`) are then able to act on versions in that Collection. Such actions are tracked as a :ref:`moderation_request_action`. Drafts cannot be added to a Moderation Collection while that collection is `in review` and drafts that are already in the Collection have limited editing permissions (see :ref:`lock`). + +Archived +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Once all items in a collection have either been removed or approved, the collection becomes archiveable. Archiving a collection is a manual process. The effect of archiving a collection is that it to facilitate list filtering. Archived collections cannot be modified in any way. + +Cancelled +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +A collection can also be flagged as cancelled. This is similar to Archived except that it can be done at any stage. + + + + +Bulk Actions +------------------------------------------------- +These will appear in the Collection’s action drop-down for each content-type registered with Moderation. + +Remove from collection +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Removes a draft from the collection. + +Approve +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Flags a draft as being ready for publishing. + +Submit for rework (reject) +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Flags a draft as being in need of further editing + +Submit for review +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Useful for items that have been flagged for rework - resubmits them for review, sending out notifications again. diff --git a/docs/moderation_integration.rst b/docs/moderation_integration.rst new file mode 100644 index 00000000..7d9f22ba --- /dev/null +++ b/docs/moderation_integration.rst @@ -0,0 +1,44 @@ +Integrating Moderation +====================== + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + +Moderation depends on `Versioning `_ to be installed. The content-type models that should be moderated need to be registered. This can be done in `cms_config.py` file: + +.. code-block:: python + + # blog/cms_config.py + from collections import OrderedDict + from cms.app_base import CMSAppConfig + from djangocms_versioning.datastructures import VersionableItem, default_copy + from .models import PostContent + + def get_preview_url(obj): + # generate url as required + return obj.get_absolute_url() + + def stories_about_intelligent_cats(request, version, *args, **kwargs): + return version.content.cat_stories + + + class BlogCMSConfig(CMSAppConfig): + djangocms_versioning_enabled = True # -- 1 + djangocms_moderation_enabled = True # -- 2 + versioning = [ + VersionableItem( # -- 3 + content_model=PostContent, + grouper_field_name='post', + copy_function=default_copy, + preview_url=get_preview_url, + ), + ] + moderated_models = [ # -- 4 + PostContent, + ] + +1. This must be set to True for Versioning to read app's CMS config. +2. This must be set to True for Moderation to read app's CMS config. +3. `versioning` attribute takes a list of `VersionableItem` objects. See `djangocms_versioning` documentation for details. +4. `moderated_models` attribute takes a list of moderatable model objects. diff --git a/docs/moderation_request.rst b/docs/moderation_request.rst new file mode 100644 index 00000000..d2ee9a11 --- /dev/null +++ b/docs/moderation_request.rst @@ -0,0 +1,29 @@ +.. _moderation_request: + +Moderation Request +================================================ +While the aim of a :ref:`moderation_collection` is to group draft Version objects together. This is achieved via an intermediary model :ref:`moderation_request` which allows meta-data such as approvals, comments, dates and actors to be associated with each draft as it goes through moderation. + +Conceptually this entity can be thought of as a "request to publish" for a particular draft version. Thus the request tracks the meta-data associated with the moderation process for a particular draft. + +Moderation Requests should not be confused with the standard Django request entity. + +States +------------------------------------------------ +Moderation Requests do not track state directly, however they contain one or more instances of the :ref:`moderation_request_action` entity, which is stateful and the Moderation Request state can thus be inferred from its `moderation request actions`. These latter also link to a draft version, which also has states. The inferred states for a request are: + +Ready for review +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Waiting for approval / rejection. I.e. (@TODO: ???) + +Ready for rework +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Waiting for editing and resubmission for review. I.e. contains one or more actions of the `rejected` state. + +Approved +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Ready to be published. I.e. contains only actions of the 'approved' state. + +Published +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +I.e. refers to a Published version - no longer a draft. diff --git a/docs/moderation_request_action.rst b/docs/moderation_request_action.rst new file mode 100644 index 00000000..290c1aac --- /dev/null +++ b/docs/moderation_request_action.rst @@ -0,0 +1,6 @@ +.. _moderation_request_action: + +Moderation Request Action +================================================ + +Each :ref:`workflow_step` must be assigned to a Role. This allows the moderation system to know the set of valid `Reviewers` for that step. Once that `Reviewer` acts on a given :ref:`moderation_request`, their action is recorded as a :ref:`moderation_request_action`. \ No newline at end of file diff --git a/docs/overview.rst b/docs/overview.rst new file mode 100644 index 00000000..57207d65 --- /dev/null +++ b/docs/overview.rst @@ -0,0 +1,16 @@ +.. _overview: + +Overview +================================================ + +Moderation provides an approval workflow mechanism for organisations who need to ensure that content is approved before it is published. It is designed to extend and compliment the Versioning addon and has that as a dependency. + +The general idea is that a draft version can be submitted for moderation. This involves adding that draft to a :ref:`moderation_collection`, which can be thought of as a chapter, edition or batch of content that aims to all be published simultaneously. Various drafts can be added to the same :ref:`moderation_collection`. + +Drafts within the :ref:`moderation_collection` can then be approved rejected by various parties according to :ref:`role`s defined within the :ref:`workflow` assigned to the :ref:`moderation_collection`. + +Once one or more items within the :ref:`moderation_collection` have been approved, the :ref:`moderation_collection` owner is able to select those items and publish them. + +Comments can also be added to any of these entities :ref:`moderation_collection`, :ref:`moderation_request`, :ref:`moderation_request_action`. + +The Moderation addon makes use of the App Registry features provided as part of DjangoCMS 4.0 in order to register models for various content-types to be moderated. Thus it is possible to configure your CMS project so that some content-types are moderated whilst others are not. Any such model registered with Moderation must also be registered with Versioning. \ No newline at end of file diff --git a/docs/references.rst b/docs/references.rst new file mode 100644 index 00000000..613b4a11 --- /dev/null +++ b/docs/references.rst @@ -0,0 +1,5 @@ +.. _references: + +References +================================================ +Moderation offers integration with the `djangocms-references` addon (`djangocms-references `_). If this is enabled then on the confirmation screen when publishing, all content records that will be affected by the publish action will be listed for review before confirmation. \ No newline at end of file diff --git a/docs/role.rst b/docs/role.rst new file mode 100644 index 00000000..5dcacb9c --- /dev/null +++ b/docs/role.rst @@ -0,0 +1,30 @@ +.. _role: + +Role +================================================ +Understanding the Role model can be a bit tricky because it blurs the lines between the CMS permissions system and the custom permission system implemented by Moderation. So let's break this down a bit... + +Firstly from a CMS permissions perspective - you can define whatever Groups you like to the various standard and customer model permissions for Moderation (e.g. `add_moderationcollection`). However, the recommendation is the following groups: Editor, Publisher and Reviewer. The Reviewer should have permission to view the :ref:`moderation_collection` only (and should generally have very limited access to the CMS - no edit / create permissions for content-types in general. The Editor should have rights to view and edit a :ref:`moderation_collection`. The Publisher should have rights to create, edit, cancel and view a :ref:`moderation_collection`. + +For the purpose of this explanation - `Role` (capitalised) and `role` (lowercase) - `Role` refers to the model, whereas `role` refers to the word "role" in the normal broad application of the English term. + +Moderation has internal permissions logic which does not involve CMS permissions but rather which defines two `roles`, each which will have differing access to parts of the Moderation UI/UX. These `roles` are `Collection author` and `Reviewer`. + +`Collection author` is defined simply by the `author` fk link to a user on the ModerationCollection model instance. It is always a single User. For this user, the following bulk actions will be enabled in the Moderation's change_list view , namely `cancel collection`, `publish` and `remove`. + +`Reviewer` is a more nebulous concept. A ModerationCollection may have a number of `Reviewers`. The :ref:`workflow` has one or more :ref:`workflow_step` each which have a single Role assigned to it. The Role links to either a single User or a Group of Users. This dynamically determines a set of users that are valid `Reviewers` for a given :ref:`moderation_request` at a given :ref:`workflow_step`. Once a valid `Reviewer` acts on a given :ref:`moderation_request`, their action is recorded as a :ref:`moderation_request_action`. A `Reviewer` has access to a compliment of bulk actions - specifically allowing a `Reviewer` to `accept` or `reject` a draft version. + +A User may be both a `Reviewer` and a `Collection author` for a given :ref:`moderation_collection`. + +Thus, the Role model defines `the person/s who is responsible for reviewing a particular step of the workflow`. I.e. it defines the users that may review a `draft` version for a given :ref:`moderation_request` for a given Collection. + +In summary... + +Reviewer +------------------------------------------------ +The Reviewer is responsible for approving / rejecting items in the collection and making comments. +They have access to the `Approve` and `Submit for rework` :ref:`moderation_collection` bulk actions. + +Collection author +------------------------------------------------ +The collection author is responsible for creating, editing and (usually) publishing the collection. They have access to the `Submit for review` and `Publish` :ref:`moderation_collection` bulk actions, as well as the various :ref:`moderation_collection` buttons. diff --git a/docs/tree_admin.rst b/docs/tree_admin.rst new file mode 100644 index 00000000..4198ec60 --- /dev/null +++ b/docs/tree_admin.rst @@ -0,0 +1,27 @@ +.. _tree_admin: + +Tree Admin +================================================ + + +Add Children To Collection +------------------------------------------------ +The CollectionItemsView class from `views.py` provides a way, when adding a Page to a :ref:`moderation_collection` of also adding any drafts from other content-types that are included as plugins in any placeholders on that Page. + +This poses certain UI / UX challenges. The default implementation meant that these drafts would simply be added to the Collection as part of the list of content objects in that collection. However there are several problems with this: + + 1. There's no obvious link between the listed items and the pages that contain them and they would thus be indistinguishable from content objects that may have been added without being part of any page. + 2. Reviewers aren't necessarily interested in all of that detail. They would want to simply be able to moderate the page, including all of it's content. So all of the detail may be undigestible for them. + +The solution for these problems that has been implemented is to rework the admin as a TreeBeard admin view. This creates a tree for each collection which would map the relationships between items that are added as part of a page and that page. + +The outcome of this is that a given content object may be appear repeatedly within the tree, even though it is only added to the Collection once. +e.g. If a `child` that is added to a :ref:`moderation_collection` as part of both `parent1` and `parent2` and individually via that `child` content-type's admin, it would then appear three times within the tree. Removing the `child` from the :ref:`moderation_collection` would remove it from all/any parts of the tree it was part of. + +The tree structure is shown visually using tabbed spacing to indicate nesting. + +.. image:: _static/nested-layout.jpg + +The `ModerationRequestTreeAdmin` class in `admin.py` replaces the original `ModerationRequestAdmin`, providing TreeBeard integration as described above. + +The `delete_selected_view` function of that class ensures that removing an item from the :ref:`moderation_collection` updates the tree correctly via means of a recursive function, `_traverse_moderation_nodes`. diff --git a/docs/workflow.rst b/docs/workflow.rst new file mode 100644 index 00000000..0abf6682 --- /dev/null +++ b/docs/workflow.rst @@ -0,0 +1,7 @@ +.. _workflow: + +Workflow +================================================ +The moderation workflow system is designed to be flexible enough that it can cater for a multi-step approval workflow. E.g. if your organisation has `marketing`, `legal` and `compliance` departments who each need to approve every request, you would add 3 steps to your workflow, each assigned to a different Role. The workflow would require each step to be approved before the :ref:`moderation_request` could be published. + +Workflows are designed to be extensible and customisable for developers. \ No newline at end of file diff --git a/docs/workflow_step.rst b/docs/workflow_step.rst new file mode 100644 index 00000000..7ce3e1b0 --- /dev/null +++ b/docs/workflow_step.rst @@ -0,0 +1,11 @@ +.. _workflow_step: + +Workflow Step +================================================ +Each :ref:`workflow` has at least one :ref:`workflowstep`. These are steps of review the moderation process needs to go through. For example, if an organisation had several different departments, each needing to approve each :ref:`moderation_request`, then: + + 1. Each of those departments would be set up as a user Group + 2. A workflow would be created to represent this + 3. A step would be added for each department and the Group for that department would be assigned as the :ref:`role` for that workflow step. + +As a result, the draft could not be published without first being approved at each step in the :ref:`workflow` From 80f10801b3c9f456d20fcd58629ada8efd077d5b Mon Sep 17 00:00:00 2001 From: Krzysztof Socha Date: Fri, 12 Apr 2019 19:56:06 +0200 Subject: [PATCH 114/147] Add (re)submit for review signals (#149) --- djangocms_moderation/admin.py | 9 ++++- djangocms_moderation/models.py | 9 ++++- djangocms_moderation/signals.py | 4 ++ docs/index.rst | 1 + docs/signals.rst | 52 ++++++++++++++++++++++++ tests/test_signals.py | 72 +++++++++++++++++++++++++++++++++ 6 files changed, 145 insertions(+), 2 deletions(-) create mode 100644 docs/signals.rst create mode 100644 tests/test_signals.py diff --git a/djangocms_moderation/admin.py b/djangocms_moderation/admin.py index 5fd71e52..28180198 100644 --- a/djangocms_moderation/admin.py +++ b/djangocms_moderation/admin.py @@ -21,7 +21,7 @@ from adminsortable2.admin import SortableInlineAdminMixin from treebeard.admin import TreeAdmin -from . import constants +from . import constants, signals from .admin_actions import ( approve_selected, delete_selected, @@ -527,6 +527,13 @@ def resubmit_view(self, request): # stage of moderation - at the beginning action_obj=resubmitted_requests[0].get_last_action(), ) + signals.submitted_for_review.send( + sender=collection.__class__, + collection=collection, + moderation_requests=resubmitted_requests, + user=request.user, + rework=True, + ) messages.success( request, diff --git a/djangocms_moderation/models.py b/djangocms_moderation/models.py index c9993333..05ee7d12 100644 --- a/djangocms_moderation/models.py +++ b/djangocms_moderation/models.py @@ -21,7 +21,7 @@ from .utils import generate_compliance_number -from . import conf, constants # isort:skip +from . import conf, constants, signals # isort:skip @python_2_unicode_compatible @@ -298,6 +298,13 @@ def submit_for_review(self, by_user, to_user=None): moderation_requests=self.moderation_requests.all(), action_obj=action, ) + signals.submitted_for_review.send( + sender=self.__class__, + collection=self, + moderation_requests=list(self.moderation_requests.all()), + user=by_user, + rework=False, + ) def is_cancellable(self, user): return all( diff --git a/djangocms_moderation/signals.py b/djangocms_moderation/signals.py index e52d803f..e2fd26df 100644 --- a/djangocms_moderation/signals.py +++ b/djangocms_moderation/signals.py @@ -4,3 +4,7 @@ confirmation_form_submission = django.dispatch.Signal( providing_args=["page", "language", "user", "form_data"] ) + +submitted_for_review = django.dispatch.Signal( + providing_args=["collection", "moderation_requests", "user", "rework"] +) diff --git a/docs/index.rst b/docs/index.rst index e97881fa..913aeb27 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -28,6 +28,7 @@ Welcome to djangocms-moderation's documentation! introduction internals + signals Glossary diff --git a/docs/signals.rst b/docs/signals.rst new file mode 100644 index 00000000..a0986625 --- /dev/null +++ b/docs/signals.rst @@ -0,0 +1,52 @@ +.. _signals: + +Signals +======= + + +.. module:: djangocms_moderation.signals + :synopsis: Signals sent by the moderation. + +The :mod:`djangocms_moderation.signals` module defines a set of signals sent by +Django CMS Moderation. + +``submitted_for_review`` +------------------------ + +.. attribute:: djangocms_moderation.submitted_for_review + :module: + +.. ^^^^^^^ this :module: hack keeps Sphinx from prepending the module. + +Sent when a :ref:`moderation_collection` is submitted for review, +or when select :ref:`Moderation Requests ` +are resubmitted after being rejected. + +Arguments sent with this signal: + +``sender`` + :class:`djangocms_moderation.models.ModerationCollection` class + +``collection`` + A :class:`djangocms_moderation.models.ModerationCollection` instance + which was submitted for review + +``moderation_requests`` + A list of :class:`djangocms_moderation.models.ModerationRequest` instances + which were submitted for review + + .. note:: + + It's possible for this list to contain only some of the requests + belonging to the collection being moderated, + because only some of the requests required rework. + + This case is only possible for resubmitting after a rework. + +``user`` + A :class:`django.contrib.auth.models.User` instance which triggered + the submission + +``rework`` + A :class:`bool` value specifying if this was the first time the + collection was submitted, or a rework of its moderation requests diff --git a/tests/test_signals.py b/tests/test_signals.py new file mode 100644 index 00000000..8becd947 --- /dev/null +++ b/tests/test_signals.py @@ -0,0 +1,72 @@ +from django.urls import reverse + +from cms.test_utils.testcases import CMSTestCase +from cms.test_utils.util.context_managers import signal_tester +from cms.utils.urlutils import add_url_parameters + +from djangocms_moderation import constants +from djangocms_moderation.models import ModerationCollection, Role +from djangocms_moderation.signals import submitted_for_review + +from .utils import factories + + +class SignalsTestCase(CMSTestCase): + def test_submitted_for_review_signal(self): + """Test that submitting for review emits a signal + """ + moderation_request = factories.ModerationRequestFactory( + collection__status=constants.COLLECTING + ) + user = factories.UserFactory() + reviewer = factories.UserFactory() + + with signal_tester(submitted_for_review) as env: + moderation_request.collection.submit_for_review(user, reviewer) + + self.assertEqual(env.call_count, 1) + + signal = env.calls[0][1] + self.assertEqual(signal["sender"], ModerationCollection) + self.assertEqual(signal["collection"], moderation_request.collection) + self.assertEqual(len(signal["moderation_requests"]), 1) + self.assertEqual(signal["moderation_requests"][0], moderation_request) + self.assertEqual(signal["user"], user) + self.assertFalse(signal["rework"]) + + def test_resubmitted_for_review_signal(self): + """Test that re-submitting for review emits a signal + """ + user = self.get_superuser() + reviewer = factories.UserFactory() + moderation_request = factories.ModerationRequestFactory( + collection__status=constants.COLLECTING, author=user + ) + moderation_request.collection.workflow.steps.create( + role=Role.objects.create(name="Role 1", user=reviewer), order=1 + ) + moderation_request.update_status( + action=constants.ACTION_REJECTED, by_user=reviewer, to_user=user + ) + + with signal_tester(submitted_for_review) as env: + with self.login_user_context(user): + self.client.post( + add_url_parameters( + reverse( + "admin:djangocms_moderation_moderationrequest_resubmit" + ), + collection_id=moderation_request.collection_id, + ids=moderation_request.pk, + ) + ) + + self.assertEqual(env.call_count, 1) + + signal = env.calls[0][1] + self.assertEqual(signal["sender"], ModerationCollection) + self.assertEqual(signal["collection"], moderation_request.collection) + self.assertEqual(len(signal["moderation_requests"]), 1) + self.assertEqual(signal["moderation_requests"][0], moderation_request) + self.assertEqual(signal["user"], user) + self.assertTrue(signal["rework"]) From e108097bd4a00725fc1781d1eff0cb70f8facc0e Mon Sep 17 00:00:00 2001 From: Krzysztof Socha Date: Fri, 12 Apr 2019 19:57:17 +0200 Subject: [PATCH 115/147] Release 1.0.21 (#150) --- djangocms_moderation/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/djangocms_moderation/__init__.py b/djangocms_moderation/__init__.py index f1667f31..6e65f619 100644 --- a/djangocms_moderation/__init__.py +++ b/djangocms_moderation/__init__.py @@ -1,3 +1,3 @@ -__version__ = "1.0.20" +__version__ = "1.0.21" default_app_config = "djangocms_moderation.apps.ModerationConfig" From 609f9cc00f8734eec1a791df5e003f29dca23c08 Mon Sep 17 00:00:00 2001 From: Jonathan Sundqvist Date: Wed, 9 Oct 2019 14:21:35 +0200 Subject: [PATCH 116/147] Make running tests a smoother experience (#155) --- djangocms_moderation/admin_actions.py | 2 +- .../contrib/moderation_forms/cms_plugins.py | 1 - setup.py | 20 +++++++++++++++++++ tests/requirements.txt | 8 -------- tests/settings.py | 10 ++++------ tests/utils/factories.py | 4 ++-- 6 files changed, 27 insertions(+), 18 deletions(-) diff --git a/djangocms_moderation/admin_actions.py b/djangocms_moderation/admin_actions.py index 11ccd552..d1585c23 100644 --- a/djangocms_moderation/admin_actions.py +++ b/djangocms_moderation/admin_actions.py @@ -10,9 +10,9 @@ from cms.utils.urlutils import add_url_parameters -from django_fsm import TransitionNotAllowed from djangocms_versioning.models import Version +from django_fsm import TransitionNotAllowed from djangocms_moderation import constants from .utils import get_admin_url diff --git a/djangocms_moderation/contrib/moderation_forms/cms_plugins.py b/djangocms_moderation/contrib/moderation_forms/cms_plugins.py index 64b29794..de2e1dfd 100644 --- a/djangocms_moderation/contrib/moderation_forms/cms_plugins.py +++ b/djangocms_moderation/contrib/moderation_forms/cms_plugins.py @@ -3,7 +3,6 @@ from cms.plugin_pool import plugin_pool from aldryn_forms.cms_plugins import FormPlugin - from djangocms_moderation.helpers import get_page_or_404 from djangocms_moderation.signals import confirmation_form_submission diff --git a/setup.py b/setup.py index 982f9b82..8cf08572 100644 --- a/setup.py +++ b/setup.py @@ -10,6 +10,18 @@ "django-admin-sortable2>=0.6.4", ] +TEST_REQUIREMENTS = [ + "aldryn-forms", + "django-cms", + "pillow<=5.4.1", # Requirement for tests to pass in python 3.4 + "lxml<=4.3.5", # Requirement for tests to pass in python 3.4 + "djangocms-text-ckeditor", + "djangocms-version-locking", + "djangocms-versioning", + "djangocms_helper", + "factory-boy", + "mock" +] setup( name="djangocms-moderation", @@ -30,5 +42,13 @@ author_email="info@divio.ch", url="http://github.com/divio/djangocms-moderation", license="BSD", + tests_require=TEST_REQUIREMENTS, test_suite="tests.settings.run", + dependency_links=[ + "http://github.com/divio/django-cms/tarball/release/4.0.x#egg=django-cms-4.0.0", + "http://github.com/divio/djangocms-versioning/tarball/master#egg=djangocms-versioning-0.0.23", + "http://github.com/FidelityInternational/djangocms-version-locking/tarball/master#egg=djangocms-version-locking-0.0.13", # noqa + "https://github.com/divio/djangocms-text-ckeditor/tarball/support/4.0.x#egg=djangocms-text-ckeditor-4.0.x", + "https://github.com/divio/aldryn-forms/tarball/master#egg=aldryn-forms" + ] ) diff --git a/tests/requirements.txt b/tests/requirements.txt index 5399285e..cacea447 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -1,13 +1,5 @@ -djangocms-helper tox coverage pyflakes>=2.1.1 flake8 isort -factory-boy -freezegun --e git+git://github.com/aldryn/aldryn-forms.git@master#egg=aldryn-forms -mock --e git+git://github.com/divio/djangocms-versioning.git@master#egg=djangocms-versioning --e git+git://github.com/FidelityInternational/djangocms-version-locking.git@master#egg=djangocms-version-locking -pyflakes>=2.1.1 diff --git a/tests/settings.py b/tests/settings.py index 3eab6352..311abff4 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -4,16 +4,14 @@ "tests.utils.app_2", "djangocms_versioning", "djangocms_version_locking", + + # the following 4 apps are related + "aldryn_forms", "filer", "easy_thumbnails", - "absolute", - "aldryn_forms", "captcha", - "emailit", + "djangocms_text_ckeditor", - "djangocms_versioning.test_utils.blogpost", - "djangocms_versioning.test_utils.people", - "djangocms_versioning.test_utils.text", "tests.utils.moderated_polls", "tests.utils.versioned_none_moderated_app", ], diff --git a/tests/utils/factories.py b/tests/utils/factories.py index 74a44fe4..e5445351 100644 --- a/tests/utils/factories.py +++ b/tests/utils/factories.py @@ -4,20 +4,20 @@ from cms.models import Placeholder -import factory from djangocms_versioning.models import Version from djangocms_versioning.test_utils.factories import ( AbstractVersionFactory, PageVersionFactory, ) -from factory.fuzzy import FuzzyChoice, FuzzyInteger, FuzzyText +import factory from djangocms_moderation.models import ( ModerationCollection, ModerationRequest, ModerationRequestTreeNode, Workflow, ) +from factory.fuzzy import FuzzyChoice, FuzzyInteger, FuzzyText from .moderated_polls.models import Poll, PollContent, PollPlugin from .versioned_none_moderated_app.models import ( From 06e74814f5050c505b92302a83c2e7bf30208cf7 Mon Sep 17 00:00:00 2001 From: Jonathan Sundqvist Date: Sat, 12 Oct 2019 01:01:42 +0200 Subject: [PATCH 117/147] Select moderation requests from changelist for publishing (#160) * Select moderation requests from changelist for publishing * Some minor clean-up of code * Re-use tests from monikasulik in PR152 --- djangocms_moderation/admin.py | 79 ++- tests/test_admin_actions.py | 1102 ++++++++++++++++++++++++++------- tests/test_signals.py | 24 +- 3 files changed, 935 insertions(+), 270 deletions(-) diff --git a/djangocms_moderation/admin.py b/djangocms_moderation/admin.py index 28180198..d962cb95 100644 --- a/djangocms_moderation/admin.py +++ b/djangocms_moderation/admin.py @@ -490,28 +490,47 @@ def get_urls(self): ), ] + super().get_urls() + def _get_selected_tree_nodes(self, request): + treenodes = ModerationRequestTreeNode.objects.filter( + pk__in=request.GET.get('ids', '').split(',') + ).select_related('moderation_request') + return treenodes + + def _custom_view_context(self, request): + treenodes = self._get_selected_tree_nodes(request) + collection_id = request.GET.get('collection_id') + redirect_url = self._redirect_to_changeview_url(collection_id) + return dict( + ids=request.GET.getlist("ids"), + back_url=redirect_url, + queryset=[n.moderation_request for n in treenodes] + ) + def resubmit_view(self, request): collection_id = request.GET.get('collection_id') - queryset = ModerationRequest.objects.filter(pk__in=request.GET.get('ids', '').split(',')) + treenodes = self._get_selected_tree_nodes(request) redirect_url = self._redirect_to_changeview_url(collection_id) + try: + collection = ModerationCollection.objects.get(id=int(collection_id)) + except (ValueError, ModerationCollection.DoesNotExist): + raise Http404 + + if collection.author != request.user: + raise PermissionDenied + if request.method != 'POST': - context = dict( - ids=request.GET.getlist("ids"), back_url=redirect_url, queryset=queryset - ) + context = self._custom_view_context(request) return render( request, 'admin/djangocms_moderation/moderationrequest/resubmit_confirmation.html', context ) else: - try: - collection = ModerationCollection.objects.get(id=int(collection_id)) - except (ValueError, ModerationCollection.DoesNotExist): - raise Http404 resubmitted_requests = [] - for mr in queryset.all(): + for node in treenodes.all(): + mr = node.moderation_request if mr.user_can_resubmit(request.user): resubmitted_requests.append(mr) mr.update_status( @@ -548,28 +567,28 @@ def resubmit_view(self, request): def published_view(self, request): collection_id = request.GET.get('collection_id') - queryset = ModerationRequest.objects.filter(pk__in=request.GET.get('ids', '').split(',')) + treenodes = self._get_selected_tree_nodes(request) redirect_url = self._redirect_to_changeview_url(collection_id) + try: + collection = ModerationCollection.objects.get(id=int(collection_id)) + except (ValueError, ModerationCollection.DoesNotExist): + raise Http404 + + if request.user != collection.author: + raise PermissionDenied + if request.method != 'POST': - context = dict( - ids=request.GET.getlist("ids"), - back_url=redirect_url, - queryset=queryset, - ) + context = self._custom_view_context(request) return render( request, "admin/djangocms_moderation/moderationrequest/publish_confirmation.html", context, ) else: - try: - collection = ModerationCollection.objects.get(id=int(collection_id)) - except (ValueError, ModerationCollection.DoesNotExist): - raise Http404 - num_published_requests = 0 - for mr in queryset.all(): + for node in treenodes.all(): + mr = node.moderation_request if mr.version_can_be_published(): if publish_version(mr.version, request.user): num_published_requests += 1 @@ -596,13 +615,11 @@ def published_view(self, request): def rework_view(self, request): collection_id = request.GET.get('collection_id') - queryset = ModerationRequest.objects.filter(pk__in=request.GET.get('ids', '').split(',')) + treenodes = self._get_selected_tree_nodes(request) redirect_url = self._redirect_to_changeview_url(collection_id) if request.method != 'POST': - context = dict( - ids=request.GET.getlist("ids"), back_url=redirect_url, queryset=queryset - ) + context = self._custom_view_context(request) return render( request, "admin/djangocms_moderation/moderationrequest/rework_confirmation.html", @@ -616,7 +633,8 @@ def rework_view(self, request): rejected_requests = [] - for moderation_request in queryset.all(): + for node in treenodes.all(): + moderation_request = node.moderation_request if moderation_request.user_can_take_moderation_action(request.user): rejected_requests.append(moderation_request) moderation_request.update_status( @@ -646,13 +664,11 @@ def rework_view(self, request): def approved_view(self, request): collection_id = request.GET.get('collection_id') - queryset = ModerationRequest.objects.filter(pk__in=request.GET.get('ids', '').split(',')) + treenodes = self._get_selected_tree_nodes(request) redirect_url = self._redirect_to_changeview_url(collection_id) if request.method != 'POST': - context = dict( - ids=request.GET.getlist("ids"), back_url=redirect_url, queryset=queryset - ) + context = self._custom_view_context(request) return render( request, "admin/djangocms_moderation/moderationrequest/approve_confirmation.html", @@ -682,7 +698,8 @@ def approved_view(self, request): # Variable we are using to group the requests by action.step_approved request_action_mapping = dict() - for mr in queryset.all(): + for node in treenodes.all(): + mr = node.moderation_request if mr.user_can_take_moderation_action(request.user): approved_requests.append(mr) mr.update_status( diff --git a/tests/test_admin_actions.py b/tests/test_admin_actions.py index 64511cac..7855cd62 100644 --- a/tests/test_admin_actions.py +++ b/tests/test_admin_actions.py @@ -1,6 +1,8 @@ import mock +import unittest from django.contrib.admin import ACTION_CHECKBOX_NAME +from django.contrib.auth.models import Group from django.test import TransactionTestCase from django.urls import reverse @@ -8,282 +10,913 @@ from djangocms_versioning.constants import DRAFT, PUBLISHED from djangocms_versioning.models import Version -from djangocms_versioning.test_utils.factories import PageVersionFactory from djangocms_moderation import constants from djangocms_moderation.admin import ModerationRequestTreeAdmin from djangocms_moderation.constants import ACTION_REJECTED from djangocms_moderation.models import ( - ModerationCollection, ModerationRequest, ModerationRequestTreeNode, Role, - Workflow, ) from .utils import factories -from .utils.base import BaseTestCase -class AdminActionTest(BaseTestCase): +def get_url_data(cls, action): + get_resp = cls.client.get(cls.url) + data = { + "action": action, + ACTION_CHECKBOX_NAME: [str(f.pk) for f in get_resp.context['cl'].queryset] + } + return data + + +class ApproveSelectedTest(CMSTestCase): def setUp(self): - self.wf = Workflow.objects.create(name="Workflow Test") - self.collection = ModerationCollection.objects.create( - author=self.user, - name="Collection Admin Actions", - workflow=self.wf, - status=constants.IN_REVIEW, + # Set up the db data + self.role1 = Role.objects.create(name="Role 1", user=factories.UserFactory(is_staff=True, is_superuser=True)) + self.role2 = Role.objects.create(name="Role 2", user=factories.UserFactory(is_staff=True, is_superuser=True)) + self.collection = factories.ModerationCollectionFactory( + author=self.role1.user, status=constants.IN_REVIEW) + self.collection.workflow.steps.create(role=self.role1, is_required=True, order=1) + self.collection.workflow.steps.create(role=self.role2, is_required=True, order=1) + # NOTE: Setting ids because we want the ids of the requests to be + # different to the ids of the nodes. This will give us confidence + # that the ids of the correct objects are being passed to the + # correct places. + self.moderation_request1 = factories.ModerationRequestFactory( + id=1, collection=self.collection) + self.moderation_request2 = factories.ModerationRequestFactory( + id=2, collection=self.collection) + self.root1 = factories.RootModerationRequestTreeNodeFactory( + id=4, moderation_request=self.moderation_request1) + factories.ChildModerationRequestTreeNodeFactory( + id=5, moderation_request=self.moderation_request2, parent=self.root1) + self.root2 = factories.RootModerationRequestTreeNodeFactory( + id=6, moderation_request=self.moderation_request2) + # Request 1 is approved, request 2 is started + self.moderation_request1.actions.create(by_user=self.role1.user, action=constants.ACTION_STARTED) + self.moderation_request2.actions.create(by_user=self.role1.user, action=constants.ACTION_STARTED) + self.moderation_request1.update_status(constants.ACTION_APPROVED, self.role1.user) + self.moderation_request1.update_status(constants.ACTION_APPROVED, self.role2.user) + + # Set up the url data + self.url = reverse("admin:djangocms_moderation_moderationrequesttreenode_changelist") + self.url += "?moderation_request__collection__id={}".format(self.collection.pk) + + # Asserts to check data set up is ok. Ideally wouldn't need them, but + # the set up is so complex that it's safer to have them. + self.assertFalse(self.moderation_request2.is_approved()) + self.assertTrue(self.moderation_request1.is_approved()) + + @mock.patch("django.contrib.messages.success") + @mock.patch("djangocms_moderation.admin.notify_collection_moderators") + @mock.patch("djangocms_moderation.admin.notify_collection_author") + def test_approve_selected(self, notify_author_mock, notify_moderators_mock, messages_mock): + # Login as the collection author/role 1 + self.client.force_login(self.role1.user) + + # Select the approve action from the menu + data = get_url_data(self, "approve_selected") + response = self.client.post(self.url, data) + # And now go to the view the action redirects to. This will + # perform step1 of the approval process (as defined in the + # workflow in the setUp method) + response = self.client.post(response.url) + + # Check response + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, self.url) + self.assertEqual(messages_mock.call_args[0][1], '1 request successfully approved') + messages_mock.reset_mock() + + # Check correct users notified + notify_author_mock.assert_called_once_with( + collection=self.collection, + moderation_requests=[self.moderation_request2], + action=self.moderation_request2.get_last_action().action, + by_user=self.role1.user, + ) + notify_moderators_mock.assert_called_once_with( + collection=self.collection, + moderation_requests=[self.moderation_request2], + action_obj=self.moderation_request2.get_last_action(), ) + # And reset mocks because we'll be needing to check this again + # for step2 + notify_author_mock.reset_mock() + notify_moderators_mock.reset_mock() - pg1_version = PageVersionFactory() - pg2_version = PageVersionFactory() + # The status of the moderation requests hasn't changed yet + # because there are 2 steps to approval and both are needed + # for status to change + self.assertFalse(self.moderation_request2.is_approved()) + self.assertTrue(self.moderation_request1.is_approved()) - self.mr1 = ModerationRequest.objects.create( - version=pg1_version, language="en", collection=self.collection, - is_active=True, author=self.collection.author) + # Collection status hasn't changed either + self.collection.refresh_from_db() + self.assertEqual(self.collection.status, constants.IN_REVIEW) - self.wfst1 = self.wf.steps.create(role=self.role1, is_required=True, order=1) - self.wfst2 = self.wf.steps.create(role=self.role2, is_required=True, order=1) + # Now login as the user responsible for approving step2 + self.client.force_login(self.role2.user) - # this moderation request is approved - self.mr1.actions.create(by_user=self.user, action=constants.ACTION_STARTED) - self.mr1.update_status(constants.ACTION_APPROVED, self.user) - self.mr1.update_status(constants.ACTION_APPROVED, self.user2) + # Select the approve action from the menu again + data = get_url_data(self, "approve_selected") + response = self.client.post(self.url, data) + # And now go to the view the action redirects to. This will + # perform step2 of the approval process (as defined in the + # workflow in the setUp method) + response = self.client.post(response.url) - # this moderation request is not approved - self.mr2 = ModerationRequest.objects.create( - version=pg2_version, language="en", collection=self.collection, - is_active=True, author=self.collection.author) - self.mr2.actions.create(by_user=self.user, action=constants.ACTION_STARTED) + # Check response + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, self.url) + self.assertEqual(messages_mock.call_args[0][1], '1 request successfully approved') - self.url = reverse("admin:djangocms_moderation_moderationrequesttreenode_changelist") - self.url_with_filter = "{}?moderation_request__collection__id={}".format( - self.url, self.collection.pk + # The status of the previously unapproved request has changed. + # The other request stays as it is + self.assertTrue(self.moderation_request2.is_approved()) + self.assertTrue(self.moderation_request1.is_approved()) + + # The collection has been archived as both requests have been + # approved now + self.collection.refresh_from_db() + self.assertEqual(self.collection.status, constants.ARCHIVED) + + # Correct users have been notified + notify_author_mock.assert_called_once_with( + collection=self.collection, + moderation_requests=[self.moderation_request2], + action=self.moderation_request2.get_last_action().action, + by_user=self.role2.user, ) + self.assertFalse(notify_moderators_mock.called) - self.client.force_login(self.user) + @mock.patch("django.contrib.messages.success") + @mock.patch("djangocms_moderation.admin.notify_collection_moderators") + def test_approve_selected_sends_correct_emails_to_moderators(self, notify_moderators_mock, messages_mock): + # Set up additional roles and user + user3 = factories.UserFactory(is_staff=True, is_superuser=True) + group = Group.objects.create(name="Group 1") + user3.groups.add(group) + role3 = Role.objects.create(name="Role 3", group=group) + role4 = Role.objects.create(user=self.role1.user) + # Set up two more steps + self.collection.workflow.steps.create(role=role3, is_required=True, order=1) + self.collection.workflow.steps.create(role=role4, is_required=True, order=1) + self.role1.user.groups.add(group) + # Set up one more, partially approved request + moderation_request3 = factories.ModerationRequestFactory(id=3, collection=self.collection) + moderation_request3.actions.create(by_user=self.role1.user, action=constants.ACTION_STARTED) + moderation_request3.update_status(by_user=self.role1.user, action=constants.ACTION_APPROVED) + moderation_request3.update_status(by_user=self.role2.user, action=constants.ACTION_APPROVED) + factories.RootModerationRequestTreeNodeFactory( + id=7, moderation_request=moderation_request3 + ) - def test_publish_selected(self): - fixtures = [self.mr1, self.mr2] + # Login as the collection author/role1 user + self.client.force_login(self.role1.user) + data = get_url_data(self, "approve_selected") + response = self.client.post(self.url, data) + response = self.client.post(response.url) - # Pre-checks - version1 = self.mr1.version - version2 = self.mr2.version - self.assertTrue(self.mr1.is_active) - self.assertTrue(self.mr2.is_active) - self.assertEqual(version1.state, DRAFT) - self.assertEqual(version2.state, DRAFT) + # Check response + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, self.url) + self.assertEqual(messages_mock.call_args[0][1], '3 requests successfully approved') + messages_mock.reset_mock() - data = { - "action": "publish_selected", - ACTION_CHECKBOX_NAME: [str(f.pk) for f in fixtures] - } - response = self.client.post(self.url_with_filter, data) - self.client.post(response.url) - # After-checks - # We can't do refresh_from_db() for Version, as it complains about - # `state` field being changed directly - version1 = Version.objects.get(pk=version1.pk) - version2 = Version.objects.get(pk=version2.pk) - self.mr1.refresh_from_db() - self.mr2.refresh_from_db() + # First post as role1 user should notify mr1 and mr2 and mr3 moderators + # The notify email will be sent accordingly. As mr1 and mr3 are in + # different stages of approval compared to mr 2, + # we need to send 2 emails to appropriate moderators + self.assertEqual(notify_moderators_mock.call_count, 2) + self.assertEqual( + notify_moderators_mock.call_args_list[1], + mock.call(collection=self.collection, + moderation_requests=[self.moderation_request1, moderation_request3], + action_obj=self.moderation_request1.get_last_action()) + ) + self.assertEqual( + notify_moderators_mock.call_args_list[0], + mock.call(collection=self.collection, + moderation_requests=[self.moderation_request2], + action_obj=self.moderation_request2.get_last_action()) + ) + notify_moderators_mock.reset_mock() - self.assertEqual(version1.state, PUBLISHED) - self.assertEqual(version2.state, DRAFT) - self.assertFalse(self.mr1.is_active) - self.assertTrue(self.mr2.is_active) + # No moderation requests are approved yet + self.assertFalse(self.moderation_request1.is_approved()) + self.assertFalse(self.moderation_request2.is_approved()) + self.assertFalse(moderation_request3.is_approved()) - @mock.patch("djangocms_moderation.admin.notify_collection_moderators") - @mock.patch("djangocms_moderation.admin.notify_collection_author") - def test_approve_selected(self, notify_author_mock, notify_moderators_mock): - fixtures = [self.mr1, self.mr2] - data = { - "action": "approve_selected", - ACTION_CHECKBOX_NAME: [str(f.pk) for f in fixtures] - } - self.assertFalse(self.mr2.is_approved()) - self.assertTrue(self.mr1.is_approved()) - - response = self.client.post(self.url_with_filter, data) - self.client.post(response.url) + response = self.client.post(self.url, data) + response = self.client.post(response.url) - notify_author_mock.assert_called_once_with( + # Check response + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, self.url) + self.assertEqual(messages_mock.call_args[0][1], '2 requests successfully approved') + messages_mock.reset_mock() + + # Second post approves m3 and mr1, but as this is the last stage of + # the approval, there is no need for notification emails anymore + self.assertEqual(notify_moderators_mock.call_count, 0) + # moderation request 1 and 3 are now approved. Moderation request + # 2 is not + self.assertTrue(self.moderation_request1.is_approved()) + self.assertFalse(self.moderation_request2.is_approved()) + self.assertTrue(moderation_request3.is_approved()) + + # moderation request 2 is not yet approved so collection should + # still be in review + self.collection.refresh_from_db() + self.assertEqual(self.collection.status, constants.IN_REVIEW) + + self.client.force_login(self.role2.user) + # user2 can approve only 1 request, mr2, so one notification email + # should go out + response = self.client.post(self.url, data) + response = self.client.post(response.url) + + # Check response + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, self.url) + self.assertEqual(messages_mock.call_args[0][1], '1 request successfully approved') + messages_mock.reset_mock() + + notify_moderators_mock.assert_called_once_with( collection=self.collection, - moderation_requests=[self.mr2], - action=self.mr2.get_last_action().action, - by_user=self.user, + moderation_requests=[self.moderation_request2], + action_obj=self.moderation_request2.get_last_action(), ) + notify_moderators_mock.reset_mock() + + # moderation request 2 is still not yet approved + self.assertTrue(self.moderation_request1.is_approved()) + self.assertFalse(self.moderation_request2.is_approved()) + self.assertTrue(moderation_request3.is_approved()) + + # moderation request 2 is not yet approved so collection should + # still be in review + self.collection.refresh_from_db() + self.assertEqual(self.collection.status, constants.IN_REVIEW) + + self.client.force_login(user3) + response = self.client.post(self.url, data) + response = self.client.post(response.url) + + # Check response + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, self.url) + self.assertEqual(messages_mock.call_args[0][1], '1 request successfully approved') + messages_mock.reset_mock() notify_moderators_mock.assert_called_once_with( collection=self.collection, - moderation_requests=[self.mr2], - action_obj=self.mr2.get_last_action(), + moderation_requests=[self.moderation_request2], + action_obj=self.moderation_request2.get_last_action(), ) - notify_author_mock.reset_mock() notify_moderators_mock.reset_mock() - # There are 2 steps so we need to approve both to get mr2 approved - self.assertFalse(self.mr2.is_approved()) - self.assertTrue(self.mr1.is_approved()) + self.assertTrue(self.moderation_request1.is_approved()) + self.assertFalse(self.moderation_request2.is_approved()) + self.assertTrue(moderation_request3.is_approved()) + + # moderation request 2 is not yet approved so collection should + # still be in review self.collection.refresh_from_db() self.assertEqual(self.collection.status, constants.IN_REVIEW) - self.client.force_login(self.user2) - response = self.client.post(self.url_with_filter, data) - self.client.post(response.url) + self.client.force_login(self.role1.user) + response = self.client.post(self.url, data) + response = self.client.post(response.url) + + # Check response + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, self.url) + self.assertEqual(messages_mock.call_args[0][1], '1 request successfully approved') + messages_mock.reset_mock() + + self.assertEqual(notify_moderators_mock.call_count, 0) + + self.assertTrue(self.moderation_request1.is_approved()) + self.assertTrue(self.moderation_request2.is_approved()) + self.assertTrue(moderation_request3.is_approved()) - self.assertTrue(self.mr2.is_approved()) - self.assertTrue(self.mr1.is_approved()) + # moderation request 2 is now approved so collection should + # have been archived self.collection.refresh_from_db() self.assertEqual(self.collection.status, constants.ARCHIVED) - notify_author_mock.assert_called_once_with( - collection=self.collection, - moderation_requests=[self.mr2], - action=self.mr2.get_last_action().action, - by_user=self.user2, + def test_404_on_nonexisting_collection(self): + self.client.force_login(self.role1.user) + # Need to access the view directly to test for this + url = reverse("admin:djangocms_moderation_moderationrequest_approve") + url += "?ids=1,2&collection_id=12342" + + response = self.client.post(url) + + self.assertEqual(response.status_code, 404) + + def test_404_on_invalid_collection_id(self): + self.client.force_login(self.role1.user) + # Need to access the view directly to test for this + url = reverse("admin:djangocms_moderation_moderationrequest_approve") + url += "?ids=1,2&collection_id=aaa" + + response = self.client.post(url) + + self.assertEqual(response.status_code, 404) + + @mock.patch("djangocms_moderation.admin.notify_collection_moderators") + @mock.patch("django.contrib.messages.success") + @mock.patch( + "djangocms_moderation.models.ModerationRequest.user_can_take_moderation_action", + mock.Mock(return_value=False) + ) + def test_view_doesnt_approve_when_user_cant_approve(self, messages_mock, notify_moderators_mock): + self.client.force_login(self.role1.user) + # Set up the url (need to access the view directly) + url = reverse("admin:djangocms_moderation_moderationrequest_approve") + url += "?ids=%d,%d&collection_id=%d" % ( + self.moderation_request1.pk, self.moderation_request2.pk, self.collection.pk) + + response = self.client.post(url) + + # Check response + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, self.url) + self.assertEqual(messages_mock.call_args[0][1], '0 requests successfully approved') + # No actions were approved + self.assertFalse(self.moderation_request2.actions.filter( + action=constants.ACTION_RESUBMITTED).exists()) + self.assertEqual(notify_moderators_mock.call_count, 0) + + def test_approve_view_when_using_get(self): + self.client.force_login(self.role1.user) + # Choose the approve_selected action from the dropdown + data = get_url_data(self, "approve_selected") + response = self.client.post(self.url, data) + # And follow the redirect (with a GET call) to the view that does the approve + response = self.client.get(response.url) + + # Smoke test the response. When using a GET call not a lot happens. + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.templates[0].name, + 'admin/djangocms_moderation/moderationrequest/approve_confirmation.html' ) - self.assertFalse(notify_moderators_mock.called) + +class RejectSelectedTest(CMSTestCase): + + def setUp(self): + # Set up the db data + self.user = factories.UserFactory(is_staff=True, is_superuser=True) + self.collection = factories.ModerationCollectionFactory( + author=self.user, status=constants.IN_REVIEW) + self.role1 = Role.objects.create(name="Role 1", user=self.user) + self.role2 = Role.objects.create(name="Role 2", user=factories.UserFactory(is_staff=True, is_superuser=True)) + self.collection.workflow.steps.create(role=self.role1, is_required=True, order=1) + self.collection.workflow.steps.create(role=self.role2, is_required=True, order=1) + # NOTE: Setting ids because we want the ids of the requests to be + # different to the ids of the nodes. This will give us confidence + # that the ids of the correct objects are being passed to the + # correct places. + self.moderation_request1 = factories.ModerationRequestFactory( + id=1, collection=self.collection) + self.moderation_request2 = factories.ModerationRequestFactory( + id=2, collection=self.collection) + self.root1 = factories.RootModerationRequestTreeNodeFactory( + id=4, moderation_request=self.moderation_request1) + factories.ChildModerationRequestTreeNodeFactory( + id=5, moderation_request=self.moderation_request2, parent=self.root1) + self.root2 = factories.RootModerationRequestTreeNodeFactory( + id=6, moderation_request=self.moderation_request2) + # Request 1 is approved, request 2 is started + self.moderation_request1.actions.create(by_user=self.user, action=constants.ACTION_STARTED) + self.moderation_request2.actions.create(by_user=self.user, action=constants.ACTION_STARTED) + self.moderation_request1.update_status(constants.ACTION_APPROVED, self.role1.user) + self.moderation_request1.update_status(constants.ACTION_APPROVED, self.role2.user) + + # Login as the collection author + self.client.force_login(self.user) + + # Set up the url data + self.url = reverse("admin:djangocms_moderation_moderationrequesttreenode_changelist") + self.url += "?moderation_request__collection__id={}".format(self.collection.pk) + + # Asserts to check data set up is ok. Ideally wouldn't need them, but + # the set up is so complex that it's safer to have them. + self.assertFalse(self.moderation_request2.is_approved()) + self.assertFalse(self.moderation_request2.is_rejected()) + self.assertTrue(self.moderation_request2.is_active) + self.assertFalse(self.moderation_request2.actions.get().is_archived) + self.assertTrue(self.moderation_request1.is_approved()) + + @mock.patch("django.contrib.messages.success") @mock.patch("djangocms_moderation.admin.notify_collection_moderators") @mock.patch("djangocms_moderation.admin.notify_collection_author") - def test_reject_selected(self, notify_author_mock, notify_moderators_mock): - fixtures = [self.mr1, self.mr2] - data = { - "action": "reject_selected", - ACTION_CHECKBOX_NAME: [str(f.pk) for f in fixtures] - } - self.assertFalse(self.mr2.is_approved()) - self.assertFalse(self.mr2.is_rejected()) - self.assertTrue(self.mr1.is_approved()) - - response = self.client.post(self.url_with_filter, data) - self.client.post(response.url) - - self.assertFalse(self.mr2.is_approved()) - self.assertTrue(self.mr2.is_rejected()) - self.assertTrue(self.mr1.is_approved()) + def test_reject_selected_rejects_request(self, notify_author_mock, notify_moderators_mock, messages_mock): + # Select the reject action from the menu + data = get_url_data(self, "reject_selected") + response = self.client.post(self.url, data) + # And now go to the view the action redirects to + response = self.client.post(response.url) - self.assertFalse(notify_moderators_mock.called) + # Response is correct + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, self.url) + self.assertEqual( + messages_mock.call_args[0][1], + '1 request successfully submitted for rework' + ) + # The rejected request has indeed been marked rejected. The + # previous action on the rejected request has been archived. The + # previously approved request has not changed its status + self.assertFalse(self.moderation_request2.is_approved()) + self.assertTrue(self.moderation_request2.is_rejected()) + self.assertTrue(self.moderation_request2.is_active) + moderation_request2_actions = self.moderation_request2.actions.all() + self.assertEqual(moderation_request2_actions.count(), 2) + self.assertTrue(moderation_request2_actions[0].is_archived) + self.assertFalse(moderation_request2_actions[1].is_archived) + self.assertTrue(self.moderation_request1.is_approved()) + + # Expected users were notified + self.assertFalse(notify_moderators_mock.called) notify_author_mock.assert_called_once_with( collection=self.collection, - moderation_requests=[self.mr2], - action=self.mr2.get_last_action().action, + moderation_requests=[self.moderation_request2], + action=self.moderation_request2.get_last_action().action, by_user=self.user, ) + # Collection still in review as version2 is still draft self.collection.refresh_from_db() self.assertEqual(self.collection.status, constants.IN_REVIEW) + def test_reject_selected_view_when_using_get(self): + # Choose the reject_selected action from the dropdown + data = get_url_data(self, "reject_selected") + response = self.client.post(self.url, data) + # And follow the redirect (with a GET call) to the view that does the publish + response = self.client.get(response.url) + + # Smoke test the response. When using a GET call not a lot happens. + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.templates[0].name, + 'admin/djangocms_moderation/moderationrequest/rework_confirmation.html' + ) + + def test_404_on_nonexisting_collection(self): + # Need to access the view directly to test for this + url = reverse("admin:djangocms_moderation_moderationrequest_rework") + url += "?ids=1,2&collection_id=12342" + + response = self.client.post(url) + + self.assertEqual(response.status_code, 404) + + def test_404_on_invalid_collection_id(self): + # Need to access the view directly to test for this + url = reverse("admin:djangocms_moderation_moderationrequest_rework") + url += "?ids=1,2&collection_id=aaa" + + response = self.client.post(url) + + self.assertEqual(response.status_code, 404) + + # TODO: This needs to be verified because unlike with other views + # no code throws a 403 on non-author but the list of actions does + # appear to filter the action dropdown for this user. Hard to say + # what is the correct behaviour or if this test makes correct + # assumptions in user set up. + def test_reject_selected_action_cannot_be_accessed_if_not_collection_author(self): + # Login as a user who is not the collection author + self.client.force_login(self.role2.user) + + # Choose the reject_selected action from the dropdown + data = get_url_data(self, "reject_selected") + response = self.client.post(self.url, data) + + # The action is not on the page as available to somebody who is not + # the author, therefore django will just return 200 as you're + # trying to choose an action that isn't in the dropdown + # (if anything had been resubmitted it would have been a 302) + self.assertEqual(response.status_code, 200) + + @mock.patch("django.contrib.messages.success") + @mock.patch( + "djangocms_moderation.models.ModerationRequest.user_can_take_moderation_action", + mock.Mock(return_value=False) + ) @mock.patch("djangocms_moderation.admin.notify_collection_moderators") @mock.patch("djangocms_moderation.admin.notify_collection_author") - def test_resubmit_selected(self, notify_author_mock, notify_moderators_mock): - self.mr2.update_status( - action=ACTION_REJECTED, - by_user=self.user - ) + def test_view_doesnt_reject_when_user_cant_take_moderation_action( + self, notify_author_mock, notify_moderators_mock, messages_mock + ): + # Set up the url (need to access the view directly) + url = reverse("admin:djangocms_moderation_moderationrequest_rework") + url += "?ids=%d,%d&collection_id=%d" % ( + self.moderation_request1.pk, self.moderation_request2.pk, self.collection.pk) - fixtures = [self.mr1, self.mr2] - data = { - "action": "resubmit_selected", - ACTION_CHECKBOX_NAME: [str(f.pk) for f in fixtures] - } - self.assertTrue(self.mr2.is_rejected()) - self.assertTrue(self.mr1.is_approved()) + response = self.client.post(url) + + # Check response + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, self.url) + self.assertEqual( + messages_mock.call_args[0][1], + '0 requests successfully submitted for rework' + ) - response = self.client.post(self.url_with_filter, data) - self.client.post(response.url) + # The request has not changed + self.assertFalse(self.moderation_request2.is_approved()) + self.assertFalse(self.moderation_request2.is_rejected()) - self.assertFalse(self.mr2.is_rejected()) - self.assertTrue(self.mr1.is_approved()) + # Collection still in review + self.collection.refresh_from_db() + self.assertEqual(self.collection.status, constants.IN_REVIEW) + # Nobody was notified + self.assertFalse(notify_moderators_mock.called) self.assertFalse(notify_author_mock.called) - notify_moderators_mock.assert_called_once_with( - collection=self.collection, - moderation_requests=[self.mr2], - action_obj=self.mr2.get_last_action(), + + +class PublishSelectedTest(CMSTestCase): + + def setUp(self): + # Set up the db data + self.user = factories.UserFactory(is_staff=True, is_superuser=True) + self.collection = factories.ModerationCollectionFactory( + author=self.user, status=constants.IN_REVIEW) + self.role1 = Role.objects.create(name="Role 1", user=self.user) + self.role2 = Role.objects.create(name="Role 2", user=factories.UserFactory(is_staff=True, is_superuser=True)) + self.collection.workflow.steps.create(role=self.role1, is_required=True, order=1) + self.collection.workflow.steps.create(role=self.role2, is_required=True, order=1) + # NOTE: Setting ids because we want the ids of the requests to be + # different to the ids of the nodes. This will give us confidence + # that the ids of the correct objects are being passed to the + # correct places. + self.moderation_request1 = factories.ModerationRequestFactory( + id=1, collection=self.collection) + self.moderation_request2 = factories.ModerationRequestFactory( + id=2, collection=self.collection) + self.root1 = factories.RootModerationRequestTreeNodeFactory( + id=4, moderation_request=self.moderation_request1) + factories.ChildModerationRequestTreeNodeFactory( + id=5, moderation_request=self.moderation_request2, parent=self.root1) + self.root2 = factories.RootModerationRequestTreeNodeFactory( + id=6, moderation_request=self.moderation_request2) + # Request 1 is approved, request 2 is started + self.moderation_request1.actions.create(by_user=self.user, action=constants.ACTION_STARTED) + self.moderation_request2.actions.create(by_user=self.user, action=constants.ACTION_STARTED) + self.moderation_request1.update_status(constants.ACTION_APPROVED, self.role1.user) + self.moderation_request1.update_status(constants.ACTION_APPROVED, self.role2.user) + + # Login as the collection author + self.client.force_login(self.user) + + # Set up the url data + self.url = reverse("admin:djangocms_moderation_moderationrequesttreenode_changelist") + self.url += "?moderation_request__collection__id={}".format(self.collection.pk) + + # Asserts to check data set up is ok. Ideally wouldn't need them, but + # the set up is so complex that it's safer to have them. + self.assertTrue(self.moderation_request1.is_active) + self.assertTrue(self.moderation_request2.is_active) + self.assertEqual(self.moderation_request1.version.state, DRAFT) + self.assertEqual(self.moderation_request2.version.state, DRAFT) + + @mock.patch("django.contrib.messages.success") + def test_publish_selected_publishes_approved_request(self, messages_mock): + # Select the publish action from the menu + + data = get_url_data(self, "publish_selected") + response = self.client.post(self.url, data) + # And now go to the view the action redirects to + response = self.client.post(response.url) + + # Response is correct + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, self.url) + self.assertEqual(messages_mock.call_args[0][1], '1 request successfully published') + + # Check approved request was published and started request was not + # NOTE: We can't do refresh_from_db() for Version, as it complains about + # `state` field being changed directly + version1 = Version.objects.get(pk=self.moderation_request1.version.pk) + version2 = Version.objects.get(pk=self.moderation_request2.version.pk) + self.moderation_request1.refresh_from_db() + self.moderation_request2.refresh_from_db() + self.assertEqual(version1.state, PUBLISHED) + self.assertEqual(version2.state, DRAFT) + self.assertFalse(self.moderation_request1.is_active) + self.assertTrue(self.moderation_request2.is_active) + + # Collection still in review as version2 is still draft + self.collection.refresh_from_db() + self.assertEqual(self.collection.status, constants.IN_REVIEW) + + @unittest.skip("Skip until collection status bugs fixed") + @mock.patch("django.contrib.messages.success") + def test_publish_selected_sets_collection_to_archived_if_all_requests_published(self, messages_mock): + # Make sure both moderation requests have been approved + self.moderation_request2.update_status(constants.ACTION_APPROVED, self.role1.user) + self.moderation_request2.update_status(constants.ACTION_APPROVED, self.role2.user) + + # Select the publish action from the menu + data = get_url_data(self, "publish_selected") + response = self.client.post(self.url, data) + # And now go to the view the action redirects to + response = self.client.post(response.url) + + # Response is correct + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, self.url) + self.assertEqual(messages_mock.call_args[0][1], '2 requests successfully published') + + # Check both approved request were published + # NOTE: We can't do refresh_from_db() for Version, as it complains about + # `state` field being changed directly + version1 = Version.objects.get(pk=self.moderation_request1.version.pk) + version2 = Version.objects.get(pk=self.moderation_request2.version.pk) + self.moderation_request1.refresh_from_db() + self.moderation_request2.refresh_from_db() + self.assertEqual(version1.state, PUBLISHED) + self.assertEqual(version2.state, PUBLISHED) + self.assertFalse(self.moderation_request1.is_active) + self.assertFalse(self.moderation_request2.is_active) + + # Collection should be archived as both requests are now published + self.collection.refresh_from_db() + self.assertEqual(self.collection.status, constants.ARCHIVED) + + def test_publish_selected_view_when_using_get(self): + # Choose the publish_selected action from the dropdown + data = get_url_data(self, "publish_selected") + response = self.client.post(self.url, data) + # And follow the redirect (with a GET call) to the view that does the publish + response = self.client.get(response.url) + + # Smoke test the response. When using a GET call not a lot happens. + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.templates[0].name, + 'admin/djangocms_moderation/moderationrequest/publish_confirmation.html' ) + def test_404_on_nonexisting_collection(self): + # Need to access the view directly to test for this + url = reverse("admin:djangocms_moderation_moderationrequest_publish") + url += "?ids=1,2&collection_id=12342" + + response = self.client.post(url) + + self.assertEqual(response.status_code, 404) + + def test_404_on_invalid_collection_id(self): + # Need to access the view directly to test for this + url = reverse("admin:djangocms_moderation_moderationrequest_publish") + url += "?ids=1,2&collection_id=aaa" + + response = self.client.post(url) + + self.assertEqual(response.status_code, 404) + + def test_publish_selected_action_cannot_be_accessed_if_not_collection_author(self): + # Login as a user who is not the collection author + self.client.force_login(self.get_superuser()) + + # Choose the resubmit_selected action from the dropdown + data = get_url_data(self, "publish_selected") + response = self.client.post(self.url, data) + + # The action is not on the page as available to somebody who is not + # the author, therefore django will just return 200 as you're + # trying to choose an action that isn't in the dropdown + # (if anything had been resubmitted it would have been a 302) + self.assertEqual(response.status_code, 200) + + def test_publish_selected_view_cannot_be_accessed_if_not_collection_author(self): + # Login as a user who is not the collection author + self.client.force_login(self.get_superuser()) + # Set up the url (need to access the view directly) + url = reverse("admin:djangocms_moderation_moderationrequest_publish") + url += "?ids=%d,%d&collection_id=%d" % ( + self.moderation_request1.pk, self.moderation_request2.pk, self.collection.pk) + + # POST directly to the view, don't go through actions + response = self.client.post(url) + + # Check response + self.assertEqual(response.status_code, 403) + + # Nothing is published + version1 = Version.objects.get(pk=self.moderation_request1.version.pk) + version2 = Version.objects.get(pk=self.moderation_request2.version.pk) + self.moderation_request1.refresh_from_db() + self.moderation_request2.refresh_from_db() + self.assertEqual(version1.state, DRAFT) + self.assertEqual(version2.state, DRAFT) + self.assertTrue(self.moderation_request1.is_active) + self.assertTrue(self.moderation_request2.is_active) + + # Collection still in review self.collection.refresh_from_db() self.assertEqual(self.collection.status, constants.IN_REVIEW) + @mock.patch("django.contrib.messages.success") + @mock.patch("djangocms_moderation.models.ModerationRequest.version_can_be_published", mock.Mock(return_value=False)) + def test_view_doesnt_publish_when_version_cant_be_published(self, messages_mock): + # Set up the url (need to access the view directly) + url = reverse("admin:djangocms_moderation_moderationrequest_publish") + url += "?ids=%d,%d&collection_id=%d" % ( + self.moderation_request1.pk, self.moderation_request2.pk, self.collection.pk) + + response = self.client.post(url) + + # Check response + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, self.url) + self.assertEqual(messages_mock.call_args[0][1], '0 requests successfully published') + + # Nothing is published + version1 = Version.objects.get(pk=self.moderation_request1.version.pk) + version2 = Version.objects.get(pk=self.moderation_request2.version.pk) + self.moderation_request1.refresh_from_db() + self.moderation_request2.refresh_from_db() + self.assertEqual(version1.state, DRAFT) + self.assertEqual(version2.state, DRAFT) + self.assertTrue(self.moderation_request1.is_active) + self.assertTrue(self.moderation_request2.is_active) + + # Collection still in review + self.collection.refresh_from_db() + self.assertEqual(self.collection.status, constants.IN_REVIEW) + + +class ResubmitSelectedTest(CMSTestCase): + + def setUp(self): + # Set up the db data + self.user = factories.UserFactory(is_staff=True, is_superuser=True) + self.collection = factories.ModerationCollectionFactory( + author=self.user, status=constants.IN_REVIEW) + # NOTE: Setting ids because we want the ids of the requests to be + # different to the ids of the nodes. This will give us confidence + # that the ids of the correct objects are being passed to the + # correct places. + self.moderation_request1 = factories.ModerationRequestFactory( + id=1, collection=self.collection) + self.moderation_request2 = factories.ModerationRequestFactory( + id=2, collection=self.collection) + # Make self.moderation_request2 rejected + self.moderation_request2.update_status(action=ACTION_REJECTED, by_user=self.user) + self.root1 = factories.RootModerationRequestTreeNodeFactory( + id=4, moderation_request=self.moderation_request1) + factories.ChildModerationRequestTreeNodeFactory( + id=5, moderation_request=self.moderation_request2, parent=self.root1) + self.root2 = factories.RootModerationRequestTreeNodeFactory( + id=6, moderation_request=self.moderation_request2) + + # Login as the collection author + self.client.force_login(self.user) + + self.url = reverse("admin:djangocms_moderation_moderationrequesttreenode_changelist") + self.url += "?moderation_request__collection__id={}".format(self.collection.pk) + + # Asserts to check data set up is ok. Ideally wouldn't need them, but + # the set up is so complex that it's safer to have them. + self.assertTrue(self.moderation_request2.is_rejected()) + self.assertTrue(self.moderation_request1.is_approved()) + self.assertEqual(self.collection.status, constants.IN_REVIEW) + @mock.patch("djangocms_moderation.admin.notify_collection_moderators") - def test_approve_selected_sends_correct_emails(self, notify_moderators_mock): - role4 = Role.objects.create(user=self.user) - # Add two more steps - self.wf.steps.create(role=self.role3, is_required=True, order=1) - self.wf.steps.create(role=role4, is_required=True, order=1) - self.user.groups.add(self.group) - - # Add one more, partially approved request - pg3_version = PageVersionFactory() - self.mr3 = ModerationRequest.objects.create( - version=pg3_version, language="en", collection=self.collection, - is_active=True, author=self.collection.author) - self.mr3.actions.create(by_user=self.user, action=constants.ACTION_STARTED) - self.mr3.update_status(by_user=self.user, action=constants.ACTION_APPROVED) - self.mr3.update_status(by_user=self.user2, action=constants.ACTION_APPROVED) - - self.user.groups.add(self.group) - - fixtures = [self.mr1, self.mr2, self.mr3] - data = { - "action": "approve_selected", - ACTION_CHECKBOX_NAME: [str(f.pk) for f in fixtures] - } - - # First post as `self.user` should notify mr1 and mr2 and mr3 moderators - response = self.client.post(self.url_with_filter, data) - # The notify email will be send accordingly. As mr1 and mr3 are in the - # different stages of approval compared to mr 2, - # we need to send 2 emails to appropriate moderators - self.client.post(response.url) - self.assertEqual(notify_moderators_mock.call_count, 2) - self.assertEqual( - notify_moderators_mock.call_args_list[0], - mock.call(collection=self.collection, - moderation_requests=[self.mr1, self.mr3], - action_obj=self.mr1.get_last_action() - ) + @mock.patch("djangocms_moderation.admin.notify_collection_author") + @mock.patch("django.contrib.messages.success") + def test_resubmit_selected_resubmits_rejected_request( + self, messages_mock, notify_author_mock, notify_moderators_mock + ): + # Choose the resubmit_selected action from the dropdown + data = get_url_data(self, "resubmit_selected") + response = self.client.post(self.url, data) + # And follow the redirect to the view that does the resubmit + response = self.client.post(response.url) + + # Response is correct + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, self.url) + self.assertEqual(messages_mock.call_args[0][1], '1 request successfully resubmitted for review') + + # Rejected request was resubmitted and approved request did not change + self.assertFalse(self.moderation_request2.is_rejected()) + self.assertTrue(self.moderation_request2.actions.filter( + action=constants.ACTION_RESUBMITTED, by_user=self.user).exists()) + self.assertTrue(self.moderation_request1.is_approved()) + + # Collection status has not changed + self.collection.refresh_from_db() + self.assertEqual(self.collection.status, constants.IN_REVIEW) + + # Expected users were notified + self.assertFalse(notify_author_mock.called) + notify_moderators_mock.assert_called_once_with( + collection=self.collection, + moderation_requests=[self.moderation_request2], + action_obj=self.moderation_request2.get_last_action(), ) + def test_resubmit_selected_view_when_using_get(self): + # Choose the resubmit_selected action from the dropdown + data = get_url_data(self, "resubmit_selected") + response = self.client.post(self.url, data) + # And follow the redirect (with a GET call) to the view that does the resubmit + response = self.client.get(response.url) + + # Smoke test the response. When using a GET call not a lot happens. + self.assertEqual(response.status_code, 200) self.assertEqual( - notify_moderators_mock.call_args_list[1], - mock.call(collection=self.collection, - moderation_requests=[self.mr2], - action_obj=self.mr2.get_last_action(), - ) + response.templates[0].name, + 'admin/djangocms_moderation/moderationrequest/resubmit_confirmation.html' ) - self.assertFalse(self.mr1.is_approved()) - self.assertFalse(self.mr3.is_approved()) - notify_moderators_mock.reset_mock() - response = self.client.post(self.url_with_filter, data) - self.client.post(response.url) - # Second post approves m3 and mr1, but as this is the last stage of - # the approval, there is no need for notification emails anymore + def test_404_on_nonexisting_collection(self): + # Need to access the view directly to test for this + url = reverse("admin:djangocms_moderation_moderationrequest_resubmit") + url += "?ids=1,2&collection_id=12342" + + response = self.client.post(url) + + self.assertEqual(response.status_code, 404) + + def test_404_on_invalid_collection_id(self): + # Need to access the view directly to test for this + url = reverse("admin:djangocms_moderation_moderationrequest_resubmit") + url += "?ids=1,2&collection_id=aaa" + + response = self.client.post(url) + + self.assertEqual(response.status_code, 404) + + @mock.patch("djangocms_moderation.admin.notify_collection_moderators") + @mock.patch("django.contrib.messages.success") + @mock.patch("djangocms_moderation.models.ModerationRequest.user_can_resubmit", mock.Mock(return_value=False)) + def test_view_doesnt_resubmit_when_user_cant_resubmit(self, messages_mock, notify_moderators_mock): + # Set up the url (need to access the view directly) + url = reverse("admin:djangocms_moderation_moderationrequest_resubmit") + url += "?ids=%d,%d&collection_id=%d" % ( + self.moderation_request1.pk, self.moderation_request2.pk, self.collection.pk) + + response = self.client.post(url) + + # Check response + self.assertEqual(response.status_code, 302) + self.assertEqual(response.url, self.url) + self.assertEqual(messages_mock.call_args[0][1], '0 requests successfully resubmitted for review') + # No actions were resubmitted + self.assertFalse(self.moderation_request2.actions.filter( + action=constants.ACTION_RESUBMITTED).exists()) self.assertEqual(notify_moderators_mock.call_count, 0) - self.assertTrue(self.mr1.is_approved()) - self.assertTrue(self.mr3.is_approved()) - self.client.force_login(self.user2) - # user2 can approve only 1 request, mr2, so one notification email - # should go out - response = self.client.post(self.url_with_filter, data) - self.client.post(response.url) - notify_moderators_mock.assert_called_once_with( - collection=self.collection, - moderation_requests=[self.mr2], - action_obj=self.mr2.get_last_action(), - ) + def test_resubmit_selected_action_cannot_be_accessed_if_not_collection_author(self): + # Login as a user who is not the collection author + self.client.force_login(self.get_superuser()) - self.user.groups.remove(self.group) + # Choose the resubmit_selected action from the dropdown + data = get_url_data(self, "resubmit_selected") + response = self.client.post(self.url, data) - # Not all request have been fully approved - self.collection.refresh_from_db() - self.assertEqual(self.collection.status, constants.IN_REVIEW) + # The action is not on the page as available to somebody who is not + # the author, therefore django will just return 200 as you're + # trying to choose an action that isn't in the dropdown + # (if anything had been resubmitted it would have been a 302) + self.assertEqual(response.status_code, 200) + + @mock.patch("djangocms_moderation.admin.notify_collection_moderators") + def test_resubmit_selected_view_cannot_be_accessed_if_not_collection_author(self, notify_moderators_mock): + # Login as a user who is not the collection author + self.client.force_login(self.get_superuser()) + # Set up the url (need to access the view directly) + url = reverse("admin:djangocms_moderation_moderationrequest_resubmit") + url += "?ids=%d,%d&collection_id=%d" % ( + self.moderation_request1.pk, self.moderation_request2.pk, self.collection.pk) + + # POST directly to the view, don't go through actions + response = self.client.post(url) + + self.assertEqual(response.status_code, 403) + # Nothing is resubmitted + self.assertFalse(self.moderation_request2.actions.filter( + action=constants.ACTION_RESUBMITTED).exists()) + # Notifications not sent + self.assertFalse(notify_moderators_mock.called) class DeleteSelectedTest(CMSTestCase): @@ -291,31 +924,32 @@ def setUp(self): self.user = factories.UserFactory(is_staff=True, is_superuser=True) self.collection = factories.ModerationCollectionFactory( author=self.user, status=constants.IN_REVIEW) + # NOTE: Setting ids because we want the ids of the requests to be + # different to the ids of the nodes. This will give us confidence + # that the ids of the correct objects are being passed to the + # correct places. self.moderation_request1 = factories.ModerationRequestFactory( - collection=self.collection) + id=1, collection=self.collection) self.moderation_request2 = factories.ModerationRequestFactory( - collection=self.collection) + id=2, collection=self.collection) self.root1 = factories.RootModerationRequestTreeNodeFactory( - moderation_request=self.moderation_request1) - self.root2 = factories.RootModerationRequestTreeNodeFactory( - moderation_request=self.moderation_request2) + id=4, moderation_request=self.moderation_request1) factories.ChildModerationRequestTreeNodeFactory( - moderation_request=self.moderation_request1, parent=self.root1) + id=5, moderation_request=self.moderation_request2, parent=self.root1) + self.root2 = factories.RootModerationRequestTreeNodeFactory( + id=6, moderation_request=self.moderation_request2) + + self.url = reverse("admin:djangocms_moderation_moderationrequesttreenode_changelist") + self.url += "?moderation_request__collection__id={}".format(self.collection.pk) @mock.patch.object(ModerationRequestTreeAdmin, "has_delete_permission", mock.Mock(return_value=True)) def test_delete_selected_action_cannot_be_accessed_if_not_collection_author(self): # Login as a user who is not the collection author self.client.force_login(self.get_superuser()) - # Set up action url - url = reverse("admin:djangocms_moderation_moderationrequesttreenode_changelist") - url += "?moderation_request__collection__id={}".format(self.collection.pk) # Choose the delete_selected action from the dropdown - data = { - "action": "delete_selected", - ACTION_CHECKBOX_NAME: [str(self.moderation_request1.pk), str(self.moderation_request2.pk)] - } - response = self.client.post(url, data) + data = get_url_data(self, "delete_selected") + response = self.client.post(self.url, data) # The action is not on the page as available to somebody who is not # the author, therefore django will just return 200 as you're @@ -352,14 +986,8 @@ def test_delete_selected_action_cannot_be_accessed_without_delete_permission(sel # Login as the collection author self.client.force_login(self.user) # Choose the delete_selected action from the dropdown - url = reverse("admin:djangocms_moderation_moderationrequesttreenode_changelist") - url += "?moderation_request__collection__id={}".format(self.collection.pk) - data = { - "action": "delete_selected", - ACTION_CHECKBOX_NAME: [str(self.moderation_request1.pk), str(self.moderation_request2.pk)] - } - - response = self.client.post(url, data) + data = get_url_data(self, "delete_selected") + response = self.client.post(self.url, data) self.assertEqual(response.status_code, 403) @@ -387,26 +1015,25 @@ def test_delete_selected_view_cannot_be_accessed_without_delete_permission(self, # Notifications not sent self.assertFalse(notify_author_mock.called) + @mock.patch("django.contrib.messages.success") @mock.patch.object(ModerationRequestTreeAdmin, "has_delete_permission", mock.Mock(return_value=True)) @mock.patch("djangocms_moderation.admin.notify_collection_moderators") @mock.patch("djangocms_moderation.admin.notify_collection_author") - def test_delete_selected_deletes_all_relevant_objects(self, notify_author_mock, notify_moderators_mock): + def test_delete_selected_deletes_all_relevant_objects( + self, notify_author_mock, notify_moderators_mock, messages_mock + ): """The selected ModerationRequest and ModerationRequestTreeNode objects should be deleted.""" # Login as the collection author self.client.force_login(self.user) # Choose the delete_selected action from the dropdown - url = reverse("admin:djangocms_moderation_moderationrequesttreenode_changelist") - url += "?moderation_request__collection__id={}".format(self.collection.pk) - data = { - "action": "delete_selected", - ACTION_CHECKBOX_NAME: [str(self.moderation_request1.pk), str(self.moderation_request2.pk)] - } - response = self.client.post(url, data) + data = get_url_data(self, "delete_selected") + response = self.client.post(self.url, data) self.assertEqual(response.status_code, 302) # Now do the request the delete_selected action has led us to response = self.client.post(response.url) - self.assertRedirects(response, url) + self.assertRedirects(response, self.url) + self.assertEqual(messages_mock.call_args[0][1], '2 requests successfully deleted') # And check the requests have indeed been deleted self.assertEqual(ModerationRequest.objects.filter(collection=self.collection).count(), 0) # And correct notifications sent out @@ -421,6 +1048,24 @@ def test_delete_selected_deletes_all_relevant_objects(self, notify_author_mock, self.collection.refresh_from_db() self.assertEqual(self.collection.status, constants.ARCHIVED) + def test_delete_view_when_using_get(self): + # Login as the collection author + self.client.force_login(self.user) + # Choose the delete_selected action from the dropdown + + # Choose the approve_selected action from the dropdown + data = get_url_data(self, "delete_selected") + response = self.client.post(self.url, data) + # And follow the redirect (with a GET call) to the view that does the approve + response = self.client.get(response.url) + + # Smoke test the response. When using a GET call not a lot happens. + self.assertEqual(response.status_code, 200) + self.assertEqual( + response.templates[0].name, + 'admin/djangocms_moderation/moderationrequest/delete_confirmation.html' + ) + class DeletedSelectedTransactionTest(TransactionTestCase): @@ -446,10 +1091,7 @@ def setUp(self): # Generate url and POST data self.url = reverse("admin:djangocms_moderation_moderationrequesttreenode_changelist") self.url += "?moderation_request__collection__id={}".format(self.collection.pk) - self.data = { - "action": "delete_selected", - ACTION_CHECKBOX_NAME: [str(self.moderation_request1.pk), str(self.moderation_request2.pk)] - } + self.data = get_url_data(self, "delete_selected") def tearDown(self): """Clear content type cache for page content's versionable. diff --git a/tests/test_signals.py b/tests/test_signals.py index 8becd947..c298687d 100644 --- a/tests/test_signals.py +++ b/tests/test_signals.py @@ -42,6 +42,12 @@ def test_resubmitted_for_review_signal(self): moderation_request = factories.ModerationRequestFactory( collection__status=constants.COLLECTING, author=user ) + moderation_request.collection.author = user + moderation_request.collection.save() + + self.root = factories.RootModerationRequestTreeNodeFactory( + moderation_request=moderation_request + ) moderation_request.collection.workflow.steps.create( role=Role.objects.create(name="Role 1", user=reviewer), order=1 ) @@ -50,16 +56,16 @@ def test_resubmitted_for_review_signal(self): ) with signal_tester(submitted_for_review) as env: - with self.login_user_context(user): - self.client.post( - add_url_parameters( - reverse( - "admin:djangocms_moderation_moderationrequest_resubmit" - ), - collection_id=moderation_request.collection_id, - ids=moderation_request.pk, - ) + self.client.force_login(user) + self.client.post( + add_url_parameters( + reverse( + "admin:djangocms_moderation_moderationrequest_resubmit" + ), + collection_id=moderation_request.collection_id, + ids=self.root.pk, ) + ) self.assertEqual(env.call_count, 1) From efc273b49890e8036546fe4faa11f05a0084cf86 Mon Sep 17 00:00:00 2001 From: Jonathan Sundqvist Date: Sat, 12 Oct 2019 10:37:17 +0200 Subject: [PATCH 118/147] Create signal for when a collection is published (#159) --- djangocms_moderation/admin.py | 15 +++++++++++---- djangocms_moderation/signals.py | 9 +++++++++ tests/test_admin_actions.py | 23 +++++++++++++++++++++++ 3 files changed, 43 insertions(+), 4 deletions(-) diff --git a/djangocms_moderation/admin.py b/djangocms_moderation/admin.py index d962cb95..e4acc258 100644 --- a/djangocms_moderation/admin.py +++ b/djangocms_moderation/admin.py @@ -586,12 +586,12 @@ def published_view(self, request): context, ) else: - num_published_requests = 0 + published_moderation_requests = [] for node in treenodes.all(): mr = node.moderation_request if mr.version_can_be_published(): if publish_version(mr.version, request.user): - num_published_requests += 1 + published_moderation_requests.append(mr) mr.update_status( action=constants.ACTION_FINISHED, by_user=request.user ) @@ -604,12 +604,19 @@ def published_view(self, request): ungettext( "%(count)d request successfully published", "%(count)d requests successfully published", - num_published_requests, + len(published_moderation_requests), ) - % {"count": num_published_requests}, + % {"count": len(published_moderation_requests)}, ) post_bulk_actions(collection) + signals.published.send( + sender=self.model, + collection=collection, + moderator=collection.author, + moderation_requests=published_moderation_requests, + workflow=collection.workflow + ) return HttpResponseRedirect(redirect_url) diff --git a/djangocms_moderation/signals.py b/djangocms_moderation/signals.py index e2fd26df..46c28975 100644 --- a/djangocms_moderation/signals.py +++ b/djangocms_moderation/signals.py @@ -8,3 +8,12 @@ submitted_for_review = django.dispatch.Signal( providing_args=["collection", "moderation_requests", "user", "rework"] ) + +published = django.dispatch.Signal( + providing_args=[ + "collection", + "moderator", + "moderation_requests", + "workflow" + ] +) diff --git a/tests/test_admin_actions.py b/tests/test_admin_actions.py index 7855cd62..e45475ce 100644 --- a/tests/test_admin_actions.py +++ b/tests/test_admin_actions.py @@ -7,6 +7,7 @@ from django.urls import reverse from cms.test_utils.testcases import CMSTestCase +from cms.test_utils.util.context_managers import signal_tester from djangocms_versioning.constants import DRAFT, PUBLISHED from djangocms_versioning.models import Version @@ -19,6 +20,7 @@ ModerationRequestTreeNode, Role, ) +from djangocms_moderation.signals import published from .utils import factories @@ -624,6 +626,27 @@ def test_publish_selected_publishes_approved_request(self, messages_mock): self.collection.refresh_from_db() self.assertEqual(self.collection.status, constants.IN_REVIEW) + def test_signal_when_published(self): + """ + A signal should be sent so further action can be taken when a moderation + collection is being published. + """ + data = get_url_data(self, "publish_selected") + response = self.client.post(self.url, data) + + with signal_tester(published) as signal: + # And now go to the view the action redirects to + self.client.post(response.url) + args, kwargs = signal.calls[0] + published_mr = kwargs['moderation_requests'] + self.assertEquals(signal.call_count, 1) + self.assertEquals(kwargs['sender'], ModerationRequest) + self.assertEquals(kwargs['collection'], self.collection) + self.assertEquals(kwargs['moderator'], self.collection.author) + self.assertEquals(len(published_mr), 1) + self.assertEquals(published_mr[0], self.moderation_request1) + self.assertEquals(kwargs['workflow'], self.collection.workflow) + @unittest.skip("Skip until collection status bugs fixed") @mock.patch("django.contrib.messages.success") def test_publish_selected_sets_collection_to_archived_if_all_requests_published(self, messages_mock): From 9cc66bca359086f26e47f208db8880e083e66f4d Mon Sep 17 00:00:00 2001 From: Jonathan Sundqvist Date: Sat, 12 Oct 2019 10:44:18 +0200 Subject: [PATCH 119/147] Reduce the amount of queries from 11000 in admin to 14 (#157) --- djangocms_moderation/admin.py | 13 +++++++- djangocms_moderation/managers.py | 57 ++++++++++++++++++++++++++++++-- djangocms_moderation/models.py | 29 ++++++---------- tests/test_managers.py | 14 ++++++++ 4 files changed, 90 insertions(+), 23 deletions(-) create mode 100644 tests/test_managers.py diff --git a/djangocms_moderation/admin.py b/djangocms_moderation/admin.py index e4acc258..306a2ed8 100644 --- a/djangocms_moderation/admin.py +++ b/djangocms_moderation/admin.py @@ -969,6 +969,12 @@ class Media: actions = None # remove `delete_selected` for now, it will be handled later list_filter = [ModeratorFilter, "status", "date_created", ReviewerFilter] list_display_links = None + list_per_page = 100 + + def get_queryset(self, request): + qs = super().get_queryset(request) + qs = qs.prefetch_reviewers() + return qs def get_list_display(self, request): list_display = [ @@ -977,7 +983,7 @@ def get_list_display(self, request): "author", "workflow", "status", - "reviewers", + "commaseparated_reviewers", "date_created", "list_display_actions", ] @@ -986,6 +992,11 @@ def get_list_display(self, request): def job_id(self, obj): return obj.pk + def commaseparated_reviewers(self, obj): + reviewers = self.model.objects.reviewers(obj) + return ", ".join(map(get_user_model().get_full_name, reviewers)) + commaseparated_reviewers.short_description = _('reviewers') + def list_display_actions(self, obj): """Display links to state change endpoints """ diff --git a/djangocms_moderation/managers.py b/djangocms_moderation/managers.py index a8552bd4..2d8f7e6a 100644 --- a/djangocms_moderation/managers.py +++ b/djangocms_moderation/managers.py @@ -1,5 +1,6 @@ from __future__ import unicode_literals +from django.db import models from django.db.models import Manager, Q from .constants import ( @@ -8,6 +9,7 @@ ACCESS_PAGE, ACCESS_PAGE_AND_CHILDREN, ACCESS_PAGE_AND_DESCENDANTS, + COLLECTING, ) @@ -32,8 +34,57 @@ def for_page(self, page): ) query |= Q(extended_object=page) & ( - Q(grant_on=ACCESS_PAGE_AND_DESCENDANTS) - | Q(grant_on=ACCESS_PAGE_AND_CHILDREN) - | Q(grant_on=ACCESS_PAGE) + Q(grant_on=ACCESS_PAGE_AND_DESCENDANTS) | + Q(grant_on=ACCESS_PAGE_AND_CHILDREN) | + Q(grant_on=ACCESS_PAGE) ) return self.filter(query).order_by("-extended_object__node__depth").first() + + +class CollectionQuerySet(models.QuerySet): + def prefetch_reviewers(self): + """ + Prefetch all necessary relations so it's possible to get reviewers + without incurring extra queries. + """ + return self.prefetch_related( + 'moderation_requests', + 'moderation_requests__actions', + 'moderation_requests__actions__to_user', + 'workflow__steps__role__group__user_set' + ) + + +class CollectionManager(Manager): + + def get_queryset(self): + return CollectionQuerySet(self.model, using=self._db) + + def reviewers(self, collection): + """ + Returns a set of all reviewers assigned to any ModerationRequestAction + associated with this collection. If none are associated with a given + action then get the role for the step in the workflow and include all + reviewers within that list. + + Please note that if collection has not been prefetched using + `prefetch_reviewers` this has a chance of making a massive overhead. + """ + + reviewers = set() + moderation_requests = collection.moderation_requests.all() + for mr in moderation_requests: + moderation_request_actions = mr.actions.all() + reviewers_in_actions = set() + for mra in moderation_request_actions: + if mra.to_user: + reviewers_in_actions.add(mra.to_user) + reviewers.add(mra.to_user) + + if not reviewers_in_actions and collection.status != COLLECTING: + role = collection.workflow.first_step.role + users = role.get_users_queryset() + for user in users: + reviewers.add(user) + + return reviewers diff --git a/djangocms_moderation/models.py b/djangocms_moderation/models.py index 05ee7d12..2b840e33 100644 --- a/djangocms_moderation/models.py +++ b/djangocms_moderation/models.py @@ -18,6 +18,7 @@ from treebeard.mp_tree import MP_Node from .emails import notify_collection_moderators +from .managers import CollectionManager from .utils import generate_compliance_number @@ -224,6 +225,8 @@ class ModerationCollection(models.Model): date_created = models.DateTimeField(auto_now_add=True) date_modified = models.DateTimeField(auto_now=True) + objects = CollectionManager() + class Meta: verbose_name = _("collection") permissions = ( @@ -245,25 +248,13 @@ def author_name(self): @property def reviewers(self): """ - Find all the reviewers assigned to any moderationrequestaction associated with this collection. - If none are associated with a given action, - then get the role for the step in the workflow and include all reviewers within that list. + DEPRECATED - if you need to get a list of reviewers use the following instead + obj = ModerationCollection.objects.all().prefetch_reviewers().first() + reviewers = ModerationCollection.objects.reviewers(obj) + + The above method makes sure that you won't incur a query overhead """ - reviewers = set() - moderation_requests = self.moderation_requests.all() - for mr in moderation_requests: - moderation_request_actions = mr.actions.all() - reviewers_in_actions = set() - for mra in moderation_request_actions: - if mra.to_user: - reviewers_in_actions.add(mra.to_user) - reviewers.add(mra.to_user) - - if not reviewers_in_actions and self.status != constants.COLLECTING: - role = self.workflow.first_step.role - users = role.get_users_queryset() - for user in users: - reviewers.add(user) + reviewers = self.objects.reviewers(self) return ", ".join(map(get_user_model().get_full_name, reviewers)) def allow_submit_for_review(self, user): @@ -453,7 +444,7 @@ class Meta: ordering = ["id"] def __str__(self): - return "{} {}".format(self.pk, self.version.pk) + return "{} {}".format(self.pk, self.version_id) @cached_property def workflow(self): diff --git a/tests/test_managers.py b/tests/test_managers.py new file mode 100644 index 00000000..f2161983 --- /dev/null +++ b/tests/test_managers.py @@ -0,0 +1,14 @@ +from djangocms_moderation.models import ModerationCollection + +from .utils.base import BaseTestCase + + +class CollectionManangerTest(BaseTestCase): + + def test_reviewers_wont_execute_too_many_queries(self): + """This works as a stop gap that will prevent any further changes to + execute more than 9 queries for prefetching_reviweers""" + with self.assertNumQueries(9): + colls = ModerationCollection.objects.all().prefetch_reviewers() + for collection in colls: + ModerationCollection.objects.reviewers(collection) From 3971f3fc60f4438bce42450decdf55987510b901 Mon Sep 17 00:00:00 2001 From: Aiky30 Date: Sat, 12 Oct 2019 11:03:41 +0100 Subject: [PATCH 120/147] Release 1.0.22 (#162) --- djangocms_moderation/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/djangocms_moderation/__init__.py b/djangocms_moderation/__init__.py index 6e65f619..d3d135bb 100644 --- a/djangocms_moderation/__init__.py +++ b/djangocms_moderation/__init__.py @@ -1,3 +1,3 @@ -__version__ = "1.0.21" +__version__ = "1.0.22" default_app_config = "djangocms_moderation.apps.ModerationConfig" From 1f64ce9d04bafd944d1b80646fca43542553f1ab Mon Sep 17 00:00:00 2001 From: Jonathan Sundqvist Date: Mon, 21 Oct 2019 23:50:05 +0200 Subject: [PATCH 121/147] Add docs for Moderation publish and unpublish signal (#163) --- docs/signals.rst | 98 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) diff --git a/docs/signals.rst b/docs/signals.rst index a0986625..e70e6d24 100644 --- a/docs/signals.rst +++ b/docs/signals.rst @@ -50,3 +50,101 @@ Arguments sent with this signal: ``rework`` A :class:`bool` value specifying if this was the first time the collection was submitted, or a rework of its moderation requests + + + +``published`` +------------------------ + +.. attribute:: djangocms_moderation.published + :module: + +.. ^^^^^^^ this :module: hack keeps Sphinx from prepending the module. + +Sent when a :ref:`moderation_collection` is being published + +Arguments sent with this signal: + +``sender`` + :class:`djangocms_moderation.models.ModerationCollection` class + +``collection`` + A :class:`djangocms_moderation.models.ModerationCollection` instance + which was submitted to be published. + +``moderator`` + A :class:`django.contrib.auth.models.User` associated with the collection which is the moderator of the collection. + +``moderation_requests`` + A list of :class:`djangocms_moderation.models.ModerationRequest` instances + which were published. + + .. note:: + + It's possible for this list to contain only some of the requests + belonging to the collection being moderated, + because only some of the requests were published. + +``workflow`` + An instance of :class:`djangocms_moderation.models.Workflow` which was used for this collection. + + +``unpublished`` +------------------------ + +.. attribute:: djangocms_moderation.unpublished + :module: + +.. ^^^^^^^ this :module: hack keeps Sphinx from prepending the module. + +Sent when a :ref:`moderation_collection` is being unpublished + +Arguments sent with this signal: + +``sender`` + :class:`djangocms_moderation.models.ModerationCollection` class + +``collection`` + A :class:`djangocms_moderation.models.ModerationCollection` instance + which was submitted to be unpublished. + +``moderator`` + A :class:`django.contrib.auth.models.User` associated with the collection which is the moderator of the collection. + +``moderation_requests`` + A list of :class:`djangocms_moderation.models.ModerationRequest` instances + which were unpublished. + + .. note:: + + It's possible for this list to contain only some of the requests + belonging to the collection being moderated, + because only some of the requests were unpublished. + +``workflow`` + An instance of :class:`djangocms_moderation.models.Workflow` which was used for this collection. + + + +How to use the moderation publish signal for a collection +--------------------------------------------------------------------- + +The CMS used to provide page publish and unpublish signals which have since been removed in DjangoCMS 4.0. You can instead use the signals provided above to replace these. + +Djangocms-moderation provides a way to take further actions once a collection has been published. The `published` event is the last event executed for a moderation. + + +.. code-block:: python + + from django.dispatch import receiver + + from cms.models import PageContent + + from djangocm_moderation.signals import published + + + @receiver(published) + def do_something_on_publish_event(*args, **kwargs): + # all keyword arguments can be found in kwargs + # pass + From 365a4b9938a1a1e95d402505001ae19b3206a939 Mon Sep 17 00:00:00 2001 From: Jonathan Sundqvist Date: Mon, 21 Oct 2019 23:53:10 +0200 Subject: [PATCH 122/147] Disable docker caching for circleci (#167) It appears that CirclCi have changed their available features for projects using the open source package. This change is required to get CircleCI working again. --- .circleci/config.yml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index 28e2b07e..c2667a55 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -5,7 +5,7 @@ py34default: &py34default - image: circleci/python:3.4 steps: - setup_remote_docker: - docker_layer_caching: true + docker_layer_caching: false - checkout - attach_workspace: at: /tmp/images @@ -17,7 +17,7 @@ py35default: &py35default - image: circleci/python:3.5 steps: - setup_remote_docker: - docker_layer_caching: true + docker_layer_caching: false - checkout - attach_workspace: at: /tmp/images @@ -30,7 +30,7 @@ py36default: &py36default - image: circleci/python:3.6 steps: - setup_remote_docker: - docker_layer_caching: true + docker_layer_caching: false - checkout - attach_workspace: at: /tmp/images @@ -56,7 +56,7 @@ jobs: steps: - checkout - setup_remote_docker: - docker_layer_caching: true + docker_layer_caching: false - run: docker build -f .circleci/Dockerfile --build-arg PYTHON_VERSION=3.4 -t py34 . - run: mkdir images - run: docker save -o images/py34.tar py34 @@ -69,7 +69,7 @@ jobs: steps: - checkout - setup_remote_docker: - docker_layer_caching: true + docker_layer_caching: false - run: docker build -f .circleci/Dockerfile --build-arg PYTHON_VERSION=3.6 -t py36 . - run: mkdir images - run: docker save -o images/py36.tar py36 @@ -83,7 +83,7 @@ jobs: steps: - checkout - setup_remote_docker: - docker_layer_caching: true + docker_layer_caching: false - run: docker build -f .circleci/Dockerfile --build-arg PYTHON_VERSION=3.5 -t py35 . - run: mkdir images - run: docker save -o images/py35.tar py35 @@ -100,7 +100,7 @@ jobs: - image: circleci/python:3.5 steps: - setup_remote_docker: - docker_layer_caching: true + docker_layer_caching: false - checkout - attach_workspace: at: /tmp/images From ef2bd3e301762a7ffeecb0589a9c7f77848a0635 Mon Sep 17 00:00:00 2001 From: Jonathan Sundqvist Date: Tue, 22 Oct 2019 17:04:02 +0200 Subject: [PATCH 123/147] Add appropriately named action to unpublish (#166) --- djangocms_moderation/admin_actions.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/djangocms_moderation/admin_actions.py b/djangocms_moderation/admin_actions.py index d1585c23..96daf574 100644 --- a/djangocms_moderation/admin_actions.py +++ b/djangocms_moderation/admin_actions.py @@ -1,4 +1,5 @@ from collections import defaultdict +from functools import partial from django.contrib import admin from django.contrib.contenttypes.models import ContentType @@ -153,9 +154,11 @@ def add_items_to_collection(modeladmin, request, queryset): return HttpResponseRedirect(request.META.get("HTTP_REFERER")) -add_items_to_collection.short_description = _( - "Add to moderation collection" -) # noqa: E305 +add_items_to_collection.short_description = _("Add to moderation collection") + +add_item_to_unpublish_collection = partial(add_items_to_collection) +add_item_to_unpublish_collection.__name__ = 'add_item_to_unpublish_collection' +add_item_to_unpublish_collection.short_description = _('Add items to a collection to unpublish') def post_bulk_actions(collection): From b0293ae44f7551acf52d9eb468311e3d02660ba6 Mon Sep 17 00:00:00 2001 From: Jonathan Sundqvist Date: Thu, 19 Mar 2020 14:08:41 +0100 Subject: [PATCH 124/147] Support django 2.2 (#168) * Make changes for django 2.2 * Use https for dependencies * Fix failing tests * Add py37 for the CI --- .circleci/config.yml | 82 ++++++++++++------- README.rst | 2 +- djangocms_moderation/admin.py | 2 +- djangocms_moderation/admin_actions.py | 4 + .../contrib/moderation_forms/cms_plugins.py | 1 + djangocms_moderation/forms.py | 6 +- djangocms_moderation/models.py | 19 +++-- setup.py | 23 +++--- tests/requirements.txt | 5 ++ tests/test_admin.py | 4 +- tests/test_admin_actions.py | 9 +- tests/test_helpers.py | 9 +- tests/test_models.py | 2 + tests/test_views.py | 10 +-- tox.ini | 10 ++- 15 files changed, 122 insertions(+), 66 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index c2667a55..05b5842d 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,17 +1,5 @@ version: 2.0 -py34default: &py34default - docker: - - image: circleci/python:3.4 - steps: - - setup_remote_docker: - docker_layer_caching: false - - checkout - - attach_workspace: - at: /tmp/images - - run: docker load -i /tmp/images/py34.tar || true - - run: docker run py34 tox -e $CIRCLE_STAGE - py35default: &py35default docker: - image: circleci/python:3.5 @@ -37,9 +25,18 @@ py36default: &py36default - run: docker load -i /tmp/images/py36.tar || true - run: docker run py36 tox -e $CIRCLE_STAGE -py34_requires: &py34_requires - requires: - - py34_base +py37default: &py37default + docker: + - image: circleci/python:3.7 + steps: + - setup_remote_docker: + docker_layer_caching: false + - checkout + - attach_workspace: + at: /tmp/images + - run: docker load -i /tmp/images/py37.tar || true + - run: docker run py37 tox -e $CIRCLE_STAGE + py35_requires: &py35_requires requires: @@ -49,20 +46,25 @@ py36_requires: &py36_requires requires: - py36_base +py37_requires: &py37_requires + requires: + - py37_base + jobs: - py34_base: + py37_base: docker: - - image: circleci/python:3.4 + - image: circleci/python:3.7 steps: - checkout - setup_remote_docker: docker_layer_caching: false - - run: docker build -f .circleci/Dockerfile --build-arg PYTHON_VERSION=3.4 -t py34 . + - run: docker build -f .circleci/Dockerfile --build-arg PYTHON_VERSION=3.7 -t py37 . - run: mkdir images - - run: docker save -o images/py34.tar py34 + - run: docker save -o images/py37.tar py37 - persist_to_workspace: root: images - paths: py34.tar + paths: py37.tar + py36_base: docker: - image: circleci/python:3.6 @@ -106,18 +108,27 @@ jobs: at: /tmp/images - run: docker load -i /tmp/images/py35.tar || true - run: docker run py35 gulp lint - py34-dj111-sqlite-cms4: - <<: *py34default py35-dj111-sqlite-cms4: <<: *py35default py36-dj111-sqlite-cms4: <<: *py36default - py34-dj20-sqlite-cms4: - <<: *py34default + py37-dj111-sqlite-cms4: + <<: *py37default + py35-dj20-sqlite-cms4: <<: *py35default py36-dj20-sqlite-cms4: <<: *py36default + py37-dj20-sqlite-cms4: + <<: *py37default + + py35-dj22-sqlite-cms4: + <<: *py35default + py36-dj22-sqlite-cms4: + <<: *py36default + py37-dj22-sqlite-cms4: + <<: *py37default + ####################### @@ -125,9 +136,9 @@ workflows: version: 2 build: jobs: - - py34_base - py35_base - py36_base + - py37_base - flake8: requires: - py35_base @@ -137,21 +148,32 @@ workflows: - eslint: requires: - py35_base - - py34-dj111-sqlite-cms4: - requires: - - py34_base - py35-dj111-sqlite-cms4: requires: - py35_base - py36-dj111-sqlite-cms4: requires: - py36_base - - py34-dj20-sqlite-cms4: + - py37-dj111-sqlite-cms4: requires: - - py34_base + - py37_base + - py35-dj20-sqlite-cms4: requires: - py35_base - py36-dj20-sqlite-cms4: requires: - py36_base + - py37-dj20-sqlite-cms4: + requires: + - py37_base + + - py35-dj22-sqlite-cms4: + requires: + - py35_base + - py36-dj22-sqlite-cms4: + requires: + - py36_base + - py37-dj22-sqlite-cms4: + requires: + - py37_base diff --git a/README.rst b/README.rst index 894b90dd..9e13b946 100644 --- a/README.rst +++ b/README.rst @@ -33,7 +33,7 @@ to perform the application's database migrations. Documentation ============= -We maintain documentation under ``docs`` folder using rst format. HTML documentation can be generated using the following command +We maintain documentation under ``docs`` folder using rst format. HTML documentation can be generated using the following commands Run:: diff --git a/djangocms_moderation/admin.py b/djangocms_moderation/admin.py index 306a2ed8..a47a7e70 100644 --- a/djangocms_moderation/admin.py +++ b/djangocms_moderation/admin.py @@ -318,7 +318,7 @@ def get_actions(self, request): # Only collection author can delete moderation requests if collection.author == request.user: - actions_to_keep.append("delete_selected") + actions_to_keep.append("remove_selected") return {key: value for key, value in actions.items() if key in actions_to_keep} diff --git a/djangocms_moderation/admin_actions.py b/djangocms_moderation/admin_actions.py index 96daf574..b37cbf32 100644 --- a/djangocms_moderation/admin_actions.py +++ b/djangocms_moderation/admin_actions.py @@ -63,6 +63,9 @@ def approve_selected(modeladmin, request, queryset): return HttpResponseRedirect(url) +approve_selected.short_description = _("Approve") + + def delete_selected(modeladmin, request, queryset): if not modeladmin.has_delete_permission(request): raise PermissionDenied @@ -77,6 +80,7 @@ def delete_selected(modeladmin, request, queryset): delete_selected.short_description = _("Remove selected") +delete_selected.__name__ = 'remove_selected' def publish_selected(modeladmin, request, queryset): diff --git a/djangocms_moderation/contrib/moderation_forms/cms_plugins.py b/djangocms_moderation/contrib/moderation_forms/cms_plugins.py index de2e1dfd..64b29794 100644 --- a/djangocms_moderation/contrib/moderation_forms/cms_plugins.py +++ b/djangocms_moderation/contrib/moderation_forms/cms_plugins.py @@ -3,6 +3,7 @@ from cms.plugin_pool import plugin_pool from aldryn_forms.cms_plugins import FormPlugin + from djangocms_moderation.helpers import get_page_or_404 from djangocms_moderation.signals import confirmation_form_submission diff --git a/djangocms_moderation/forms.py b/djangocms_moderation/forms.py index 4b7c2317..3d5d54b9 100644 --- a/djangocms_moderation/forms.py +++ b/djangocms_moderation/forms.py @@ -136,10 +136,14 @@ def __init__(self, user, *args, **kwargs): def set_collection_widget(self, request): related_modeladmin = admin.site._registry.get(ModerationCollection) dbfield = ModerationRequest._meta.get_field("collection") + + # Django 2.2 requires `remote_field` instead of `rel`. + remote_field = dbfield.rel if hasattr(dbfield, 'rel') else dbfield.remote_field + formfield = self.fields["collection"] formfield.widget = RelatedFieldWidgetWrapper( formfield.widget, - dbfield.rel, + remote_field, admin_site=admin.site, can_add_related=related_modeladmin.has_add_permission(request), can_change_related=related_modeladmin.has_change_permission(request), diff --git a/djangocms_moderation/models.py b/djangocms_moderation/models.py index 2b840e33..edb844df 100644 --- a/djangocms_moderation/models.py +++ b/djangocms_moderation/models.py @@ -79,9 +79,11 @@ class Role(models.Model): unique=True, ) user = models.ForeignKey( - to=settings.AUTH_USER_MODEL, verbose_name=_("user"), blank=True, null=True + to=settings.AUTH_USER_MODEL, verbose_name=_("user"), blank=True, null=True, on_delete=models.CASCADE + ) + group = models.ForeignKey( + to=Group, verbose_name=_("group"), blank=True, null=True, on_delete=models.CASCADE ) - group = models.ForeignKey(to=Group, verbose_name=_("group"), blank=True, null=True) confirmation_page = models.ForeignKey( to=ConfirmationPage, verbose_name=_("confirmation page"), @@ -172,10 +174,15 @@ def first_step(self): @python_2_unicode_compatible class WorkflowStep(models.Model): - role = models.ForeignKey(to=Role, verbose_name=_("role")) + role = models.ForeignKey( + to=Role, verbose_name=_("role"), on_delete=models.CASCADE + ) is_required = models.BooleanField(verbose_name=_("is mandatory"), default=True) workflow = models.ForeignKey( - to=Workflow, verbose_name=_("workflow"), related_name="steps" + to=Workflow, + verbose_name=_("workflow"), + related_name="steps", + on_delete=models.CASCADE ) order = models.PositiveIntegerField() @@ -214,7 +221,8 @@ class ModerationCollection(models.Model): on_delete=models.CASCADE, ) workflow = models.ForeignKey( - to=Workflow, verbose_name=_("workflow"), related_name="moderation_collections" + to=Workflow, verbose_name=_("workflow"), related_name="moderation_collections", + on_delete=models.CASCADE ) status = models.CharField( max_length=10, @@ -635,6 +643,7 @@ class ModerationRequestAction(models.Model): to=ModerationRequest, verbose_name=_("moderation_request"), related_name="actions", + on_delete=models.CASCADE ) date_taken = models.DateTimeField(verbose_name=_("date taken"), auto_now_add=True) diff --git a/setup.py b/setup.py index 8cf08572..3e2e4ec9 100644 --- a/setup.py +++ b/setup.py @@ -4,23 +4,23 @@ INSTALL_REQUIREMENTS = [ - "Django>=1.8,<2.0", - "django-cms>=3.4.2", + "Django>=1.11,<3.0", + "django-cms", "django-sekizai>=0.7", "django-admin-sortable2>=0.6.4", ] TEST_REQUIREMENTS = [ - "aldryn-forms", - "django-cms", - "pillow<=5.4.1", # Requirement for tests to pass in python 3.4 - "lxml<=4.3.5", # Requirement for tests to pass in python 3.4 + "django_polymorphic==2.0.3", + "cachetools", + "mock", "djangocms-text-ckeditor", "djangocms-version-locking", "djangocms-versioning", "djangocms_helper", "factory-boy", - "mock" + "django-simple-captcha", + "python-dateutil>=2.4" ] setup( @@ -45,10 +45,9 @@ tests_require=TEST_REQUIREMENTS, test_suite="tests.settings.run", dependency_links=[ - "http://github.com/divio/django-cms/tarball/release/4.0.x#egg=django-cms-4.0.0", - "http://github.com/divio/djangocms-versioning/tarball/master#egg=djangocms-versioning-0.0.23", - "http://github.com/FidelityInternational/djangocms-version-locking/tarball/master#egg=djangocms-version-locking-0.0.13", # noqa - "https://github.com/divio/djangocms-text-ckeditor/tarball/support/4.0.x#egg=djangocms-text-ckeditor-4.0.x", - "https://github.com/divio/aldryn-forms/tarball/master#egg=aldryn-forms" + "https://github.com/divio/django-cms/tarball/release/4.0.x#egg=django-cms-4.0.0", + "https://github.com/divio/djangocms-versioning/tarball/master#egg=djangocms-versioning-0.0.23", + "https://github.com/FidelityInternational/djangocms-version-locking/tarball/master#egg=djangocms-version-locking-0.0.13", # noqa + "https://github.com/divio/djangocms-text-ckeditor/tarball/support/4.0.x#egg=djangocms-text-ckeditor-4.0.x" ] ) diff --git a/tests/requirements.txt b/tests/requirements.txt index cacea447..678feaff 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -3,3 +3,8 @@ coverage pyflakes>=2.1.1 flake8 isort +python-dateutil +mock +cachetools +# Please note that aldryn-forms version 5 can only be used with django >2 and deprecates <2 completely. Version 5 is only available on master, not yet on pypi. +https://github.com/divio/aldryn-forms/archive/master.zip diff --git a/tests/test_admin.py b/tests/test_admin.py index 50780d9e..7e3e9207 100644 --- a/tests/test_admin.py +++ b/tests/test_admin.py @@ -82,13 +82,15 @@ def test_delete_selected_action_visibility(self): mock_request.user = self.user mock_request._collection = self.collection actions = self.mr_tree_admin.get_actions(request=mock_request) - self.assertIn("delete_selected", actions) + self.assertIn("remove_selected", actions) + self.assertNotIn("delete_selected", actions) # user2 won't be able to delete requests, as they are not the collection # author mock_request.user = self.user2 actions = self.mr_tree_admin.get_actions(request=mock_request) self.assertNotIn("delete_selected", actions) + self.assertNotIn("remove_selected", actions) def test_publish_selected_action_visibility_when_version_is_published(self): mock_request = MockRequest() diff --git a/tests/test_admin_actions.py b/tests/test_admin_actions.py index e45475ce..14464f3d 100644 --- a/tests/test_admin_actions.py +++ b/tests/test_admin_actions.py @@ -1009,7 +1009,7 @@ def test_delete_selected_action_cannot_be_accessed_without_delete_permission(sel # Login as the collection author self.client.force_login(self.user) # Choose the delete_selected action from the dropdown - data = get_url_data(self, "delete_selected") + data = get_url_data(self, "remove_selected") response = self.client.post(self.url, data) self.assertEqual(response.status_code, 403) @@ -1049,7 +1049,7 @@ def test_delete_selected_deletes_all_relevant_objects( # Login as the collection author self.client.force_login(self.user) # Choose the delete_selected action from the dropdown - data = get_url_data(self, "delete_selected") + data = get_url_data(self, "remove_selected") response = self.client.post(self.url, data) self.assertEqual(response.status_code, 302) @@ -1074,10 +1074,9 @@ def test_delete_selected_deletes_all_relevant_objects( def test_delete_view_when_using_get(self): # Login as the collection author self.client.force_login(self.user) - # Choose the delete_selected action from the dropdown - # Choose the approve_selected action from the dropdown - data = get_url_data(self, "delete_selected") + # Choose the delete_selected action from the dropdown + data = get_url_data(self, "remove_selected") response = self.client.post(self.url, data) # And follow the redirect (with a GET call) to the view that does the approve response = self.client.get(response.url) diff --git a/tests/test_helpers.py b/tests/test_helpers.py index 6d92c265..7c6e89e9 100644 --- a/tests/test_helpers.py +++ b/tests/test_helpers.py @@ -2,12 +2,14 @@ import mock from unittest import skip +from django.template.defaultfilters import truncatechars from django.urls import reverse from cms.test_utils.testcases import CMSTestCase from djangocms_versioning.test_utils.factories import PageVersionFactory +from djangocms_moderation.conf import COLLECTION_NAME_LENGTH_LIMIT from djangocms_moderation.constants import COLLECTING, IN_REVIEW from djangocms_moderation.helpers import ( get_form_submission_for_step, @@ -123,17 +125,20 @@ def test_get_moderation_button_truncated_title_and_url(self): self.collection.name = "Very long collection name so long wow!" self.collection.save() title, url = get_moderation_button_title_and_url(self.mr) + + expected_title = truncatechars(self.collection.name, COLLECTION_NAME_LENGTH_LIMIT) self.assertEqual( title, # By default, truncate will shorten the name - 'In collection "Very long collection ... ({})"'.format(self.collection.id), + 'In collection "{} ({})"'.format(expected_title, self.collection.id), ) with mock.patch("djangocms_moderation.helpers.COLLECTION_NAME_LENGTH_LIMIT", 3): title, url = get_moderation_button_title_and_url(self.mr) + expected_title = truncatechars(self.collection.name, 3) self.assertEqual( title, # As the limit is only 3, the truncate will produce `...` - 'In collection "... ({})"'.format(self.collection.id), + 'In collection "{} ({})"'.format(expected_title, self.collection.id), ) with mock.patch( diff --git a/tests/test_models.py b/tests/test_models.py index af655d42..3b592261 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -361,6 +361,7 @@ def test_compliance_number_sequential_number_backend(self): self.wf2.compliance_number_backend = ( "djangocms_moderation.backends.sequential_number_backend" ) + self.wf2.save() request = ModerationRequest.objects.create( version=self.pg1_version, language="en", @@ -378,6 +379,7 @@ def test_compliance_number_sequential_number_backend(self): def test_compliance_number_sequential_number_with_identifier_prefix_backend(self): self.wf2.compliance_number_backend = "djangocms_moderation.backends.sequential_number_with_identifier_prefix_backend" # noqa:E501 self.wf2.identifier = "SSO" + self.wf2.save() request = ModerationRequest.objects.create( version=self.pg1_version, diff --git a/tests/test_views.py b/tests/test_views.py index 39d59cb3..96312027 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -243,21 +243,21 @@ def test_add_pages_moderated_children_to_collection(self): ) self.assertEqual(mr.count(), 1) self.assertEqual( - ModerationRequestTreeNode.objects.filter(moderation_request=mr).count(), 1 + ModerationRequestTreeNode.objects.filter(moderation_request=mr.first()).count(), 1 ) mr1 = ModerationRequest.objects.filter( collection=collection, version=poll1_version ) self.assertEqual(mr1.count(), 1) self.assertEqual( - ModerationRequestTreeNode.objects.filter(moderation_request=mr1).count(), 1 + ModerationRequestTreeNode.objects.filter(moderation_request=mr1.first()).count(), 1 ) mr2 = ModerationRequest.objects.filter( collection=collection, version=poll2_version ) self.assertEqual(mr2.count(), 1) self.assertEqual( - ModerationRequestTreeNode.objects.filter(moderation_request=mr2).count(), 1 + ModerationRequestTreeNode.objects.filter(moderation_request=mr2.first()).count(), 1 ) def test_add_pages_moderated_duplicated_children_to_collection(self): @@ -540,14 +540,14 @@ def test_adding_page_not_by_the_author_doesnt_trigger_nested_collection_mechanis ) self.assertEqual(mr.count(), 1) self.assertEqual( - ModerationRequestTreeNode.objects.filter(moderation_request=mr).count(), 1 + ModerationRequestTreeNode.objects.filter(moderation_request=mr.first()).count(), 1 ) mr1 = ModerationRequest.objects.filter( collection=collection, version=poll1_version ) self.assertEqual(mr1.count(), 0) self.assertEqual( - ModerationRequestTreeNode.objects.filter(moderation_request=mr1).count(), 0 + ModerationRequestTreeNode.objects.filter(moderation_request=mr1.first()).count(), 0 ) diff --git a/tox.ini b/tox.ini index bc6de0aa..140d49c8 100644 --- a/tox.ini +++ b/tox.ini @@ -2,8 +2,8 @@ envlist = flake8 isort - py{34,35,36}-dj111-sqlite-cms4 - py{34,35,36}-dj20-sqlite-cms4 + py{35,36,37}-dj{111,20}-sqlite-cms4-aldrynforms4 + py{35,36,37}-dj{21,22}-sqlite-cms4-aldrynforms5 skip_missing_interpreters=True @@ -13,13 +13,17 @@ deps = dj111: Django>=1.11,<2.0 dj20: Django>=2.0,<2.1 + dj21: Django>=2.1,<2.2 + dj22: Django>=2.2,<3.0 cms4: https://github.com/divio/django-cms/archive/release/4.0.x.zip + aldrynforms5: https://github.com/divio/aldryn-forms/archive/master.zip + aldrynforms4: aldryn-forms==4.0.1 basepython = - py34: python3.4 py35: python3.5 py36: python3.6 + py37: python3.7 commands = {envpython} --version From 76625cf924f19af072ae865dc8901cb1c13dec40 Mon Sep 17 00:00:00 2001 From: Aiky30 Date: Thu, 19 Mar 2020 15:43:23 +0000 Subject: [PATCH 125/147] Release 1.0.23 (#174) --- djangocms_moderation/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/djangocms_moderation/__init__.py b/djangocms_moderation/__init__.py index d3d135bb..3b296b27 100644 --- a/djangocms_moderation/__init__.py +++ b/djangocms_moderation/__init__.py @@ -1,3 +1,3 @@ -__version__ = "1.0.22" +__version__ = "1.0.23" default_app_config = "djangocms_moderation.apps.ModerationConfig" From 1c35ace95131ada49463d890195ea5cf9196e9ad Mon Sep 17 00:00:00 2001 From: Jonathan Sundqvist Date: Tue, 24 Mar 2020 17:55:54 +0100 Subject: [PATCH 126/147] Referer error occuring in dj2.2 (#175) Fix an issue found when running tests from other packages where the referrer is not present as the view is isolated. File "/Users/jonathan/projects/eu-multisite-4/addons-dev/djangocms-versioning-filer/venv/lib/python3.6/site-packages/django/utils/http.py", line 96, in urlencode 'Cannot encode None in a query string. Did you mean to pass ' TypeError: Cannot encode None in a query string. Did you mean to pass an empty string or omit the value? --- djangocms_moderation/admin_actions.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/djangocms_moderation/admin_actions.py b/djangocms_moderation/admin_actions.py index b37cbf32..e09a2388 100644 --- a/djangocms_moderation/admin_actions.py +++ b/djangocms_moderation/admin_actions.py @@ -148,14 +148,14 @@ def add_items_to_collection(modeladmin, request, queryset): args=(), ), version_ids=",".join(version_ids), - return_to_url=request.META.get("HTTP_REFERER"), + return_to_url=request.META.get("HTTP_REFERER", ""), ) return HttpResponseRedirect(admin_url) else: modeladmin.message_user( request, _("No suitable items found to add to moderation collection") ) - return HttpResponseRedirect(request.META.get("HTTP_REFERER")) + return HttpResponseRedirect(request.META.get("HTTP_REFERER", "")) add_items_to_collection.short_description = _("Add to moderation collection") From f43c7c56e73bf18901af0794c699f3f9ebeef4c2 Mon Sep 17 00:00:00 2001 From: Aiky30 Date: Tue, 14 Apr 2020 18:57:45 +0100 Subject: [PATCH 127/147] Release 1.0.24 (#177) --- djangocms_moderation/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/djangocms_moderation/__init__.py b/djangocms_moderation/__init__.py index 3b296b27..8c4b502e 100644 --- a/djangocms_moderation/__init__.py +++ b/djangocms_moderation/__init__.py @@ -1,3 +1,3 @@ -__version__ = "1.0.23" +__version__ = "1.0.24" default_app_config = "djangocms_moderation.apps.ModerationConfig" From 296b1a9341c550f2d8897bf2aa48e6b4c2677182 Mon Sep 17 00:00:00 2001 From: Aiky30 Date: Mon, 27 Apr 2020 15:07:05 +0100 Subject: [PATCH 128/147] Optionally enable silent failure of email notifications (#153) * Optionally enable silent failure of email notifications * Named the setting to one that can be used by other packages --- djangocms_moderation/conf.py | 4 ++++ djangocms_moderation/emails.py | 5 ++++- docs/index.rst | 1 + docs/notifications.rst | 8 ++++++++ 4 files changed, 17 insertions(+), 1 deletion(-) create mode 100644 docs/notifications.rst diff --git a/djangocms_moderation/conf.py b/djangocms_moderation/conf.py index a5d4c9d0..417d5500 100644 --- a/djangocms_moderation/conf.py +++ b/djangocms_moderation/conf.py @@ -58,3 +58,7 @@ COLLECTION_NAME_LENGTH_LIMIT = getattr( settings, "CMS_MODERATION_COLLECTION_NAME_LENGTH_LIMIT", 24 ) + +EMAIL_NOTIFICATIONS_FAIL_SILENTLY = getattr( + settings, "EMAIL_NOTIFICATIONS_FAIL_SILENTLY", False +) diff --git a/djangocms_moderation/emails.py b/djangocms_moderation/emails.py index b6581a5a..35acc07c 100644 --- a/djangocms_moderation/emails.py +++ b/djangocms_moderation/emails.py @@ -7,6 +7,7 @@ from django.utils.encoding import force_text from django.utils.translation import ugettext_lazy as _ +from .conf import EMAIL_NOTIFICATIONS_FAIL_SILENTLY from .utils import get_absolute_url @@ -48,7 +49,9 @@ def _send_email( from_email=settings.DEFAULT_FROM_EMAIL, to=recipients, ) - return message.send() + return message.send( + fail_silently=EMAIL_NOTIFICATIONS_FAIL_SILENTLY + ) def notify_collection_author(collection, moderation_requests, action, by_user): diff --git a/docs/index.rst b/docs/index.rst index 913aeb27..adfa08a3 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -16,6 +16,7 @@ Welcome to djangocms-moderation's documentation! lock moderation_request moderation_request_action + notifications role references workflow diff --git a/docs/notifications.rst b/docs/notifications.rst new file mode 100644 index 00000000..77197239 --- /dev/null +++ b/docs/notifications.rst @@ -0,0 +1,8 @@ + +Notifications +========================== + + +Email notifications +------------------------ +Configure email notifications to fail silently by setting: ``EMAIL_NOTIFICATIONS_FAIL_SILENTLY=True`` From 0d9936ced5917f44aecd37f3d16444364d171272 Mon Sep 17 00:00:00 2001 From: Aiky30 Date: Mon, 11 May 2020 13:11:06 +0100 Subject: [PATCH 129/147] Release 1.0.25 (#178) --- djangocms_moderation/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/djangocms_moderation/__init__.py b/djangocms_moderation/__init__.py index 8c4b502e..15c09081 100644 --- a/djangocms_moderation/__init__.py +++ b/djangocms_moderation/__init__.py @@ -1,3 +1,3 @@ -__version__ = "1.0.24" +__version__ = "1.0.25" default_app_config = "djangocms_moderation.apps.ModerationConfig" From dbeb6204662ab9d1c39f9d6df178c7004d332464 Mon Sep 17 00:00:00 2001 From: Adam Murray Date: Wed, 14 Oct 2020 15:40:13 +0100 Subject: [PATCH 130/147] Added test case and validation for xss possibility in views (#181) Co-authored-by: narender81 --- djangocms_moderation/views.py | 3 ++- tests/requirements.txt | 6 +++++- tests/test_views.py | 31 +++++++++++++++++++++++++++++++ tox.ini | 9 ++++----- 4 files changed, 42 insertions(+), 7 deletions(-) diff --git a/djangocms_moderation/views.py b/djangocms_moderation/views.py index 6bcccfcd..cee30199 100644 --- a/djangocms_moderation/views.py +++ b/djangocms_moderation/views.py @@ -6,7 +6,7 @@ from django.shortcuts import get_object_or_404, render from django.urls import reverse from django.utils.decorators import method_decorator -from django.utils.http import is_safe_url +from django.utils.http import is_safe_url, urlquote from django.utils.translation import ugettext_lazy as _, ungettext from django.views.generic import FormView @@ -88,6 +88,7 @@ def _get_success_redirect(self): allowed_hosts=self.request.get_host(), require_https=self.request.is_secure(), ) + return_to_url = urlquote(return_to_url) if not url_is_safe: return_to_url = self.request.path return HttpResponseRedirect(return_to_url) diff --git a/tests/requirements.txt b/tests/requirements.txt index 678feaff..015bb328 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -6,5 +6,9 @@ isort python-dateutil mock cachetools -# Please note that aldryn-forms version 5 can only be used with django >2 and deprecates <2 completely. Version 5 is only available on master, not yet on pypi. +django-filer<2.0.0 +django-classy-tags<2.0.0 +django-sekizai<2.0.0 +# Please note that aldryn-forms version 5 can only be used with django >2 and deprecates <2 completely. Version 5 is only available on master, not yet on pypi. https://github.com/divio/aldryn-forms/archive/master.zip +https://github.com/divio/djangocms-text-ckeditor/tarball/support/4.0.x#egg=djangocms-text-ckeditor diff --git a/tests/test_views.py b/tests/test_views.py index 96312027..d496d48d 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -550,6 +550,37 @@ def test_adding_page_not_by_the_author_doesnt_trigger_nested_collection_mechanis ModerationRequestTreeNode.objects.filter(moderation_request=mr1.first()).count(), 0 ) + def test_collection_with_redirect_url_query_string_parameter_sanitisation(self): + user = self.get_superuser() + collection = ModerationCollectionFactory(author=user) + page1_version = PageVersionFactory(created_by=user) + page2_version = PageVersionFactory(created_by=user) + + redirect_to_url = f"""{reverse( + "admin:djangocms_moderation_moderationcollection_changelist" + )}?=""" + + url = add_url_parameters( + get_admin_url( + name="cms_moderation_items_to_collection", language="en", args=() + ), + return_to_url=redirect_to_url, + version_ids=",".join(str(x) for x in [page1_version.pk, page2_version.pk]), + collection_id=collection.pk, + ) + with self.login_user_context(user): + response = self.client.post( + path=url, + data={ + "collection": collection.pk, + "versions": [page1_version.pk, page2_version.pk], + }, + follow=False, + ) + + self.assertEqual(response.status_code, 302) + self.assertIn("%3F%3D%3Cscript%3Ealert%28%27attack%21%27%29%3C/script%3E", response.url) + class CollectionItemsViewTest(CMSTestCase): def setUp(self): diff --git a/tox.ini b/tox.ini index 140d49c8..7483eb32 100644 --- a/tox.ini +++ b/tox.ini @@ -2,8 +2,8 @@ envlist = flake8 isort - py{35,36,37}-dj{111,20}-sqlite-cms4-aldrynforms4 - py{35,36,37}-dj{21,22}-sqlite-cms4-aldrynforms5 + py{36,37}-dj{111,20}-sqlite-cms4-aldrynforms4 + py{36,37}-dj{21,22}-sqlite-cms4-aldrynforms5 skip_missing_interpreters=True @@ -21,7 +21,6 @@ deps = aldrynforms4: aldryn-forms==4.0.1 basepython = - py35: python3.5 py36: python3.6 py37: python3.7 @@ -33,8 +32,8 @@ commands = [testenv:flake8] commands = flake8 -basepython = python3.5 +basepython = python3.6 [testenv:isort] commands = isort --recursive --check-only --diff {toxinidir} -basepython = python3.5 +basepython = python3.6 From 53249f498afcdf349dc8741ae1b1a07326982414 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2020 11:41:37 +0000 Subject: [PATCH 131/147] Bump fstream from 1.0.11 to 1.0.12 (#170) Bumps [fstream](https://github.com/npm/fstream) from 1.0.11 to 1.0.12. - [Release notes](https://github.com/npm/fstream/releases) - [Commits](https://github.com/npm/fstream/compare/v1.0.11...v1.0.12) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 69 ++++++++++++++++++++++++++++++----------------- 1 file changed, 44 insertions(+), 25 deletions(-) diff --git a/package-lock.json b/package-lock.json index a811c70a..bd9e1f91 100644 --- a/package-lock.json +++ b/package-lock.json @@ -71,7 +71,7 @@ "dependencies": { "acorn": { "version": "3.3.0", - "resolved": "http://registry.npmjs.org/acorn/-/acorn-3.3.0.tgz", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-3.3.0.tgz", "integrity": "sha1-ReN/s56No/JbruP/U2niu18iAXo=", "dev": true } @@ -1338,7 +1338,7 @@ "dependencies": { "lodash": { "version": "3.10.1", - "resolved": "http://registry.npmjs.org/lodash/-/lodash-3.10.1.tgz", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-3.10.1.tgz", "integrity": "sha1-W/Rejkm6QYnhfUgnid/RW9FAt7Y=", "dev": true } @@ -1466,7 +1466,7 @@ }, "chalk": { "version": "1.1.3", - "resolved": "http://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-1.1.3.tgz", "integrity": "sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=", "dev": true, "requires": { @@ -2334,7 +2334,7 @@ }, "eslint": { "version": "4.19.1", - "resolved": "http://registry.npmjs.org/eslint/-/eslint-4.19.1.tgz", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-4.19.1.tgz", "integrity": "sha512-bT3/1x1EbZB7phzYu7vCr1v3ONuzDtX8WjuM9c0iYxe+cq+pwcKEoQjl7zd3RpC6YOLgnSy3cTN58M2jcoPDIQ==", "dev": true, "requires": { @@ -2939,7 +2939,7 @@ "dependencies": { "combined-stream": { "version": "1.0.6", - "resolved": "http://registry.npmjs.org/combined-stream/-/combined-stream-1.0.6.tgz", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.6.tgz", "integrity": "sha1-cj599ugBrFYTETp+RFqbactjKBg=", "dev": true, "requires": { @@ -2983,7 +2983,8 @@ "ansi-regex": { "version": "2.1.1", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "aproba": { "version": "1.2.0", @@ -3004,12 +3005,14 @@ "balanced-match": { "version": "1.0.0", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "brace-expansion": { "version": "1.1.11", "bundled": true, "dev": true, + "optional": true, "requires": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" @@ -3024,17 +3027,20 @@ "code-point-at": { "version": "1.1.0", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "concat-map": { "version": "0.0.1", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "console-control-strings": { "version": "1.1.0", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "core-util-is": { "version": "1.0.2", @@ -3151,7 +3157,8 @@ "inherits": { "version": "2.0.3", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "ini": { "version": "1.3.5", @@ -3163,6 +3170,7 @@ "version": "1.0.0", "bundled": true, "dev": true, + "optional": true, "requires": { "number-is-nan": "^1.0.0" } @@ -3177,6 +3185,7 @@ "version": "3.0.4", "bundled": true, "dev": true, + "optional": true, "requires": { "brace-expansion": "^1.1.7" } @@ -3184,12 +3193,14 @@ "minimist": { "version": "0.0.8", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "minipass": { "version": "2.2.4", "bundled": true, "dev": true, + "optional": true, "requires": { "safe-buffer": "^5.1.1", "yallist": "^3.0.0" @@ -3208,6 +3219,7 @@ "version": "0.5.1", "bundled": true, "dev": true, + "optional": true, "requires": { "minimist": "0.0.8" } @@ -3288,7 +3300,8 @@ "number-is-nan": { "version": "1.0.1", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "object-assign": { "version": "4.1.1", @@ -3300,6 +3313,7 @@ "version": "1.4.0", "bundled": true, "dev": true, + "optional": true, "requires": { "wrappy": "1" } @@ -3385,7 +3399,8 @@ "safe-buffer": { "version": "5.1.1", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "safer-buffer": { "version": "2.1.2", @@ -3421,6 +3436,7 @@ "version": "1.0.2", "bundled": true, "dev": true, + "optional": true, "requires": { "code-point-at": "^1.0.0", "is-fullwidth-code-point": "^1.0.0", @@ -3440,6 +3456,7 @@ "version": "3.0.1", "bundled": true, "dev": true, + "optional": true, "requires": { "ansi-regex": "^2.0.0" } @@ -3483,19 +3500,21 @@ "wrappy": { "version": "1.0.2", "bundled": true, - "dev": true + "dev": true, + "optional": true }, "yallist": { "version": "3.0.2", "bundled": true, - "dev": true + "dev": true, + "optional": true } } }, "fstream": { - "version": "1.0.11", - "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.11.tgz", - "integrity": "sha1-XB+x8RdHcRTwYyoOtLcbPLD9MXE=", + "version": "1.0.12", + "resolved": "https://registry.npmjs.org/fstream/-/fstream-1.0.12.tgz", + "integrity": "sha512-WvJ193OHa0GHPEL+AycEJgxvBEwyfRkN1vhjca23OaPVMCaLCXTd5qAu82AjTcgP1UJmytkOKb63Ypde7raDIg==", "dev": true, "requires": { "graceful-fs": "^4.1.2", @@ -3775,7 +3794,7 @@ }, "lodash": { "version": "1.0.2", - "resolved": "http://registry.npmjs.org/lodash/-/lodash-1.0.2.tgz", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-1.0.2.tgz", "integrity": "sha1-j1dWDIO1n8JwvT1WG2kAQ0MOJVE=", "dev": true }, @@ -4008,7 +4027,7 @@ }, "inquirer": { "version": "0.12.0", - "resolved": "http://registry.npmjs.org/inquirer/-/inquirer-0.12.0.tgz", + "resolved": "https://registry.npmjs.org/inquirer/-/inquirer-0.12.0.tgz", "integrity": "sha1-HvK/1jUE3wvHV4X/+MLEHfEvB34=", "dev": true, "requires": { @@ -4038,7 +4057,7 @@ }, "onetime": { "version": "1.1.0", - "resolved": "http://registry.npmjs.org/onetime/-/onetime-1.1.0.tgz", + "resolved": "https://registry.npmjs.org/onetime/-/onetime-1.1.0.tgz", "integrity": "sha1-ofeDj4MUxRbwXs78vEzP4EtO14k=", "dev": true }, @@ -4104,7 +4123,7 @@ }, "table": { "version": "3.8.3", - "resolved": "http://registry.npmjs.org/table/-/table-3.8.3.tgz", + "resolved": "https://registry.npmjs.org/table/-/table-3.8.3.tgz", "integrity": "sha1-K7xULw/amGGnVdOUf+/Ys/UThV8=", "dev": true, "requires": { @@ -6137,7 +6156,7 @@ }, "os-locale": { "version": "1.4.0", - "resolved": "http://registry.npmjs.org/os-locale/-/os-locale-1.4.0.tgz", + "resolved": "https://registry.npmjs.org/os-locale/-/os-locale-1.4.0.tgz", "integrity": "sha1-IPnxeuKe00XoveWDsT0gCYA8FNk=", "dev": true, "requires": { @@ -7783,7 +7802,7 @@ }, "yargs": { "version": "3.10.0", - "resolved": "http://registry.npmjs.org/yargs/-/yargs-3.10.0.tgz", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-3.10.0.tgz", "integrity": "sha1-9+572FfdfB0tOMDnTvvWgdFDH9E=", "dev": true, "requires": { From 0e0c8793f4710c84a7084b4834ca6f3a11a8ced5 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2020 11:41:46 +0000 Subject: [PATCH 132/147] Bump mixin-deep from 1.3.1 to 1.3.2 (#171) Bumps [mixin-deep](https://github.com/jonschlinkert/mixin-deep) from 1.3.1 to 1.3.2. - [Release notes](https://github.com/jonschlinkert/mixin-deep/releases) - [Commits](https://github.com/jonschlinkert/mixin-deep/compare/1.3.1...1.3.2) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index bd9e1f91..164a5b51 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5716,9 +5716,9 @@ "dev": true }, "mixin-deep": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/mixin-deep/-/mixin-deep-1.3.1.tgz", - "integrity": "sha512-8ZItLHeEgaqEvd5lYBXfm4EZSFCX29Jb9K+lAHhDKzReKBQKj3R+7NOF6tjqYi9t4oI8VUfaWITJQm86wnXGNQ==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/mixin-deep/-/mixin-deep-1.3.2.tgz", + "integrity": "sha512-WRoDn//mXBiJ1H40rqa3vH0toePwSsGb45iInWlTySa+Uu4k3tYUSxa2v1KqAiLtvlrSzaExqS1gtk96A9zvEA==", "dev": true, "requires": { "for-in": "^1.0.2", From 01d9bf66f508be7751ec7059b3be07c0f90874f0 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2020 11:41:51 +0000 Subject: [PATCH 133/147] Bump js-yaml from 3.12.0 to 3.13.1 (#172) Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 3.12.0 to 3.13.1. - [Release notes](https://github.com/nodeca/js-yaml/releases) - [Changelog](https://github.com/nodeca/js-yaml/blob/master/CHANGELOG.md) - [Commits](https://github.com/nodeca/js-yaml/compare/3.12.0...3.13.1) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 164a5b51..bbfa95e1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5151,9 +5151,9 @@ "dev": true }, "js-yaml": { - "version": "3.12.0", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.12.0.tgz", - "integrity": "sha512-PIt2cnwmPfL4hKNwqeiuz4bKfnzHTBv6HyVgjahA6mPLwPDzjDWrplJBMjHUFxku/N3FlmrbyPclad+I+4mJ3A==", + "version": "3.13.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.13.1.tgz", + "integrity": "sha512-YfbcO7jXDdyj0DGxYVSlSeQNHbD7XPWvrVWeVUujrQEoZzWJIRrCPoyk6kL6IAjAG2IolMK4T0hNUe0HOUs5Jw==", "dev": true, "requires": { "argparse": "^1.0.7", From 87d7a7f8593caf91fc73cecb2d52f8bff231f45c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Tue, 27 Oct 2020 11:41:57 +0000 Subject: [PATCH 134/147] Bump lodash.mergewith from 4.6.1 to 4.6.2 (#173) Bumps [lodash.mergewith](https://github.com/lodash/lodash) from 4.6.1 to 4.6.2. - [Release notes](https://github.com/lodash/lodash/releases) - [Commits](https://github.com/lodash/lodash/commits) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index bbfa95e1..56f2e76a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5468,9 +5468,9 @@ "integrity": "sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4=" }, "lodash.mergewith": { - "version": "4.6.1", - "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.1.tgz", - "integrity": "sha512-eWw5r+PYICtEBgrBE5hhlT6aAa75f411bgDz/ZL2KZqYV03USvucsxcHUIlGTDTECs1eunpI7HOV7U+WLDvNdQ==", + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", + "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", "dev": true }, "lodash.restparam": { From a568693349dda507d41f9645e3a9b6017a1e00b1 Mon Sep 17 00:00:00 2001 From: Aiky30 Date: Wed, 28 Oct 2020 13:53:39 +0000 Subject: [PATCH 135/147] Fix broken test suite (#182) --- setup.py | 10 +--------- tests/requirements.txt | 9 ++++++++- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/setup.py b/setup.py index 3e2e4ec9..2257a598 100644 --- a/setup.py +++ b/setup.py @@ -11,16 +11,8 @@ ] TEST_REQUIREMENTS = [ - "django_polymorphic==2.0.3", - "cachetools", - "mock", - "djangocms-text-ckeditor", "djangocms-version-locking", "djangocms-versioning", - "djangocms_helper", - "factory-boy", - "django-simple-captcha", - "python-dateutil>=2.4" ] setup( @@ -48,6 +40,6 @@ "https://github.com/divio/django-cms/tarball/release/4.0.x#egg=django-cms-4.0.0", "https://github.com/divio/djangocms-versioning/tarball/master#egg=djangocms-versioning-0.0.23", "https://github.com/FidelityInternational/djangocms-version-locking/tarball/master#egg=djangocms-version-locking-0.0.13", # noqa - "https://github.com/divio/djangocms-text-ckeditor/tarball/support/4.0.x#egg=djangocms-text-ckeditor-4.0.x" + "https://github.com/divio/djangocms-text-ckeditor/tarball/support/4.0.x#egg=djangocms-text-ckeditor-4.0.x", ] ) diff --git a/tests/requirements.txt b/tests/requirements.txt index 015bb328..622c5b90 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -1,9 +1,15 @@ tox coverage +djangocms_helper +django_polymorphic==2.0.3 +cachetools +mock +factory-boy +django-simple-captcha +python-dateutil>=2.4 pyflakes>=2.1.1 flake8 isort -python-dateutil mock cachetools django-filer<2.0.0 @@ -11,4 +17,5 @@ django-classy-tags<2.0.0 django-sekizai<2.0.0 # Please note that aldryn-forms version 5 can only be used with django >2 and deprecates <2 completely. Version 5 is only available on master, not yet on pypi. https://github.com/divio/aldryn-forms/archive/master.zip +# Get the lastest cms v4 compatible djangocms-text-ckeditor https://github.com/divio/djangocms-text-ckeditor/tarball/support/4.0.x#egg=djangocms-text-ckeditor From a3d43ed1cb8570375bf9ec3f1d635435fc5f8121 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2020 08:30:55 +0000 Subject: [PATCH 136/147] Bump minimist from 1.2.0 to 1.2.3 (#176) Bumps [minimist](https://github.com/substack/minimist) from 1.2.0 to 1.2.3. - [Release notes](https://github.com/substack/minimist/releases) - [Commits](https://github.com/substack/minimist/compare/1.2.0...1.2.3) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 6 +++--- package.json | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/package-lock.json b/package-lock.json index 56f2e76a..c84d5e62 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5710,9 +5710,9 @@ } }, "minimist": { - "version": "1.2.0", - "resolved": "http://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", - "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=", + "version": "1.2.3", + "resolved": "http://registry.npmjs.org/minimist/-/minimist-1.2.3.tgz", + "integrity": "sha512-+bMdgqjMN/Z77a6NlY/I3U5LlRDbnmaAk6lDveAPKwSpcPM4tKAuYsvYF8xjhOPXhOYGe/73vVLVez5PW+jqhw==", "dev": true }, "mixin-deep": { diff --git a/package.json b/package.json index 48d3a1fd..af5a7748 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "gulp-util": "3.0.5", "imports-loader": "^0.7.1", "karma-sourcemap-loader": "^0.3.7", - "minimist": "1.2.0", + "minimist": "1.2.3", "webpack": "^3.0.0" }, "dependencies": { From fdfe957afb12244685534a28f20057b66f941acd Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2020 08:31:11 +0000 Subject: [PATCH 137/147] Bump elliptic from 6.4.1 to 6.5.3 (#179) Bumps [elliptic](https://github.com/indutny/elliptic) from 6.4.1 to 6.5.3. - [Release notes](https://github.com/indutny/elliptic/releases) - [Commits](https://github.com/indutny/elliptic/compare/v6.4.1...v6.5.3) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index c84d5e62..741ce650 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2168,9 +2168,9 @@ "dev": true }, "elliptic": { - "version": "6.4.1", - "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.4.1.tgz", - "integrity": "sha512-BsXLz5sqX8OHcsh7CqBMztyXARmGQ3LWPtGjJi6DiJHq5C/qvi9P3OqgswKSDftbu8+IoI/QDTAm2fFnQ9SZSQ==", + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/elliptic/-/elliptic-6.5.3.tgz", + "integrity": "sha512-IMqzv5wNQf+E6aHeIqATs0tOLeOTwj1QKbRcS3jBbYkl5oLAserA8yJTT7/VyHUYG91PRmPyeQDObKLPpeS4dw==", "dev": true, "requires": { "bn.js": "^4.4.0", From 71d18e97099e62b11da135726632bfcd435b1fda Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2020 08:31:27 +0000 Subject: [PATCH 138/147] Bump node-sass from 4.9.3 to 4.14.1 (#180) Bumps [node-sass](https://github.com/sass/node-sass) from 4.9.3 to 4.14.1. - [Release notes](https://github.com/sass/node-sass/releases) - [Changelog](https://github.com/sass/node-sass/blob/master/CHANGELOG.md) - [Commits](https://github.com/sass/node-sass/compare/v4.9.3...v4.14.1) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 408 ++++++++++++++++++++++++++++++++-------------- 1 file changed, 290 insertions(+), 118 deletions(-) diff --git a/package-lock.json b/package-lock.json index 741ce650..37c9b044 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2182,6 +2182,12 @@ "minimalistic-crypto-utils": "^1.0.0" } }, + "emoji-regex": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-7.0.3.tgz", + "integrity": "sha512-CwBLREIQ7LvYFB0WyRvwhq5N5qPhc6PMjD6bYggFlI5YyDgl+0vxq5VHbMOFqLg7hfWzmu8T5Z1QofhmTIhItA==", + "dev": true + }, "emojis-list": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/emojis-list/-/emojis-list-2.1.0.tgz", @@ -5412,12 +5418,6 @@ "integrity": "sha1-+6HEUkwZ7ppfgTa0YJ8BfPTe1pI=", "dev": true }, - "lodash.assign": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/lodash.assign/-/lodash.assign-4.2.0.tgz", - "integrity": "sha1-DZnzzNem0mHRm9rrkkUAXShYCOc=", - "dev": true - }, "lodash.clonedeep": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", @@ -5467,12 +5467,6 @@ "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", "integrity": "sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4=" }, - "lodash.mergewith": { - "version": "4.6.2", - "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", - "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", - "dev": true - }, "lodash.restparam": { "version": "3.6.1", "resolved": "https://registry.npmjs.org/lodash.restparam/-/lodash.restparam-3.6.1.tgz", @@ -5778,7 +5772,8 @@ "version": "2.11.0", "resolved": "https://registry.npmjs.org/nan/-/nan-2.11.0.tgz", "integrity": "sha512-F4miItu2rGnV2ySkXOQoA8FKz/SR2Q2sWP0sbTxNxz/tuokeC8WxOhPMcwi0qIyGtVn/rrSeLbvVkznqCdwYnw==", - "dev": true + "dev": true, + "optional": true }, "nanomatch": { "version": "1.2.13", @@ -5883,9 +5878,9 @@ } }, "node-sass": { - "version": "4.9.3", - "resolved": "https://registry.npmjs.org/node-sass/-/node-sass-4.9.3.tgz", - "integrity": "sha512-XzXyGjO+84wxyH7fV6IwBOTrEBe2f0a6SBze9QWWYR/cL74AcQUks2AsqcCZenl/Fp/JVbuEaLpgrLtocwBUww==", + "version": "4.14.1", + "resolved": "https://registry.npmjs.org/node-sass/-/node-sass-4.14.1.tgz", + "integrity": "sha512-sjCuOlvGyCJS40R8BscF5vhVlQjNN069NtQ1gSxyK1u9iqvn6tf7O1R4GNowVZfiZUCRt5MmMs1xd+4V/7Yr0g==", "dev": true, "requires": { "async-foreach": "^0.1.3", @@ -5895,20 +5890,62 @@ "get-stdin": "^4.0.1", "glob": "^7.0.3", "in-publish": "^2.0.0", - "lodash.assign": "^4.2.0", - "lodash.clonedeep": "^4.3.2", - "lodash.mergewith": "^4.6.0", + "lodash": "^4.17.15", "meow": "^3.7.0", "mkdirp": "^0.5.1", - "nan": "^2.10.0", + "nan": "^2.13.2", "node-gyp": "^3.8.0", "npmlog": "^4.0.0", - "request": "2.87.0", - "sass-graph": "^2.2.4", + "request": "^2.88.0", + "sass-graph": "2.2.5", "stdout-stream": "^1.4.0", "true-case-path": "^1.0.2" }, "dependencies": { + "ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "requires": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + } + }, + "ansi-regex": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-4.1.0.tgz", + "integrity": "sha512-1apePfXM1UOSqw0o9IiFAovVz9M5S1Dg+4TrDwfMewQ6p/rmMueb7tWZjQ1rx4Loy1ArBggoqGpfqqdI4rondg==", + "dev": true + }, + "ansi-styles": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-3.2.1.tgz", + "integrity": "sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==", + "dev": true, + "requires": { + "color-convert": "^1.9.0" + } + }, + "camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "dev": true + }, + "cliui": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-5.0.0.tgz", + "integrity": "sha512-PYeGSEmmHM6zvoef2w8TPzlrnNpXIjTipYK780YswmIP9vjxmd6Y2a3CB2Ks6/AU8NHjZugXvo8w3oWM2qnwXA==", + "dev": true, + "requires": { + "string-width": "^3.1.0", + "strip-ansi": "^5.2.0", + "wrap-ansi": "^5.1.0" + } + }, "cross-spawn": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-3.0.1.tgz", @@ -5919,6 +5956,21 @@ "which": "^1.2.9" } }, + "fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, + "find-up": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", + "integrity": "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg==", + "dev": true, + "requires": { + "locate-path": "^3.0.0" + } + }, "gaze": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/gaze/-/gaze-1.1.3.tgz", @@ -5928,16 +5980,223 @@ "globule": "^1.0.0" } }, + "get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true + }, "globule": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/globule/-/globule-1.2.1.tgz", - "integrity": "sha512-g7QtgWF4uYSL5/dn71WxubOrS7JVGCnFPEnoeChJmBnyR9Mw8nGoEwOgJL/RC2Te0WhbsEUCejfH8SZNJ+adYQ==", + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/globule/-/globule-1.3.2.tgz", + "integrity": "sha512-7IDTQTIu2xzXkT+6mlluidnWo+BypnbSoEVVQCGfzqnl5Ik8d3e1d4wycb8Rj9tWW+Z39uPWsdlquqiqPCd/pA==", "dev": true, "requires": { "glob": "~7.1.1", "lodash": "~4.17.10", "minimatch": "~3.0.2" } + }, + "har-validator": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/har-validator/-/har-validator-5.1.5.tgz", + "integrity": "sha512-nmT2T0lljbxdQZfspsno9hgrG3Uir6Ks5afism62poxqBM6sDnMEuPmzTq8XN0OEwqKLLdh1jQI3qyE66Nzb3w==", + "dev": true, + "requires": { + "ajv": "^6.12.3", + "har-schema": "^2.0.0" + } + }, + "json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "locate-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-3.0.0.tgz", + "integrity": "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A==", + "dev": true, + "requires": { + "p-locate": "^3.0.0", + "path-exists": "^3.0.0" + } + }, + "lodash": { + "version": "4.17.20", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.20.tgz", + "integrity": "sha512-PlhdFcillOINfeV7Ni6oF1TAEayyZBoZ8bcshTHqOYJYlrqzRK5hagpagky5o4HfCzzd1TRkXPMFq6cKk9rGmA==", + "dev": true + }, + "nan": { + "version": "2.14.2", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.14.2.tgz", + "integrity": "sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ==", + "dev": true + }, + "oauth-sign": { + "version": "0.9.0", + "resolved": "https://registry.npmjs.org/oauth-sign/-/oauth-sign-0.9.0.tgz", + "integrity": "sha512-fexhUFFPTGV8ybAtSIGbV6gOkSv8UtRbDBnAyLQw4QPKkgNlsH2ByPGtMUqdWkos6YCRmAqViwgZrJc/mRDzZQ==", + "dev": true + }, + "p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dev": true, + "requires": { + "p-try": "^2.0.0" + } + }, + "p-locate": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-3.0.0.tgz", + "integrity": "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ==", + "dev": true, + "requires": { + "p-limit": "^2.0.0" + } + }, + "p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "dev": true + }, + "punycode": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.1.1.tgz", + "integrity": "sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A==", + "dev": true + }, + "request": { + "version": "2.88.2", + "resolved": "https://registry.npmjs.org/request/-/request-2.88.2.tgz", + "integrity": "sha512-MsvtOrfG9ZcrOwAW+Qi+F6HbD0CWXEh9ou77uOb7FM2WPhwT7smM833PzanhJLsgXjN89Ir6V2PczXNnMpwKhw==", + "dev": true, + "requires": { + "aws-sign2": "~0.7.0", + "aws4": "^1.8.0", + "caseless": "~0.12.0", + "combined-stream": "~1.0.6", + "extend": "~3.0.2", + "forever-agent": "~0.6.1", + "form-data": "~2.3.2", + "har-validator": "~5.1.3", + "http-signature": "~1.2.0", + "is-typedarray": "~1.0.0", + "isstream": "~0.1.2", + "json-stringify-safe": "~5.0.1", + "mime-types": "~2.1.19", + "oauth-sign": "~0.9.0", + "performance-now": "^2.1.0", + "qs": "~6.5.2", + "safe-buffer": "^5.1.2", + "tough-cookie": "~2.5.0", + "tunnel-agent": "^0.6.0", + "uuid": "^3.3.2" + } + }, + "require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "dev": true + }, + "sass-graph": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/sass-graph/-/sass-graph-2.2.5.tgz", + "integrity": "sha512-VFWDAHOe6mRuT4mZRd4eKE+d8Uedrk6Xnh7Sh9b4NGufQLQjOrvf/MQoOdx+0s92L89FeyUUNfU597j/3uNpag==", + "dev": true, + "requires": { + "glob": "^7.0.0", + "lodash": "^4.0.0", + "scss-tokenizer": "^0.2.3", + "yargs": "^13.3.2" + } + }, + "string-width": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-3.1.0.tgz", + "integrity": "sha512-vafcv6KjVZKSgz06oM/H6GDBrAtz8vdhQakGjFIvNrHA6y3HCF1CInLy+QLq8dTJPQ1b+KDUqDFctkdRW44e1w==", + "dev": true, + "requires": { + "emoji-regex": "^7.0.1", + "is-fullwidth-code-point": "^2.0.0", + "strip-ansi": "^5.1.0" + } + }, + "strip-ansi": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-5.2.0.tgz", + "integrity": "sha512-DuRs1gKbBqsMKIZlrffwlug8MHkcnpjs5VPmL1PAh+mA30U0DTotfDZ0d2UUsXpPmPmMMJ6W773MaA3J+lbiWA==", + "dev": true, + "requires": { + "ansi-regex": "^4.1.0" + } + }, + "tough-cookie": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-2.5.0.tgz", + "integrity": "sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==", + "dev": true, + "requires": { + "psl": "^1.1.28", + "punycode": "^2.1.1" + } + }, + "which-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.0.tgz", + "integrity": "sha1-2e8H3Od7mQK4o6j6SzHD4/fm6Ho=", + "dev": true + }, + "wrap-ansi": { + "version": "5.1.0", + "resolved": "http://registry.npmjs.org/wrap-ansi/-/wrap-ansi-5.1.0.tgz", + "integrity": "sha512-QC1/iN/2/RPVJ5jYK8BGttj5z83LmSKmvbvrXPNCLZSEb32KKVDJDl/MOt2N01qU2H/FkzEa9PKto1BqDjtd7Q==", + "dev": true, + "requires": { + "ansi-styles": "^3.2.0", + "string-width": "^3.0.0", + "strip-ansi": "^5.0.0" + } + }, + "y18n": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.0.tgz", + "integrity": "sha512-r9S/ZyXu/Xu9q1tYlpsLIsa3EeLXXk0VwlxqTcFRfg9EhMW+17kbt9G0NrgCmhGb5vT2hyhJZLfDGx+7+5Uj/w==", + "dev": true + }, + "yargs": { + "version": "13.3.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-13.3.2.tgz", + "integrity": "sha512-AX3Zw5iPruN5ie6xGRIDgqkT+ZhnRlZMLMHAs8tg7nRruy2Nb+i5o9bwghAogtM08q1dpr2LVoS8KSTMYpWXUw==", + "dev": true, + "requires": { + "cliui": "^5.0.0", + "find-up": "^3.0.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^3.0.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^13.1.2" + } + }, + "yargs-parser": { + "version": "13.1.2", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-13.1.2.tgz", + "integrity": "sha512-3lbsNRf/j+A4QuSZfDRA7HRSfWrzO0YjqTJd5kjAq37Zep1CEgaYmrH9Q3GwPiB9cHyd1Y1UwggGhJGoxipbzg==", + "dev": true, + "requires": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + } } } }, @@ -6154,15 +6413,6 @@ "integrity": "sha1-/7xJiDNuDoM94MFox+8VISGqf7M=", "dev": true }, - "os-locale": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/os-locale/-/os-locale-1.4.0.tgz", - "integrity": "sha1-IPnxeuKe00XoveWDsT0gCYA8FNk=", - "dev": true, - "requires": { - "lcid": "^1.0.0" - } - }, "os-tmpdir": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/os-tmpdir/-/os-tmpdir-1.0.2.tgz", @@ -6507,6 +6757,12 @@ "integrity": "sha1-8FKijacOYYkX7wqKw0wa5aaChrM=", "dev": true }, + "psl": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/psl/-/psl-1.8.0.tgz", + "integrity": "sha512-RIdOzyoavK+hA18OGGWDqUTsCLhtA7IcZ/6NCs4fFJaHBDab+pDDmDIByWFRQJq2Cd7r1OoQxBGKOaztq+hjIQ==", + "dev": true + }, "public-encrypt": { "version": "4.0.2", "resolved": "http://registry.npmjs.org/public-encrypt/-/public-encrypt-4.0.2.tgz", @@ -6976,18 +7232,6 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "dev": true }, - "sass-graph": { - "version": "2.2.4", - "resolved": "https://registry.npmjs.org/sass-graph/-/sass-graph-2.2.4.tgz", - "integrity": "sha1-E/vWPNHK8JCLn9k0dq1DpR0eC0k=", - "dev": true, - "requires": { - "glob": "^7.0.0", - "lodash": "^4.0.0", - "scss-tokenizer": "^0.2.3", - "yargs": "^7.0.0" - } - }, "scss-tokenizer": { "version": "0.2.3", "resolved": "https://registry.npmjs.org/scss-tokenizer/-/scss-tokenizer-0.2.3.tgz", @@ -8316,12 +8560,6 @@ "isexe": "^2.0.0" } }, - "which-module": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/which-module/-/which-module-1.0.0.tgz", - "integrity": "sha1-u6Y8qGGUiZT/MHc2CJ47lgJsKk8=", - "dev": true - }, "wide-align": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.3.tgz", @@ -8407,72 +8645,6 @@ "resolved": "https://registry.npmjs.org/yallist/-/yallist-2.1.2.tgz", "integrity": "sha1-HBH5IY8HYImkfdUS+TxmmaaoHVI=", "dev": true - }, - "yargs": { - "version": "7.1.0", - "resolved": "https://registry.npmjs.org/yargs/-/yargs-7.1.0.tgz", - "integrity": "sha1-a6MY6xaWFyf10oT46gA+jWFU0Mg=", - "dev": true, - "requires": { - "camelcase": "^3.0.0", - "cliui": "^3.2.0", - "decamelize": "^1.1.1", - "get-caller-file": "^1.0.1", - "os-locale": "^1.4.0", - "read-pkg-up": "^1.0.1", - "require-directory": "^2.1.1", - "require-main-filename": "^1.0.1", - "set-blocking": "^2.0.0", - "string-width": "^1.0.2", - "which-module": "^1.0.0", - "y18n": "^3.2.1", - "yargs-parser": "^5.0.0" - }, - "dependencies": { - "camelcase": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-3.0.0.tgz", - "integrity": "sha1-MvxLn82vhF/N9+c7uXysImHwqwo=", - "dev": true - }, - "is-fullwidth-code-point": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-1.0.0.tgz", - "integrity": "sha1-754xOG8DGn8NZDr4L95QxFfvAMs=", - "dev": true, - "requires": { - "number-is-nan": "^1.0.0" - } - }, - "string-width": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-1.0.2.tgz", - "integrity": "sha1-EYvfW4zcUaKn5w0hHgfisLmxB9M=", - "dev": true, - "requires": { - "code-point-at": "^1.0.0", - "is-fullwidth-code-point": "^1.0.0", - "strip-ansi": "^3.0.0" - } - } - } - }, - "yargs-parser": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-5.0.0.tgz", - "integrity": "sha1-J17PDX/+Bcd+ZOfIbkzZS/DhIoo=", - "dev": true, - "requires": { - "camelcase": "^3.0.0" - }, - "dependencies": { - "camelcase": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-3.0.0.tgz", - "integrity": "sha1-MvxLn82vhF/N9+c7uXysImHwqwo=", - "dev": true - } - } } } } From e8b6027c85bc6e9911de07960b485aae6fc010d3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 29 Oct 2020 08:32:57 +0000 Subject: [PATCH 139/147] Bump tar from 2.2.1 to 2.2.2 (#183) Bumps [tar](https://github.com/npm/node-tar) from 2.2.1 to 2.2.2. - [Release notes](https://github.com/npm/node-tar/releases) - [Changelog](https://github.com/npm/node-tar/blob/master/CHANGELOG.md) - [Commits](https://github.com/npm/node-tar/compare/v2.2.1...v2.2.2) Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package-lock.json | 76 +++++++++++++++++++++++++++++++++++------------ 1 file changed, 57 insertions(+), 19 deletions(-) diff --git a/package-lock.json b/package-lock.json index 37c9b044..483fbbf9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3026,9 +3026,7 @@ }, "chownr": { "version": "1.0.1", - "bundled": true, - "dev": true, - "optional": true + "bundled": true }, "code-point-at": { "version": "1.1.0", @@ -3205,7 +3203,6 @@ "minipass": { "version": "2.2.4", "bundled": true, - "dev": true, "optional": true, "requires": { "safe-buffer": "^5.1.1", @@ -3215,8 +3212,6 @@ "minizlib": { "version": "1.1.0", "bundled": true, - "dev": true, - "optional": true, "requires": { "minipass": "^2.2.1" } @@ -3405,7 +3400,6 @@ "safe-buffer": { "version": "5.1.1", "bundled": true, - "dev": true, "optional": true }, "safer-buffer": { @@ -3474,18 +3468,63 @@ "optional": true }, "tar": { - "version": "4.4.1", - "bundled": true, + "version": "4.4.13", + "resolved": "https://registry.npmjs.org/tar/-/tar-4.4.13.tgz", + "integrity": "sha512-w2VwSrBoHa5BsSyH+KxEqeQBAllHhccyMFVHtGtdMpF4W7IRWfZjFiQceJPChOeTsSDVUpER2T8FA93pr0L+QA==", "dev": true, "optional": true, "requires": { - "chownr": "^1.0.1", + "chownr": "^1.1.1", "fs-minipass": "^1.2.5", - "minipass": "^2.2.4", - "minizlib": "^1.1.0", + "minipass": "^2.8.6", + "minizlib": "^1.2.1", "mkdirp": "^0.5.0", - "safe-buffer": "^5.1.1", - "yallist": "^3.0.2" + "safe-buffer": "^5.1.2", + "yallist": "^3.0.3" + }, + "dependencies": { + "chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "dev": true, + "optional": true + }, + "minipass": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-2.9.0.tgz", + "integrity": "sha512-wxfUjg9WebH+CUDX/CdbRlh5SmfZiy/hpkxaRI16Y9W56Pa75sWgd/rvFilSgrauD9NyFymP/+JFV3KwzIsJeg==", + "dev": true, + "optional": true, + "requires": { + "safe-buffer": "^5.1.2", + "yallist": "^3.0.0" + } + }, + "minizlib": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-1.3.3.tgz", + "integrity": "sha512-6ZYMOEnmVsdCeTJVE0W9ZD+pVnE8h9Hma/iOwwRDsdQoePpoX56/8B6z3P9VNwppJuBKNRuFDRNRqRWexT9G9Q==", + "dev": true, + "optional": true, + "requires": { + "minipass": "^2.9.0" + } + }, + "safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "dev": true, + "optional": true + }, + "yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "optional": true + } } }, "util-deprecate": { @@ -3512,7 +3551,6 @@ "yallist": { "version": "3.0.2", "bundled": true, - "dev": true, "optional": true } } @@ -7786,13 +7824,13 @@ "dev": true }, "tar": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/tar/-/tar-2.2.1.tgz", - "integrity": "sha1-jk0qJWwOIYXGsYrWlK7JaLg8sdE=", + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/tar/-/tar-2.2.2.tgz", + "integrity": "sha512-FCEhQ/4rE1zYv9rYXJw/msRqsnmlje5jHP6huWeBZ704jUTy02c5AZyWujpMR1ax6mVw9NyJMfuK2CMDWVIfgA==", "dev": true, "requires": { "block-stream": "*", - "fstream": "^1.0.2", + "fstream": "^1.0.12", "inherits": "2" } }, From ec9afd524e88cd7b3a49bcc6834e812568862fc2 Mon Sep 17 00:00:00 2001 From: Aiky30 Date: Thu, 29 Oct 2020 14:38:39 +0000 Subject: [PATCH 140/147] Release 1.0.26 (#184) --- djangocms_moderation/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/djangocms_moderation/__init__.py b/djangocms_moderation/__init__.py index 15c09081..e5bff94d 100644 --- a/djangocms_moderation/__init__.py +++ b/djangocms_moderation/__init__.py @@ -1,3 +1,3 @@ -__version__ = "1.0.25" +__version__ = "1.0.26" default_app_config = "djangocms_moderation.apps.ModerationConfig" From 2ab6deb79c6baae1af1db163330f9bfe1235d567 Mon Sep 17 00:00:00 2001 From: Aiky30 Date: Wed, 10 Mar 2021 16:52:19 +0000 Subject: [PATCH 141/147] Fix inconsistent moderation request publishing states (#187) * Implemented a management command to fix corrupted ModerationCollection states * Fix failing test suite * Wrapped the entire publish view in a transaction * Added version locking which should be optional but now appears to be incorrectly enforced * Added documentation --- djangocms_moderation/admin.py | 4 +- djangocms_moderation/management/__init__.py | 0 .../management/commands/__init__.py | 0 .../commands/moderation_fix_states.py | 51 +++++++ docs/index.rst | 1 + docs/management_commands.rst | 35 +++++ setup.py | 12 -- tests/requirements.txt | 6 +- tests/test_admin_actions.py | 6 +- tests/test_management_commands.py | 138 ++++++++++++++++++ 10 files changed, 235 insertions(+), 18 deletions(-) create mode 100644 djangocms_moderation/management/__init__.py create mode 100644 djangocms_moderation/management/commands/__init__.py create mode 100644 djangocms_moderation/management/commands/moderation_fix_states.py create mode 100644 docs/management_commands.rst create mode 100644 tests/test_management_commands.py diff --git a/djangocms_moderation/admin.py b/djangocms_moderation/admin.py index a47a7e70..4dd417c8 100644 --- a/djangocms_moderation/admin.py +++ b/djangocms_moderation/admin.py @@ -565,9 +565,9 @@ def resubmit_view(self, request): ) return HttpResponseRedirect(redirect_url) + @transaction.atomic def published_view(self, request): collection_id = request.GET.get('collection_id') - treenodes = self._get_selected_tree_nodes(request) redirect_url = self._redirect_to_changeview_url(collection_id) try: @@ -586,6 +586,8 @@ def published_view(self, request): context, ) else: + treenodes = self._get_selected_tree_nodes(request) + published_moderation_requests = [] for node in treenodes.all(): mr = node.moderation_request diff --git a/djangocms_moderation/management/__init__.py b/djangocms_moderation/management/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/djangocms_moderation/management/commands/__init__.py b/djangocms_moderation/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/djangocms_moderation/management/commands/moderation_fix_states.py b/djangocms_moderation/management/commands/moderation_fix_states.py new file mode 100644 index 00000000..b4bd3c0f --- /dev/null +++ b/djangocms_moderation/management/commands/moderation_fix_states.py @@ -0,0 +1,51 @@ +from django.core.management.base import BaseCommand, CommandError + +from djangocms_versioning import constants as versioning_constants + +from djangocms_moderation import constants as moderation_constants +from djangocms_moderation.models import ModerationRequest + + +class Command(BaseCommand): + help = "Repair any ModerationRequest objects that are left in an un-consistent state." + + def add_arguments(self, parser): + # Named (optional) arguments + parser.add_argument( + "--perform-fix", + action="store_true", + help="Perform the fix and commit any changes", + ) + + def handle(self, *args, **options): + self.stdout.write("Running Moderation Fix States command") + + # Find all objects that are left in an inconsistent state + items = ModerationRequest.objects.filter( + is_active=True, + collection__status=moderation_constants.ARCHIVED, + version__state=versioning_constants.PUBLISHED, + ) + + items_found = items.count() + if not items_found: + self.stdout.write(self.style.SUCCESS("No inconsistent ModerationRequest objects found")) + return + + self.stdout.write(self.style.WARNING("Inconsistent ModerationRequest objects found: %s" % items.count())) + for request in items: + self.stdout.write("Found ModerationRequest id: %s" % request.id) + + if not options.get('perform_fix'): + self.stdout.write(self.style.SUCCESS( + "Finished without making any changes. To make changes run this command with: --perform-fix")) + return + + # Perform cleanup operations + self.stdout.write("Performing cleanup of inconsistent ModerationRequest object states") + for request in items: + request.is_active = False + request.save() + self.stdout.write("Repaired ModerationRequest id: %s" % request.id) + + self.stdout.write(self.style.SUCCESS("Finished and made the changes successfully.")) diff --git a/docs/index.rst b/docs/index.rst index adfa08a3..7697b839 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -29,6 +29,7 @@ Welcome to djangocms-moderation's documentation! introduction internals + management_commands signals diff --git a/docs/management_commands.rst b/docs/management_commands.rst new file mode 100644 index 00000000..f19bf488 --- /dev/null +++ b/docs/management_commands.rst @@ -0,0 +1,35 @@ +.. _management_commands: + +Management Commands +================================================ +The commands made available to developers should be used with caution, be sure that you know what you are doing. + +moderation_fix_states +------------------------------------------------- +This command is to be used only when a version becomes un-editable due to inconsistencies with states that are controlled by moderation. +It has been observed that the state can end up inconsistent in very rare scenarios. A `ModerationRequest` object should never have `is_active=True` when the item has been successfully published. +The following states can cause a version to be locked from editing: + + - `ModerationRequest.is_active=True` + - `ModerationRequest.version.state=published` + - `ModerationRequest.collection.state=Archived` + +In this scenario a new Draft object cannot be created from the Published object due to version checks. + +The command will first analyse and list any `ModerationRequest` objects that are in a broken / inconsistent state. + +The fix will correctly set the is_active flag leaving the correct states: + + - `ModerationRequest.is_active=False` + - `ModerationRequest.version.state=published` + - `ModerationRequest.collection.state=Archived` + +Usage +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ +To first run an analysis on whether any `ModerationRequest` objects have a broken / inconsistent state. + +``python manage.py moderation_fix_states`` + +To execute and resolve any state inconsistencies, you can run the command with the `--perform-fix` flag set. + +``python manage.py moderation_fix_states --perform-fix`` diff --git a/setup.py b/setup.py index 2257a598..331b4081 100644 --- a/setup.py +++ b/setup.py @@ -10,11 +10,6 @@ "django-admin-sortable2>=0.6.4", ] -TEST_REQUIREMENTS = [ - "djangocms-version-locking", - "djangocms-versioning", -] - setup( name="djangocms-moderation", packages=find_packages(), @@ -34,12 +29,5 @@ author_email="info@divio.ch", url="http://github.com/divio/djangocms-moderation", license="BSD", - tests_require=TEST_REQUIREMENTS, test_suite="tests.settings.run", - dependency_links=[ - "https://github.com/divio/django-cms/tarball/release/4.0.x#egg=django-cms-4.0.0", - "https://github.com/divio/djangocms-versioning/tarball/master#egg=djangocms-versioning-0.0.23", - "https://github.com/FidelityInternational/djangocms-version-locking/tarball/master#egg=djangocms-version-locking-0.0.13", # noqa - "https://github.com/divio/djangocms-text-ckeditor/tarball/support/4.0.x#egg=djangocms-text-ckeditor-4.0.x", - ] ) diff --git a/tests/requirements.txt b/tests/requirements.txt index 622c5b90..6c2f15df 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -15,7 +15,9 @@ cachetools django-filer<2.0.0 django-classy-tags<2.0.0 django-sekizai<2.0.0 -# Please note that aldryn-forms version 5 can only be used with django >2 and deprecates <2 completely. Version 5 is only available on master, not yet on pypi. -https://github.com/divio/aldryn-forms/archive/master.zip +# Please note that aldryn-forms version 5 can only be used with django >2 and deprecates <2 completely. +aldryn-forms<6.0.0 # Get the lastest cms v4 compatible djangocms-text-ckeditor https://github.com/divio/djangocms-text-ckeditor/tarball/support/4.0.x#egg=djangocms-text-ckeditor +https://github.com/divio/djangocms-versioning/tarball/master#egg=djangocms-versioning +https://github.com/FidelityInternational/djangocms-version-locking/tarball/master#egg=djangocms-version-locking diff --git a/tests/test_admin_actions.py b/tests/test_admin_actions.py index 14464f3d..0114882c 100644 --- a/tests/test_admin_actions.py +++ b/tests/test_admin_actions.py @@ -572,8 +572,7 @@ def setUp(self): id=2, collection=self.collection) self.root1 = factories.RootModerationRequestTreeNodeFactory( id=4, moderation_request=self.moderation_request1) - factories.ChildModerationRequestTreeNodeFactory( - id=5, moderation_request=self.moderation_request2, parent=self.root1) + self.root2 = factories.RootModerationRequestTreeNodeFactory( id=6, moderation_request=self.moderation_request2) # Request 1 is approved, request 2 is started @@ -595,6 +594,7 @@ def setUp(self): self.assertTrue(self.moderation_request2.is_active) self.assertEqual(self.moderation_request1.version.state, DRAFT) self.assertEqual(self.moderation_request2.version.state, DRAFT) + self.assertTrue(self.moderation_request1.is_approved()) @mock.patch("django.contrib.messages.success") def test_publish_selected_publishes_approved_request(self, messages_mock): @@ -650,7 +650,7 @@ def test_signal_when_published(self): @unittest.skip("Skip until collection status bugs fixed") @mock.patch("django.contrib.messages.success") def test_publish_selected_sets_collection_to_archived_if_all_requests_published(self, messages_mock): - # Make sure both moderation requests have been approved + # Won't work because the approved_view sets the ARCHIVED state prior to how this test is setup self.moderation_request2.update_status(constants.ACTION_APPROVED, self.role1.user) self.moderation_request2.update_status(constants.ACTION_APPROVED, self.role2.user) diff --git a/tests/test_management_commands.py b/tests/test_management_commands.py new file mode 100644 index 00000000..3d7bc058 --- /dev/null +++ b/tests/test_management_commands.py @@ -0,0 +1,138 @@ +from io import StringIO + +from django.core.management import call_command + +from cms.test_utils.testcases import CMSTestCase + +from djangocms_moderation import constants +from djangocms_moderation.models import Role + +from .utils import factories + + +class FixStatesTestCase(CMSTestCase): + def setUp(self): + self.user = factories.UserFactory(is_staff=True, is_superuser=True) + self.role1 = Role.objects.create(name="Role 1", user=self.user) + # Collection 1 + self.collection1 = factories.ModerationCollectionFactory( + author=self.user, status=constants.IN_REVIEW) + self.collection1.workflow.steps.create(role=self.role1, is_required=True, order=1) + self.collection1_moderation_request1 = factories.ModerationRequestFactory(collection=self.collection1) + factories.RootModerationRequestTreeNodeFactory( + moderation_request=self.collection1_moderation_request1) + self.collection1_moderation_request2 = factories.ModerationRequestFactory(collection=self.collection1) + factories.RootModerationRequestTreeNodeFactory( + moderation_request=self.collection1_moderation_request2) + # Collection 2 + self.collection2 = factories.ModerationCollectionFactory( + author=self.user, status=constants.IN_REVIEW) + self.collection2.workflow.steps.create(role=self.role1, is_required=True, order=1) + self.collection2_moderation_request1 = factories.ModerationRequestFactory(collection=self.collection2) + factories.RootModerationRequestTreeNodeFactory( + moderation_request=self.collection2_moderation_request1) + self.collection2_moderation_request2 = factories.ModerationRequestFactory(collection=self.collection2) + factories.RootModerationRequestTreeNodeFactory( + moderation_request=self.collection2_moderation_request2) + + # Simulate approval of collection 1 + self.collection1_moderation_request1.actions.create(by_user=self.user, action=constants.ACTION_STARTED) + self.collection1_moderation_request2.actions.create(by_user=self.user, action=constants.ACTION_STARTED) + self.collection1_moderation_request1.update_status(constants.ACTION_FINISHED, self.role1.user) + self.collection1_moderation_request2.update_status(constants.ACTION_FINISHED, self.role1.user) + # Simulate archiving + self.collection1.status = constants.ARCHIVED + self.collection1.save() + # Simulate publishing + self.collection1_moderation_request1.version.publish(self.user) + self.collection1_moderation_request2.version.publish(self.user) + # Simulate approval of collection 2 + self.collection2_moderation_request1.actions.create(by_user=self.user, action=constants.ACTION_STARTED) + self.collection2_moderation_request2.actions.create(by_user=self.user, action=constants.ACTION_STARTED) + self.collection2_moderation_request1.update_status(constants.ACTION_FINISHED, self.role1.user) + self.collection2_moderation_request2.update_status(constants.ACTION_FINISHED, self.role1.user) + # Simulate archiving + self.collection2.status = constants.ARCHIVED + self.collection2.save() + # Simulate publishing + self.collection2_moderation_request1.version.publish(self.user) + self.collection2_moderation_request2.version.publish(self.user) + + # sanity check the test setup + self.assertEqual(self.collection1.status, constants.ARCHIVED) + self.assertEqual(self.collection2.status, constants.ARCHIVED) + + def test_command_output_with_no_corrupted_states_dry_run(self): + out = StringIO() + call_command("moderation_fix_states", stdout=out) + + self.assertIn("Running Moderation Fix States command", out.getvalue()) + self.assertIn("No inconsistent ModerationRequest objects found", out.getvalue()) + + out = StringIO() + call_command("moderation_fix_states", stdout=out) + + self.assertIn("No inconsistent ModerationRequest objects found", out.getvalue()) + + def test_command_output_with_no_corrupted_states(self): + out = StringIO() + call_command("moderation_fix_states", "--perform-fix", stdout=out) + + self.assertIn("Running Moderation Fix States command", out.getvalue()) + self.assertIn("No inconsistent ModerationRequest objects found", out.getvalue()) + + out = StringIO() + call_command("moderation_fix_states", "--perform-fix", stdout=out) + + self.assertIn("No inconsistent ModerationRequest objects found", out.getvalue()) + + def test_command_output_with_corrupted_states_dry_run(self): + out = StringIO() + call_command("moderation_fix_states", stdout=out) + + self.assertIn("Running Moderation Fix States command", out.getvalue()) + self.assertIn("No inconsistent ModerationRequest objects found", out.getvalue()) + + # Create a corrupt setup + # Force the corruption seen in rare circumstances where is_active is left as True + # when the version is published and the Collection is Archived + self.collection2_moderation_request2.is_active = True + self.collection2_moderation_request2.save() + + out = StringIO() + call_command("moderation_fix_states", stdout=out) + + self.assertIn("ModerationRequest objects found: 1", out.getvalue()) + self.assertIn("Finished without making any changes", out.getvalue()) + + # Repeating the command should show no changes have been made and the same objects are found + out = StringIO() + call_command("moderation_fix_states", stdout=out) + + self.assertIn("ModerationRequest objects found: 1", out.getvalue()) + self.assertIn("Finished without making any changes", out.getvalue()) + + def test_command_output_with_corrupted_states(self): + out = StringIO() + call_command("moderation_fix_states", "--perform-fix", stdout=out) + + self.assertIn("Running Moderation Fix States command", out.getvalue()) + self.assertIn("No inconsistent ModerationRequest objects found", out.getvalue()) + + # Create a corrupt setup + # Force the corruption seen in rare circumstances where is_active is left as True + # when the version is published and the Collection is Archived + self.collection2_moderation_request2.is_active = True + self.collection2_moderation_request2.save() + + out = StringIO() + call_command("moderation_fix_states", "--perform-fix", stdout=out) + + self.assertIn("ModerationRequest objects found: 1", out.getvalue()) + self.assertIn("Repaired ModerationRequest id: %s" % self.collection2_moderation_request2.id, out.getvalue()) + + # Verify fixes + out = StringIO() + call_command("moderation_fix_states", "--perform-fix", stdout=out) + + self.assertIn("No inconsistent ModerationRequest objects found", out.getvalue()) From f708cdd04aaef1090d5973598f0a60c6c653ccf5 Mon Sep 17 00:00:00 2001 From: Aiky30 Date: Wed, 10 Mar 2021 18:40:30 +0000 Subject: [PATCH 142/147] Release 1.0.27 (#188) --- CHANGELOG.rst | 11 +++++++++++ djangocms_moderation/__init__.py | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) create mode 100644 CHANGELOG.rst diff --git a/CHANGELOG.rst b/CHANGELOG.rst new file mode 100644 index 00000000..4a47425b --- /dev/null +++ b/CHANGELOG.rst @@ -0,0 +1,11 @@ +========= +Changelog +========= + +Unreleased +========== + +1.0.27 (2021-03-10) +================== +* Wrapped the publish view logic in a transaction to prevent inconsistent ModerationRequest states in the future. +* Added a new management command: "moderation_fix_states" to repair any ModerationRequests left in an inconsistent state where the the is_active state is True, the ModerationRequest version object is published, and the collection is Archived. diff --git a/djangocms_moderation/__init__.py b/djangocms_moderation/__init__.py index e5bff94d..5dadc7f9 100644 --- a/djangocms_moderation/__init__.py +++ b/djangocms_moderation/__init__.py @@ -1,3 +1,3 @@ -__version__ = "1.0.26" +__version__ = "1.0.27" default_app_config = "djangocms_moderation.apps.ModerationConfig" From 4fd2fbd8609a51b112c6325dbdc6ae434d5e42a0 Mon Sep 17 00:00:00 2001 From: Mark Walker Date: Sun, 25 Apr 2021 22:13:43 +0100 Subject: [PATCH 143/147] Removed duplicate requirement (mock) and fixed `flake8` test --- .../management/commands/moderation_fix_states.py | 2 +- setup.cfg | 1 + tests/requirements.txt | 1 - 3 files changed, 2 insertions(+), 2 deletions(-) diff --git a/djangocms_moderation/management/commands/moderation_fix_states.py b/djangocms_moderation/management/commands/moderation_fix_states.py index b4bd3c0f..c6387b19 100644 --- a/djangocms_moderation/management/commands/moderation_fix_states.py +++ b/djangocms_moderation/management/commands/moderation_fix_states.py @@ -1,4 +1,4 @@ -from django.core.management.base import BaseCommand, CommandError +from django.core.management.base import BaseCommand from djangocms_versioning import constants as versioning_constants diff --git a/setup.cfg b/setup.cfg index 2b15f90f..1fe92aab 100644 --- a/setup.cfg +++ b/setup.cfg @@ -2,6 +2,7 @@ max-line-length = 120 exclude = .git, + .env, __pycache__, **/migrations/, build/, diff --git a/tests/requirements.txt b/tests/requirements.txt index 6c2f15df..1a89422c 100644 --- a/tests/requirements.txt +++ b/tests/requirements.txt @@ -3,7 +3,6 @@ coverage djangocms_helper django_polymorphic==2.0.3 cachetools -mock factory-boy django-simple-captcha python-dateutil>=2.4 From 1fd928e806a3dd0bef62c0652408e8526838db9e Mon Sep 17 00:00:00 2001 From: Aiky30 Date: Tue, 21 Sep 2021 14:08:10 +0100 Subject: [PATCH 144/147] feat: Provide configurable Moderation Request Changelist fields and actions (#194) --- CHANGELOG.rst | 3 +- djangocms_moderation/admin.py | 52 +++++++++++++--- djangocms_moderation/cms_config.py | 15 +++++ docs/moderation_integration.rst | 18 +++++- tests/test_admin.py | 74 +++++++++++++++++++++-- tests/utils/moderated_polls/cms_config.py | 15 ++++- 6 files changed, 158 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 4a47425b..0ce590af 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -4,8 +4,9 @@ Changelog Unreleased ========== +* Configuration options added to the cms_config to allow a third party to add fields and actions to the Moderation Request Changelist admin view. 1.0.27 (2021-03-10) -================== +=================== * Wrapped the publish view logic in a transaction to prevent inconsistent ModerationRequest states in the future. * Added a new management command: "moderation_fix_states" to repair any ModerationRequests left in an inconsistent state where the the is_active state is True, the ModerationRequest version object is published, and the collection is Archived. diff --git a/djangocms_moderation/admin.py b/djangocms_moderation/admin.py index 4dd417c8..2caa756e 100644 --- a/djangocms_moderation/admin.py +++ b/djangocms_moderation/admin.py @@ -1,6 +1,7 @@ from __future__ import unicode_literals from django import forms +from django.apps import apps from django.conf.urls import url from django.contrib import admin, messages from django.contrib.auth import get_user_model @@ -117,6 +118,7 @@ class ModerationRequestTreeAdmin(TreeAdmin): """ class Media: js = ("djangocms_moderation/js/actions.js",) + css = {"all": ("djangocms_moderation/css/actions.css",)} actions = [ # filtered out in `self.get_actions` delete_selected, @@ -158,6 +160,7 @@ def get_urls(self): ] + super().get_urls() def get_list_display(self, request): + additional_fields = self._get_configured_fields(request) list_display = [ 'get_id', 'get_content_type', @@ -166,11 +169,44 @@ def get_list_display(self, request): 'get_preview_link', 'get_status', 'get_reviewer', + *additional_fields, + 'list_display_actions', ] - if conf.REQUEST_COMMENTS_ENABLED: - list_display.append("get_comments_link") return list_display + def list_display_actions(self, obj): + """Display links to state change endpoints + """ + return format_html_join( + "", "{}", ((action(obj),) for action in self.get_list_display_actions()) + ) + + list_display_actions.short_description = _("actions") + + def get_list_display_actions(self): + actions = [] + if conf.REQUEST_COMMENTS_ENABLED: + actions.append(self.get_comments_link) + + # Get any configured additional actions + moderation_config = apps.get_app_config("djangocms_moderation") + additional_actions = moderation_config.cms_extension.moderation_request_changelist_actions + if additional_actions: + actions += additional_actions + + return actions + + def _get_configured_fields(self, request): + fields = [] + moderation_config = apps.get_app_config("djangocms_moderation") + additional_fields = moderation_config.cms_extension.moderation_request_changelist_fields + + for field in additional_fields: + fields.append(field.__name__) + setattr(self, field.__name__, field) + + return fields + def get_id(self, obj): return format_html( '{id}', @@ -255,14 +291,14 @@ def get_status(self, obj): return status def get_comments_link(self, obj): - return format_html( - '{}', - reverse('admin:djangocms_moderation_requestcomment_changelist'), + comments_endpoint = format_html( + "{}?moderation_request__id__exact={}", + reverse("admin:djangocms_moderation_requestcomment_changelist"), obj.moderation_request.id, - _('View') ) - - get_comments_link.short_description = _("Comments") + return render_to_string( + "djangocms_moderation/comment_icon.html", {"url": comments_endpoint} + ) def get_actions(self, request): """ diff --git a/djangocms_moderation/cms_config.py b/djangocms_moderation/cms_config.py index 8e6acfc1..8c0d7d6a 100644 --- a/djangocms_moderation/cms_config.py +++ b/djangocms_moderation/cms_config.py @@ -7,6 +7,14 @@ class ModerationExtension(CMSAppExtension): def __init__(self): self.moderated_models = [] + self.moderation_request_changelist_actions = [] + self.moderation_request_changelist_fields = [] + + def handle_moderation_request_changelist_actions(self, moderation_request_changelist_actions): + self.moderation_request_changelist_actions.extend(moderation_request_changelist_actions) + + def handle_moderation_request_changelist_fields(self, moderation_request_changelist_fields): + self.moderation_request_changelist_fields.extend(moderation_request_changelist_fields) def configure_app(self, cms_config): versioning_enabled = getattr(cms_config, "djangocms_versioning_enabled", False) @@ -17,9 +25,16 @@ def configure_app(self, cms_config): self.moderated_models.extend(moderated_models) + if hasattr(cms_config, "moderation_request_changelist_actions"): + self.handle_moderation_request_changelist_actions(cms_config.moderation_request_changelist_actions) + + if hasattr(cms_config, "moderation_request_changelist_fields"): + self.handle_moderation_request_changelist_fields(cms_config.moderation_request_changelist_fields) + class CoreCMSAppConfig(CMSAppConfig): djangocms_moderation_enabled = True djangocms_versioning_enabled = True moderated_models = [PageContent] versioning = [] + diff --git a/docs/moderation_integration.rst b/docs/moderation_integration.rst index 7d9f22ba..305bf9e1 100644 --- a/docs/moderation_integration.rst +++ b/docs/moderation_integration.rst @@ -19,11 +19,14 @@ Moderation depends on `Versioning Date: Mon, 18 Oct 2021 09:54:35 +0100 Subject: [PATCH 145/147] Release 1.0.28 (#195) --- CHANGELOG.rst | 5 +++++ djangocms_moderation/__init__.py | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 0ce590af..b20b62c0 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -4,7 +4,12 @@ Changelog Unreleased ========== + + +1.0.28 (2021-10-18) +=================== * Configuration options added to the cms_config to allow a third party to add fields and actions to the Moderation Request Changelist admin view. +* Flake8 code formatting error fixes 1.0.27 (2021-03-10) =================== diff --git a/djangocms_moderation/__init__.py b/djangocms_moderation/__init__.py index 5dadc7f9..51479893 100644 --- a/djangocms_moderation/__init__.py +++ b/djangocms_moderation/__init__.py @@ -1,3 +1,3 @@ -__version__ = "1.0.27" +__version__ = "1.0.28" default_app_config = "djangocms_moderation.apps.ModerationConfig" From ce89c1b94e03b0086c89041f389c9018379696ee Mon Sep 17 00:00:00 2001 From: Aiky30 Date: Wed, 24 Nov 2021 15:51:57 +0000 Subject: [PATCH 146/147] feat: GitHub actions integration (#198) --- .circleci/Dockerfile | 24 --- .circleci/config.yml | 179 ---------------------- .github/workflows/frontend.yml | 21 +++ .github/workflows/lint.yml | 38 +++++ .github/workflows/test.yml | 39 +++++ .gitignore | 1 + djangocms_moderation/admin_actions.py | 4 +- djangocms_moderation/apps.py | 2 +- djangocms_moderation/cms_config.py | 1 - setup.cfg | 4 +- tests/requirements.txt | 22 --- tests/requirements/dj11_cms40.txt | 9 ++ tests/requirements/dj22_cms40.txt | 7 + tests/requirements/requirements_base.txt | 17 ++ tests/utils/factories.py | 4 +- tests/utils/moderated_polls/cms_config.py | 2 + 16 files changed, 141 insertions(+), 233 deletions(-) delete mode 100644 .circleci/Dockerfile delete mode 100644 .circleci/config.yml create mode 100644 .github/workflows/frontend.yml create mode 100644 .github/workflows/lint.yml create mode 100644 .github/workflows/test.yml delete mode 100644 tests/requirements.txt create mode 100644 tests/requirements/dj11_cms40.txt create mode 100644 tests/requirements/dj22_cms40.txt create mode 100644 tests/requirements/requirements_base.txt diff --git a/.circleci/Dockerfile b/.circleci/Dockerfile deleted file mode 100644 index a48eb87f..00000000 --- a/.circleci/Dockerfile +++ /dev/null @@ -1,24 +0,0 @@ -ARG PYTHON_VERSION=3.6 -FROM circleci/python:$PYTHON_VERSION - -ENV NODE_VERSION=6.14.1 -RUN \ - curl -o- https://raw.githubusercontent.com/creationix/nvm/v0.33.8/install.sh | bash && \ - export NVM_DIR="$HOME/.nvm" && \ - [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" && \ - [ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion" && \ - nvm install $NODE_VERSION && \ - nvm use $NODE_VERSION && \ - npm config set spin false && \ - npm install -g gulp@3.9.0 - -ENV NODE_PATH=/home/circleci/.nvm/versions/node/v$NODE_VERSION/lib/node_modules \ - PATH=/home/circleci/.nvm/versions/node/v$NODE_VERSION/bin:$PATH - -RUN sudo apt-get install libenchant-dev - -WORKDIR /home/circleci/app/ -COPY . /home/circleci/app/ -RUN sudo chown -R circleci:circleci . -RUN sudo pip install tox -RUN npm install diff --git a/.circleci/config.yml b/.circleci/config.yml deleted file mode 100644 index 05b5842d..00000000 --- a/.circleci/config.yml +++ /dev/null @@ -1,179 +0,0 @@ -version: 2.0 - -py35default: &py35default - docker: - - image: circleci/python:3.5 - steps: - - setup_remote_docker: - docker_layer_caching: false - - checkout - - attach_workspace: - at: /tmp/images - - run: docker load -i /tmp/images/py35.tar || true - - run: docker run py35 tox -e $CIRCLE_STAGE - - -py36default: &py36default - docker: - - image: circleci/python:3.6 - steps: - - setup_remote_docker: - docker_layer_caching: false - - checkout - - attach_workspace: - at: /tmp/images - - run: docker load -i /tmp/images/py36.tar || true - - run: docker run py36 tox -e $CIRCLE_STAGE - -py37default: &py37default - docker: - - image: circleci/python:3.7 - steps: - - setup_remote_docker: - docker_layer_caching: false - - checkout - - attach_workspace: - at: /tmp/images - - run: docker load -i /tmp/images/py37.tar || true - - run: docker run py37 tox -e $CIRCLE_STAGE - - -py35_requires: &py35_requires - requires: - - py35_base - -py36_requires: &py36_requires - requires: - - py36_base - -py37_requires: &py37_requires - requires: - - py37_base - -jobs: - py37_base: - docker: - - image: circleci/python:3.7 - steps: - - checkout - - setup_remote_docker: - docker_layer_caching: false - - run: docker build -f .circleci/Dockerfile --build-arg PYTHON_VERSION=3.7 -t py37 . - - run: mkdir images - - run: docker save -o images/py37.tar py37 - - persist_to_workspace: - root: images - paths: py37.tar - - py36_base: - docker: - - image: circleci/python:3.6 - steps: - - checkout - - setup_remote_docker: - docker_layer_caching: false - - run: docker build -f .circleci/Dockerfile --build-arg PYTHON_VERSION=3.6 -t py36 . - - run: mkdir images - - run: docker save -o images/py36.tar py36 - - persist_to_workspace: - root: images - paths: py36.tar - - py35_base: - docker: - - image: circleci/python:3.5 - steps: - - checkout - - setup_remote_docker: - docker_layer_caching: false - - run: docker build -f .circleci/Dockerfile --build-arg PYTHON_VERSION=3.5 -t py35 . - - run: mkdir images - - run: docker save -o images/py35.tar py35 - - persist_to_workspace: - root: images - paths: py35.tar - - flake8: - <<: *py35default - isort: - <<: *py35default - eslint: - docker: - - image: circleci/python:3.5 - steps: - - setup_remote_docker: - docker_layer_caching: false - - checkout - - attach_workspace: - at: /tmp/images - - run: docker load -i /tmp/images/py35.tar || true - - run: docker run py35 gulp lint - py35-dj111-sqlite-cms4: - <<: *py35default - py36-dj111-sqlite-cms4: - <<: *py36default - py37-dj111-sqlite-cms4: - <<: *py37default - - py35-dj20-sqlite-cms4: - <<: *py35default - py36-dj20-sqlite-cms4: - <<: *py36default - py37-dj20-sqlite-cms4: - <<: *py37default - - py35-dj22-sqlite-cms4: - <<: *py35default - py36-dj22-sqlite-cms4: - <<: *py36default - py37-dj22-sqlite-cms4: - <<: *py37default - - -####################### - -workflows: - version: 2 - build: - jobs: - - py35_base - - py36_base - - py37_base - - flake8: - requires: - - py35_base - - isort: - requires: - - py35_base - - eslint: - requires: - - py35_base - - py35-dj111-sqlite-cms4: - requires: - - py35_base - - py36-dj111-sqlite-cms4: - requires: - - py36_base - - py37-dj111-sqlite-cms4: - requires: - - py37_base - - - py35-dj20-sqlite-cms4: - requires: - - py35_base - - py36-dj20-sqlite-cms4: - requires: - - py36_base - - py37-dj20-sqlite-cms4: - requires: - - py37_base - - - py35-dj22-sqlite-cms4: - requires: - - py35_base - - py36-dj22-sqlite-cms4: - requires: - - py36_base - - py37-dj22-sqlite-cms4: - requires: - - py37_base diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml new file mode 100644 index 00000000..4d88962e --- /dev/null +++ b/.github/workflows/frontend.yml @@ -0,0 +1,21 @@ +name: Frontend + +on: [push, pull_request] + +jobs: + eslint: + runs-on: ubuntu-latest + strategy: + matrix: + node-version: [6.x] + + steps: + - uses: actions/checkout@v2 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + - run: npm install + - run: npm install -g gulp@3.9.1 + - run: | + gulp lint diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 00000000..f541d708 --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,38 @@ +name: Lint + +on: [push, pull_request] + +jobs: + flake8: + name: flake8 + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: 3.8 + - name: Install flake8 + run: pip install --upgrade flake8 + - name: Run flake8 + uses: liskin/gh-problem-matcher-wrap@v1 + with: + linters: flake8 + run: flake8 + + isort: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: 3.8 + - run: python -m pip install isort + - name: isort + uses: liskin/gh-problem-matcher-wrap@v1 + with: + linters: isort + run: isort -c -rc -df ./ diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 00000000..63a69058 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,39 @@ +name: CodeCov + +on: [push, pull_request] + +jobs: + unit-tests: + runs-on: ubuntu-latest + env: + ENABLE_VERSIONING: 1 + strategy: + fail-fast: false + matrix: + python-version: [ 3.6, 3.7, 3.8, ] # latest release minus two + requirements-file: [ + dj11_cms40.txt, + dj22_cms40.txt, + ] + os: [ + ubuntu-20.04, + ] + + steps: + - uses: actions/checkout@v1 + - name: Set up Python ${{ matrix.python-version }} + + uses: actions/setup-python@v2 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r tests/requirements/${{ matrix.requirements-file }} + python setup.py install + + - name: Run coverage + run: coverage run setup.py test + + - name: Upload Coverage to Codecov + uses: codecov/codecov-action@v1 diff --git a/.gitignore b/.gitignore index 8b8a0d67..53b9b881 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ dist/ build/ .env +venv *.sqlite .coverage .python-version diff --git a/djangocms_moderation/admin_actions.py b/djangocms_moderation/admin_actions.py index e09a2388..c5ae2aef 100644 --- a/djangocms_moderation/admin_actions.py +++ b/djangocms_moderation/admin_actions.py @@ -11,9 +11,9 @@ from cms.utils.urlutils import add_url_parameters +from django_fsm import TransitionNotAllowed from djangocms_versioning.models import Version -from django_fsm import TransitionNotAllowed from djangocms_moderation import constants from .utils import get_admin_url @@ -109,7 +109,7 @@ def convert_queryset_to_version_queryset(queryset): if model is None: model = obj._meta.model - from django.db.models.base import ModelBase, Model + from django.db.models.base import Model, ModelBase model_bases = [ModelBase, Model] if hasattr(model, "polymorphic_ctype_id"): diff --git a/djangocms_moderation/apps.py b/djangocms_moderation/apps.py index 588139f7..a7dda494 100644 --- a/djangocms_moderation/apps.py +++ b/djangocms_moderation/apps.py @@ -10,5 +10,5 @@ class ModerationConfig(AppConfig): def ready(self): import djangocms_moderation.handlers # noqa: F401 - import djangocms_moderation.signals # noqa: F401 import djangocms_moderation.monkeypatch # noqa: F401 + import djangocms_moderation.signals # noqa: F401 diff --git a/djangocms_moderation/cms_config.py b/djangocms_moderation/cms_config.py index 8c0d7d6a..6dfba873 100644 --- a/djangocms_moderation/cms_config.py +++ b/djangocms_moderation/cms_config.py @@ -37,4 +37,3 @@ class CoreCMSAppConfig(CMSAppConfig): djangocms_versioning_enabled = True moderated_models = [PageContent] versioning = [] - diff --git a/setup.cfg b/setup.cfg index 1fe92aab..5a79ae38 100644 --- a/setup.cfg +++ b/setup.cfg @@ -17,12 +17,12 @@ combine_as_imports = true include_trailing_comma = true balanced_wrapping = true skip = manage.py, migrations, .tox, node_modules -known_standard_library = mock +extra_standard_library = mock known_django = django known_cms = cms, menus known_third_party = djangocms_versioning known_first_party = djangocms_moderation -sections = FUTURE, STDLIB, DJANGO, CMS, THIRDPARTY, FIRSTPARTY, LIB, LOCALFOLDER +sections = FUTURE, STDLIB, DJANGO, CMS, THIRDPARTY, FIRSTPARTY, LOCALFOLDER [coverage:run] branch = True diff --git a/tests/requirements.txt b/tests/requirements.txt deleted file mode 100644 index 1a89422c..00000000 --- a/tests/requirements.txt +++ /dev/null @@ -1,22 +0,0 @@ -tox -coverage -djangocms_helper -django_polymorphic==2.0.3 -cachetools -factory-boy -django-simple-captcha -python-dateutil>=2.4 -pyflakes>=2.1.1 -flake8 -isort -mock -cachetools -django-filer<2.0.0 -django-classy-tags<2.0.0 -django-sekizai<2.0.0 -# Please note that aldryn-forms version 5 can only be used with django >2 and deprecates <2 completely. -aldryn-forms<6.0.0 -# Get the lastest cms v4 compatible djangocms-text-ckeditor -https://github.com/divio/djangocms-text-ckeditor/tarball/support/4.0.x#egg=djangocms-text-ckeditor -https://github.com/divio/djangocms-versioning/tarball/master#egg=djangocms-versioning -https://github.com/FidelityInternational/djangocms-version-locking/tarball/master#egg=djangocms-version-locking diff --git a/tests/requirements/dj11_cms40.txt b/tests/requirements/dj11_cms40.txt new file mode 100644 index 00000000..6019903a --- /dev/null +++ b/tests/requirements/dj11_cms40.txt @@ -0,0 +1,9 @@ +-r requirements_base.txt + +Django>=1.11,<2.0 +django-admin-sortable2<1.0.0 +django-filer<2.0.0 +django-classy-tags<2.0.0 +django-sekizai<2.0.0 +# Please note that aldryn-forms version 5 can only be used with django >2 and deprecates <2 completely. +aldryn-forms<6.0.0 diff --git a/tests/requirements/dj22_cms40.txt b/tests/requirements/dj22_cms40.txt new file mode 100644 index 00000000..4ff11633 --- /dev/null +++ b/tests/requirements/dj22_cms40.txt @@ -0,0 +1,7 @@ +-r requirements_base.txt + +Django>=2.2,<3.0 +django-filer +django-classy-tags +django-sekizai +aldryn-forms diff --git a/tests/requirements/requirements_base.txt b/tests/requirements/requirements_base.txt new file mode 100644 index 00000000..a3789121 --- /dev/null +++ b/tests/requirements/requirements_base.txt @@ -0,0 +1,17 @@ +cachetools +coverage +django_polymorphic==2.0.3 +django-simple-captcha +djangocms_helper +factory-boy +flake8 +isort +mock +pyflakes>=2.1.1 +python-dateutil>=2.4 + +# Unreleased django-cms 4.0 compatible packages +https://github.com/django-cms/django-cms/tarball/release/4.0.x#egg=django-cms +https://github.com/django-cms/djangocms-text-ckeditor/tarball/support/4.0.x#egg=djangocms-text-ckeditor +https://github.com/django-cms/djangocms-versioning/tarball/master#egg=djangocms-versioning +https://github.com/FidelityInternational/djangocms-version-locking/tarball/master#egg=djangocms-version-locking diff --git a/tests/utils/factories.py b/tests/utils/factories.py index e5445351..74a44fe4 100644 --- a/tests/utils/factories.py +++ b/tests/utils/factories.py @@ -4,20 +4,20 @@ from cms.models import Placeholder +import factory from djangocms_versioning.models import Version from djangocms_versioning.test_utils.factories import ( AbstractVersionFactory, PageVersionFactory, ) +from factory.fuzzy import FuzzyChoice, FuzzyInteger, FuzzyText -import factory from djangocms_moderation.models import ( ModerationCollection, ModerationRequest, ModerationRequestTreeNode, Workflow, ) -from factory.fuzzy import FuzzyChoice, FuzzyInteger, FuzzyText from .moderated_polls.models import Poll, PollContent, PollPlugin from .versioned_none_moderated_app.models import ( diff --git a/tests/utils/moderated_polls/cms_config.py b/tests/utils/moderated_polls/cms_config.py index 6c91b5f7..365a2d09 100644 --- a/tests/utils/moderated_polls/cms_config.py +++ b/tests/utils/moderated_polls/cms_config.py @@ -12,6 +12,8 @@ def get_poll_additional_changelist_action(obj): def get_poll_additional_changelist_field(obj): return f"Custom poll link {obj.pk}" + + get_poll_additional_changelist_field.short_description = "Custom Link" From 325f3556812b69206a815ffce40352742401f472 Mon Sep 17 00:00:00 2001 From: Aiky30 Date: Fri, 14 Jan 2022 18:00:16 +0000 Subject: [PATCH 147/147] feat: Added Django 3.2 and Python 3.8 and 3.9 support (#196) * remove python_2_unicode_compatible * remove file # -*- coding: utf-8 -*- declarations * remove python2 __future__ imports and six primitive types * replaced lru cache imports to: from functools import lru_cache * remapped import for ACTION_CHECKBOX_NAME * replaced ugettext with gettext, ugettext_lazy with gettext_lazy, and ungettext with ngettext * test assertEquals replaced with assertEqual * replaced force_text() with force_str(), https://docs.djangoproject.com/en/3.2/ref/utils/#django.utils.encoding.force_text * moved from django.conf.urls.url() to django.urls.re_path() --- .github/workflows/lint.yml | 4 +- .github/workflows/test.yml | 4 +- .gitignore | 1 + CHANGELOG.rst | 5 ++- djangocms_moderation/admin.py | 45 +++++++++---------- djangocms_moderation/admin_actions.py | 14 +++--- djangocms_moderation/apps.py | 4 +- djangocms_moderation/cms_toolbars.py | 3 +- djangocms_moderation/conf.py | 2 +- djangocms_moderation/constants.py | 4 +- .../contrib/moderation_forms/cms_plugins.py | 2 +- .../migrations/0001_initial.py | 3 -- .../contrib/moderation_forms/models.py | 2 +- djangocms_moderation/emails.py | 8 ++-- djangocms_moderation/filters.py | 10 ++--- djangocms_moderation/forms.py | 10 ++--- djangocms_moderation/handlers.py | 2 - djangocms_moderation/helpers.py | 2 +- djangocms_moderation/managers.py | 2 - .../migrations/0001_initial.py | 3 -- .../migrations/0002_auto_20180905_1152.py | 3 -- .../migrations/0003_auto_20180903_1206.py | 3 -- .../migrations/0004_auto_20180907_1206.py | 3 -- .../migrations/0005_auto_20180919_1348.py | 3 -- .../migrations/0006_auto_20181001_1840.py | 3 -- .../migrations/0007_auto_20181002_1725.py | 3 -- .../migrations/0008_auto_20181002_1833.py | 3 -- .../migrations/0009_auto_20181005_1534.py | 3 -- .../migrations/0010_auto_20181008_1317.py | 3 -- .../migrations/0011_auto_20181008_1328.py | 3 -- .../migrations/0012_auto_20181016_1319.py | 3 -- .../migrations/0013_auto_20181122_1110.py | 3 -- .../migrations/0014_auto_20190313_1638.py | 3 -- .../migrations/0014_auto_20190315_1723.py | 3 -- .../0016_moderationrequesttreenode.py | 3 -- djangocms_moderation/models.py | 15 ++----- djangocms_moderation/monkeypatch.py | 2 +- djangocms_moderation/signals.py | 17 ++----- djangocms_moderation/utils.py | 5 +-- djangocms_moderation/views.py | 6 +-- setup.py | 2 +- tests/requirements/dj11_cms40.txt | 9 ---- tests/requirements/dj22_cms40.txt | 9 ++-- tests/requirements/dj32_cms40.txt | 6 +++ tests/requirements/requirements_base.txt | 9 ++-- tests/settings.py | 2 + tests/test_admin_actions.py | 16 +++---- tests/test_cms_toolbars.py | 2 +- tests/test_models.py | 4 +- tests/test_utils.py | 8 ++-- tox.ini | 27 +++++------ 51 files changed, 112 insertions(+), 202 deletions(-) delete mode 100644 tests/requirements/dj11_cms40.txt create mode 100644 tests/requirements/dj32_cms40.txt diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index f541d708..6760a90a 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -12,7 +12,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v2 with: - python-version: 3.8 + python-version: 3.9 - name: Install flake8 run: pip install --upgrade flake8 - name: Run flake8 @@ -29,7 +29,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v2 with: - python-version: 3.8 + python-version: 3.9 - run: python -m pip install isort - name: isort uses: liskin/gh-problem-matcher-wrap@v1 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 63a69058..704336cc 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,10 +10,10 @@ jobs: strategy: fail-fast: false matrix: - python-version: [ 3.6, 3.7, 3.8, ] # latest release minus two + python-version: [ 3.7, 3.8, 3.9 ] # latest release minus two requirements-file: [ - dj11_cms40.txt, dj22_cms40.txt, + dj32_cms40.txt, ] os: [ ubuntu-20.04, diff --git a/.gitignore b/.gitignore index 53b9b881..218ee7a0 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,4 @@ venv node_modules/ yarn.lock docs/_build/ +venv* diff --git a/CHANGELOG.rst b/CHANGELOG.rst index b20b62c0..8f224355 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -4,7 +4,10 @@ Changelog Unreleased ========== - +* Python 3.8, 3.9 support added +* Django 3.0, 3.1 and 3.2 support added +* Python 3.5 and 3.6 support removed +* Django 1.11 support removed 1.0.28 (2021-10-18) =================== diff --git a/djangocms_moderation/admin.py b/djangocms_moderation/admin.py index 2caa756e..6e66ca7e 100644 --- a/djangocms_moderation/admin.py +++ b/djangocms_moderation/admin.py @@ -1,8 +1,5 @@ -from __future__ import unicode_literals - from django import forms from django.apps import apps -from django.conf.urls import url from django.contrib import admin, messages from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType @@ -11,9 +8,9 @@ from django.http import Http404, HttpResponseRedirect from django.shortcuts import get_object_or_404, render from django.template.loader import render_to_string -from django.urls import reverse +from django.urls import re_path, reverse from django.utils.html import format_html, format_html_join -from django.utils.translation import ugettext, ugettext_lazy as _, ungettext +from django.utils.translation import gettext, gettext_lazy as _, ngettext from cms.admin.placeholderadmin import PlaceholderAdminMixin from cms.toolbar.utils import get_object_preview_url @@ -78,7 +75,7 @@ def has_delete_permission(self, request, obj=None): def show_user(self, obj): _name = obj.get_by_user_name() - return ugettext("By {user}").format(user=_name) + return gettext("By {user}").format(user=_name) show_user.short_description = _("Status") @@ -152,7 +149,7 @@ def get_urls(self): info = self.model._meta.app_label, self.model._meta.model_name return [ - url( + re_path( r'^delete_selected/', self.admin_site.admin_view(self.delete_selected_view), name='{}_{}_delete'.format(*info), @@ -270,13 +267,13 @@ def get_status(self, obj): if last_action: if obj.moderation_request.version_can_be_published(): - status = ugettext('Ready for publishing') + status = gettext('Ready for publishing') elif obj.moderation_request.is_rejected(): - status = ugettext('Pending author rework') + status = gettext('Pending author rework') elif obj.moderation_request.is_active and obj.moderation_request.has_pending_step(): next_step = obj.moderation_request.get_next_required() role = next_step.role.name - status = ugettext('Pending %(role)s approval') % {'role': role} + status = gettext('Pending %(role)s approval') % {'role': role} elif not obj.moderation_request.version.can_be_published(): status = obj.moderation_request.version.get_state_display() else: @@ -285,9 +282,9 @@ def get_status(self, obj): 'action': last_action.get_action_display(), 'name': user_name, } - status = ugettext('%(action)s by %(name)s') % message_data + status = gettext('%(action)s by %(name)s') % message_data else: - status = ugettext('Ready for submission') + status = gettext('Ready for submission') return status def get_comments_link(self, obj): @@ -450,7 +447,7 @@ def _traverse_moderation_nodes(node_item): queryset.delete() messages.success( request, - ungettext( + ngettext( '%(count)d request successfully deleted', '%(count)d requests successfully deleted', num_deleted_requests @@ -504,22 +501,22 @@ def get_urls(self): info = self.model._meta.app_label, self.model._meta.model_name return [ - url( + re_path( r"^approve/", self.admin_site.admin_view(self.approved_view), name="{}_{}_approve".format(*info), ), - url( + re_path( r"^rework/", self.admin_site.admin_view(self.rework_view), name="{}_{}_rework".format(*info), ), - url( + re_path( r"^publish/", self.admin_site.admin_view(self.published_view), name="{}_{}_publish".format(*info), ), - url( + re_path( r"^resubmit/", self.admin_site.admin_view(self.resubmit_view), name="{}_{}_resubmit".format(*info), @@ -592,7 +589,7 @@ def resubmit_view(self, request): messages.success( request, - ungettext( + ngettext( "%(count)d request successfully resubmitted for review", "%(count)d requests successfully resubmitted for review", len(resubmitted_requests), @@ -639,7 +636,7 @@ def published_view(self, request): messages.success( request, - ungettext( + ngettext( "%(count)d request successfully published", "%(count)d requests successfully published", len(published_moderation_requests), @@ -698,7 +695,7 @@ def rework_view(self, request): messages.success( request, - ungettext( + ngettext( "%(count)d request successfully submitted for rework", "%(count)d requests successfully submitted for rework", len(rejected_requests), @@ -790,7 +787,7 @@ def approved_view(self, request): messages.success( request, - ungettext( + ngettext( "%(count)d request successfully approved", "%(count)d requests successfully approved", len(approved_requests), @@ -1088,7 +1085,7 @@ def get_comments_link(self, obj): def get_urls(self): def _url(regex, fn, name, **kwargs): - return url(regex, self.admin_site.admin_view(fn), kwargs=kwargs, name=name) + return re_path(regex, self.admin_site.admin_view(fn), kwargs=kwargs, name=name) url_patterns = [ _url( @@ -1138,7 +1135,7 @@ class ConfirmationPageAdmin(PlaceholderAdminMixin, admin.ModelAdmin): def get_urls(self): def _url(regex, fn, name, **kwargs): - return url(regex, self.admin_site.admin_view(fn), kwargs=kwargs, name=name) + return re_path(regex, self.admin_site.admin_view(fn), kwargs=kwargs, name=name) url_patterns = [ _url( @@ -1191,7 +1188,7 @@ def form_data(self, obj): "", "

      {}: {}
      {}: {}

      ", ( - (ugettext("Question"), d["label"], ugettext("Answer"), d["value"]) + (gettext("Question"), d["label"], gettext("Answer"), d["value"]) for d in data ), ) diff --git a/djangocms_moderation/admin_actions.py b/djangocms_moderation/admin_actions.py index c5ae2aef..07fc3eea 100644 --- a/djangocms_moderation/admin_actions.py +++ b/djangocms_moderation/admin_actions.py @@ -1,13 +1,13 @@ from collections import defaultdict from functools import partial -from django.contrib import admin +from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME from django.contrib.contenttypes.models import ContentType from django.core.exceptions import PermissionDenied from django.db.models import Q from django.http import HttpResponseRedirect from django.shortcuts import reverse -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from cms.utils.urlutils import add_url_parameters @@ -24,7 +24,7 @@ def resubmit_selected(modeladmin, request, queryset): Validate and re-submit all the selected moderation requests for moderation and notify reviewers via email. """ - selected = request.POST.getlist(admin.ACTION_CHECKBOX_NAME) + selected = request.POST.getlist(ACTION_CHECKBOX_NAME) url = "{}?ids={}&collection_id={}".format( reverse("admin:djangocms_moderation_moderationrequest_resubmit"), ",".join(selected), @@ -41,7 +41,7 @@ def reject_selected(modeladmin, request, queryset): Validate and reject all the selected moderation requests and notify the author about these requests """ - selected = request.POST.getlist(admin.ACTION_CHECKBOX_NAME) + selected = request.POST.getlist(ACTION_CHECKBOX_NAME) url = "{}?ids={}&collection_id={}".format( reverse("admin:djangocms_moderation_moderationrequest_rework"), ",".join(selected), @@ -54,7 +54,7 @@ def reject_selected(modeladmin, request, queryset): def approve_selected(modeladmin, request, queryset): - selected = request.POST.getlist(admin.ACTION_CHECKBOX_NAME) + selected = request.POST.getlist(ACTION_CHECKBOX_NAME) url = "{}?ids={}&collection_id={}".format( reverse("admin:djangocms_moderation_moderationrequest_approve"), ",".join(selected), @@ -70,7 +70,7 @@ def delete_selected(modeladmin, request, queryset): if not modeladmin.has_delete_permission(request): raise PermissionDenied - selected = request.POST.getlist(admin.ACTION_CHECKBOX_NAME) + selected = request.POST.getlist(ACTION_CHECKBOX_NAME) url = "{}?ids={}&collection_id={}".format( reverse('admin:djangocms_moderation_moderationrequesttreenode_delete'), ",".join(selected), @@ -87,7 +87,7 @@ def publish_selected(modeladmin, request, queryset): if request.user != request._collection.author: raise PermissionDenied - selected = request.POST.getlist(admin.ACTION_CHECKBOX_NAME) + selected = request.POST.getlist(ACTION_CHECKBOX_NAME) url = "{}?ids={}&collection_id={}".format( reverse("admin:djangocms_moderation_moderationrequest_publish"), ",".join(selected), diff --git a/djangocms_moderation/apps.py b/djangocms_moderation/apps.py index a7dda494..cbeed482 100644 --- a/djangocms_moderation/apps.py +++ b/djangocms_moderation/apps.py @@ -1,7 +1,5 @@ -from __future__ import unicode_literals - from django.apps import AppConfig -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ class ModerationConfig(AppConfig): diff --git a/djangocms_moderation/cms_toolbars.py b/djangocms_moderation/cms_toolbars.py index f80470cb..6181cfb7 100644 --- a/djangocms_moderation/cms_toolbars.py +++ b/djangocms_moderation/cms_toolbars.py @@ -1,6 +1,5 @@ -# -*- coding: utf-8 -*- from django.contrib.auth import get_permission_codename -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from cms.cms_toolbars import ADMIN_MENU_IDENTIFIER from cms.utils.urlutils import add_url_parameters diff --git a/djangocms_moderation/conf.py b/djangocms_moderation/conf.py index 417d5500..e7008be7 100644 --- a/djangocms_moderation/conf.py +++ b/djangocms_moderation/conf.py @@ -1,5 +1,5 @@ from django.conf import settings -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ UUID_BACKEND = "djangocms_moderation.backends.uuid4_backend" diff --git a/djangocms_moderation/constants.py b/djangocms_moderation/constants.py index d3e3a1f5..8b64c003 100644 --- a/djangocms_moderation/constants.py +++ b/djangocms_moderation/constants.py @@ -1,6 +1,4 @@ -from __future__ import unicode_literals - -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ # NOTE: those are not just numbers!! we will do binary AND on them, diff --git a/djangocms_moderation/contrib/moderation_forms/cms_plugins.py b/djangocms_moderation/contrib/moderation_forms/cms_plugins.py index 64b29794..61e23625 100644 --- a/djangocms_moderation/contrib/moderation_forms/cms_plugins.py +++ b/djangocms_moderation/contrib/moderation_forms/cms_plugins.py @@ -1,4 +1,4 @@ -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from cms.plugin_pool import plugin_pool diff --git a/djangocms_moderation/contrib/moderation_forms/migrations/0001_initial.py b/djangocms_moderation/contrib/moderation_forms/migrations/0001_initial.py index cbb95f32..c2ab629c 100644 --- a/djangocms_moderation/contrib/moderation_forms/migrations/0001_initial.py +++ b/djangocms_moderation/contrib/moderation_forms/migrations/0001_initial.py @@ -1,7 +1,4 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.12 on 2018-05-03 09:15 -from __future__ import unicode_literals - from django.db import migrations diff --git a/djangocms_moderation/contrib/moderation_forms/models.py b/djangocms_moderation/contrib/moderation_forms/models.py index f5ab7f44..72027ccd 100644 --- a/djangocms_moderation/contrib/moderation_forms/models.py +++ b/djangocms_moderation/contrib/moderation_forms/models.py @@ -1,4 +1,4 @@ -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from aldryn_forms.models import FormPlugin diff --git a/djangocms_moderation/emails.py b/djangocms_moderation/emails.py index 35acc07c..319a53e6 100644 --- a/djangocms_moderation/emails.py +++ b/djangocms_moderation/emails.py @@ -1,11 +1,9 @@ -from __future__ import unicode_literals - from django.conf import settings from django.core.mail import EmailMessage from django.template.loader import render_to_string from django.urls import reverse -from django.utils.encoding import force_text -from django.utils.translation import ugettext_lazy as _ +from django.utils.encoding import force_str +from django.utils.translation import gettext_lazy as _ from .conf import EMAIL_NOTIFICATIONS_FAIL_SILENTLY from .utils import get_absolute_url @@ -40,7 +38,7 @@ def _send_email( template = "djangocms_moderation/emails/moderation-request/{}".format(template) # TODO What language should the email be sent in? e.g. `with force_language(lang):` - subject = force_text(subject) + subject = force_str(subject) content = render_to_string(template, context) message = EmailMessage( diff --git a/djangocms_moderation/filters.py b/djangocms_moderation/filters.py index 8cd6bd59..a00ae402 100644 --- a/djangocms_moderation/filters.py +++ b/djangocms_moderation/filters.py @@ -1,8 +1,8 @@ from django.contrib import admin from django.contrib.auth import get_user_model from django.db.models import Q -from django.utils.encoding import force_text -from django.utils.translation import ugettext_lazy as _ +from django.utils.encoding import force_str +from django.utils.translation import gettext_lazy as _ from . import constants, helpers @@ -22,7 +22,7 @@ def lookups(self, request, model_admin): options = [] for user in helpers.get_all_moderators(): options.append( - (force_text(user.pk), user.get_full_name() or user.get_username()) + (force_str(user.pk), user.get_full_name() or user.get_username()) ) return options @@ -44,7 +44,7 @@ def lookups(self, request, model_admin): # collect all unique users from the three queries for user in helpers.get_all_reviewers(): options.append( - (force_text(user.pk), user.get_full_name() or user.get_username()) + (force_str(user.pk), user.get_full_name() or user.get_username()) ) return options @@ -78,7 +78,7 @@ def choices(self, changelist): } for lookup, title in self.lookup_choices: yield { - "selected": self.value() == force_text(lookup), + "selected": self.value() == force_str(lookup), "query_string": changelist.get_query_string( {self.parameter_name: lookup}, [] ), diff --git a/djangocms_moderation/forms.py b/djangocms_moderation/forms.py index 3d5d54b9..7ff8bd8d 100644 --- a/djangocms_moderation/forms.py +++ b/djangocms_moderation/forms.py @@ -1,11 +1,9 @@ -from __future__ import unicode_literals - from django import forms from django.contrib import admin from django.contrib.admin.widgets import RelatedFieldWidgetWrapper from django.contrib.auth import get_user_model from django.forms.forms import NON_FIELD_ERRORS -from django.utils.translation import ugettext, ugettext_lazy as _, ungettext +from django.utils.translation import gettext, gettext_lazy as _, ngettext from adminsortable2.admin import CustomInlineFormSet from djangocms_versioning.models import Version @@ -99,7 +97,7 @@ def configure_moderator_field(self): if next_step: next_role = next_step.role users = next_step.role.get_users_queryset() - self.fields["moderator"].empty_label = ugettext("Any {role}").format( + self.fields["moderator"].empty_label = gettext("Any {role}").format( role=next_role.name ) self.fields["moderator"].queryset = users.exclude(pk=self.user.pk) @@ -170,7 +168,7 @@ def clean_versions(self): if not eligible_versions: raise forms.ValidationError( - ungettext( + ngettext( "Your item is either locked, not enabled for moderation," "or is part of another active moderation request", "Your items are either locked, not enabled for moderation," @@ -197,7 +195,7 @@ def __init__(self, *args, **kwargs): def configure_moderator_field(self): next_role = self.collection.workflow.first_step.role users = next_role.get_users_queryset().exclude(pk=self.user.pk) - self.fields["moderator"].empty_label = ugettext("Any {role}").format( + self.fields["moderator"].empty_label = gettext("Any {role}").format( role=next_role.name ) self.fields["moderator"].queryset = users diff --git a/djangocms_moderation/handlers.py b/djangocms_moderation/handlers.py index 943a94f7..768ba335 100644 --- a/djangocms_moderation/handlers.py +++ b/djangocms_moderation/handlers.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - import json from django.dispatch import receiver diff --git a/djangocms_moderation/helpers.py b/djangocms_moderation/helpers.py index 201b2380..c3f38e62 100644 --- a/djangocms_moderation/helpers.py +++ b/djangocms_moderation/helpers.py @@ -4,7 +4,7 @@ from django.db.models import Q from django.template.defaultfilters import truncatechars from django.urls import reverse -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from cms.utils.plugins import downcast_plugins diff --git a/djangocms_moderation/managers.py b/djangocms_moderation/managers.py index 2d8f7e6a..688a0012 100644 --- a/djangocms_moderation/managers.py +++ b/djangocms_moderation/managers.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.db import models from django.db.models import Manager, Q diff --git a/djangocms_moderation/migrations/0001_initial.py b/djangocms_moderation/migrations/0001_initial.py index f8d2aa42..b1464ba4 100644 --- a/djangocms_moderation/migrations/0001_initial.py +++ b/djangocms_moderation/migrations/0001_initial.py @@ -1,7 +1,4 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.13 on 2018-08-16 08:57 -from __future__ import unicode_literals - import cms.models.fields from django.conf import settings from django.db import migrations, models diff --git a/djangocms_moderation/migrations/0002_auto_20180905_1152.py b/djangocms_moderation/migrations/0002_auto_20180905_1152.py index 41a7548d..f80795dc 100644 --- a/djangocms_moderation/migrations/0002_auto_20180905_1152.py +++ b/djangocms_moderation/migrations/0002_auto_20180905_1152.py @@ -1,7 +1,4 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.13 on 2018-09-05 10:52 -from __future__ import unicode_literals - from django.conf import settings from django.db import migrations, models import django.db.models.deletion diff --git a/djangocms_moderation/migrations/0003_auto_20180903_1206.py b/djangocms_moderation/migrations/0003_auto_20180903_1206.py index 25bd0dcd..8bbc691c 100644 --- a/djangocms_moderation/migrations/0003_auto_20180903_1206.py +++ b/djangocms_moderation/migrations/0003_auto_20180903_1206.py @@ -1,7 +1,4 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.13 on 2018-09-03 11:06 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion diff --git a/djangocms_moderation/migrations/0004_auto_20180907_1206.py b/djangocms_moderation/migrations/0004_auto_20180907_1206.py index d40417ec..9afdd8f7 100644 --- a/djangocms_moderation/migrations/0004_auto_20180907_1206.py +++ b/djangocms_moderation/migrations/0004_auto_20180907_1206.py @@ -1,7 +1,4 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.13 on 2018-09-07 11:06 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion diff --git a/djangocms_moderation/migrations/0005_auto_20180919_1348.py b/djangocms_moderation/migrations/0005_auto_20180919_1348.py index d0b57990..ffc1e8be 100644 --- a/djangocms_moderation/migrations/0005_auto_20180919_1348.py +++ b/djangocms_moderation/migrations/0005_auto_20180919_1348.py @@ -1,7 +1,4 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.13 on 2018-09-19 12:48 -from __future__ import unicode_literals - from django.conf import settings from django.db import migrations, models import django.db.models.deletion diff --git a/djangocms_moderation/migrations/0006_auto_20181001_1840.py b/djangocms_moderation/migrations/0006_auto_20181001_1840.py index e3f16933..409bb06d 100644 --- a/djangocms_moderation/migrations/0006_auto_20181001_1840.py +++ b/djangocms_moderation/migrations/0006_auto_20181001_1840.py @@ -1,7 +1,4 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.13 on 2018-10-01 17:40 -from __future__ import unicode_literals - from django.conf import settings from django.db import migrations, models import django.db.models.deletion diff --git a/djangocms_moderation/migrations/0007_auto_20181002_1725.py b/djangocms_moderation/migrations/0007_auto_20181002_1725.py index f30c7da4..651be569 100644 --- a/djangocms_moderation/migrations/0007_auto_20181002_1725.py +++ b/djangocms_moderation/migrations/0007_auto_20181002_1725.py @@ -1,7 +1,4 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.16 on 2018-10-02 16:25 -from __future__ import unicode_literals - from django.db import migrations diff --git a/djangocms_moderation/migrations/0008_auto_20181002_1833.py b/djangocms_moderation/migrations/0008_auto_20181002_1833.py index d0b5ff82..497796b6 100644 --- a/djangocms_moderation/migrations/0008_auto_20181002_1833.py +++ b/djangocms_moderation/migrations/0008_auto_20181002_1833.py @@ -1,7 +1,4 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.15 on 2018-10-02 16:33 -from __future__ import unicode_literals - from django.conf import settings from django.db import migrations, models import django.db.models.deletion diff --git a/djangocms_moderation/migrations/0009_auto_20181005_1534.py b/djangocms_moderation/migrations/0009_auto_20181005_1534.py index 3302a6c0..34803320 100644 --- a/djangocms_moderation/migrations/0009_auto_20181005_1534.py +++ b/djangocms_moderation/migrations/0009_auto_20181005_1534.py @@ -1,7 +1,4 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.13 on 2018-10-05 14:34 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion diff --git a/djangocms_moderation/migrations/0010_auto_20181008_1317.py b/djangocms_moderation/migrations/0010_auto_20181008_1317.py index 64d26983..a6f69b54 100644 --- a/djangocms_moderation/migrations/0010_auto_20181008_1317.py +++ b/djangocms_moderation/migrations/0010_auto_20181008_1317.py @@ -1,7 +1,4 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.13 on 2018-10-08 12:17 -from __future__ import unicode_literals - from django.db import migrations diff --git a/djangocms_moderation/migrations/0011_auto_20181008_1328.py b/djangocms_moderation/migrations/0011_auto_20181008_1328.py index 27a1cdbb..23829d74 100644 --- a/djangocms_moderation/migrations/0011_auto_20181008_1328.py +++ b/djangocms_moderation/migrations/0011_auto_20181008_1328.py @@ -1,7 +1,4 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.13 on 2018-10-08 12:28 -from __future__ import unicode_literals - from django.conf import settings from django.db import migrations from django.db import migrations, models diff --git a/djangocms_moderation/migrations/0012_auto_20181016_1319.py b/djangocms_moderation/migrations/0012_auto_20181016_1319.py index 8a861a50..6c9865bb 100644 --- a/djangocms_moderation/migrations/0012_auto_20181016_1319.py +++ b/djangocms_moderation/migrations/0012_auto_20181016_1319.py @@ -1,7 +1,4 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.13 on 2018-10-16 12:19 -from __future__ import unicode_literals - from django.conf import settings from django.db import migrations, models import django.db.models.deletion diff --git a/djangocms_moderation/migrations/0013_auto_20181122_1110.py b/djangocms_moderation/migrations/0013_auto_20181122_1110.py index fd2236be..78838ad0 100644 --- a/djangocms_moderation/migrations/0013_auto_20181122_1110.py +++ b/djangocms_moderation/migrations/0013_auto_20181122_1110.py @@ -1,7 +1,4 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.15 on 2018-11-22 11:10 -from __future__ import unicode_literals - from django.conf import settings from django.db import migrations, models import django.db.models.deletion diff --git a/djangocms_moderation/migrations/0014_auto_20190313_1638.py b/djangocms_moderation/migrations/0014_auto_20190313_1638.py index 7f702e2e..cd4f8b0d 100644 --- a/djangocms_moderation/migrations/0014_auto_20190313_1638.py +++ b/djangocms_moderation/migrations/0014_auto_20190313_1638.py @@ -1,7 +1,4 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.15 on 2019-03-13 16:38 -from __future__ import unicode_literals - from django.db import migrations, models diff --git a/djangocms_moderation/migrations/0014_auto_20190315_1723.py b/djangocms_moderation/migrations/0014_auto_20190315_1723.py index 23795a83..d420dc9c 100644 --- a/djangocms_moderation/migrations/0014_auto_20190315_1723.py +++ b/djangocms_moderation/migrations/0014_auto_20190315_1723.py @@ -1,7 +1,4 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.15 on 2019-03-15 17:23 -from __future__ import unicode_literals - from django.db import migrations diff --git a/djangocms_moderation/migrations/0016_moderationrequesttreenode.py b/djangocms_moderation/migrations/0016_moderationrequesttreenode.py index 209b7a1b..592c7f89 100644 --- a/djangocms_moderation/migrations/0016_moderationrequesttreenode.py +++ b/djangocms_moderation/migrations/0016_moderationrequesttreenode.py @@ -1,7 +1,4 @@ -# -*- coding: utf-8 -*- # Generated by Django 1.11.15 on 2019-03-11 16:45 -from __future__ import unicode_literals - from django.db import migrations, models import django.db.models.deletion diff --git a/djangocms_moderation/models.py b/djangocms_moderation/models.py index edb844df..25c2afd6 100644 --- a/djangocms_moderation/models.py +++ b/djangocms_moderation/models.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - import json from django.conf import settings @@ -8,9 +6,8 @@ from django.core.exceptions import ValidationError from django.db import models, transaction from django.urls import reverse -from django.utils.encoding import python_2_unicode_compatible from django.utils.functional import cached_property -from django.utils.translation import ugettext, ugettext_lazy as _ +from django.utils.translation import gettext, gettext_lazy as _ from cms.models.fields import PlaceholderField @@ -25,7 +22,6 @@ from . import conf, constants, signals # isort:skip -@python_2_unicode_compatible class ConfirmationPage(models.Model): CONTENT_TYPES = ( (constants.CONTENT_TYPE_PLAIN, _("Plain")), @@ -71,7 +67,6 @@ def is_valid(self, active_request, for_step, is_reviewed=False): return True -@python_2_unicode_compatible class Role(models.Model): name = models.CharField( verbose_name=_('name'), @@ -102,7 +97,7 @@ def __str__(self): def clean(self): if self.user_id and self.group_id: - message = ugettext("Can't pick both user and group. Only one.") + message = gettext("Can't pick both user and group. Only one.") raise ValidationError(message) def user_is_assigned(self, user): @@ -116,7 +111,6 @@ def get_users_queryset(self): return self.group.user_set.all() -@python_2_unicode_compatible class Workflow(models.Model): name = models.CharField(verbose_name=_("name"), max_length=120, unique=True) is_default = models.BooleanField(verbose_name=_("is default"), default=False) @@ -164,7 +158,7 @@ def clean(self): workflows = workflows.exclude(pk=self.pk) if workflows.exists(): - message = ugettext("Can't have two default workflows, only one is allowed.") + message = gettext("Can't have two default workflows, only one is allowed.") raise ValidationError(message) @cached_property @@ -172,7 +166,6 @@ def first_step(self): return self.steps.first() -@python_2_unicode_compatible class WorkflowStep(models.Model): role = models.ForeignKey( to=Role, verbose_name=_("role"), on_delete=models.CASCADE @@ -413,7 +406,6 @@ def __str__(self): return str(self.id) -@python_2_unicode_compatible class ModerationRequest(models.Model): collection = models.ForeignKey( to=ModerationCollection, @@ -601,7 +593,6 @@ def set_compliance_number(self): self.save(update_fields=["compliance_number"]) -@python_2_unicode_compatible class ModerationRequestAction(models.Model): action = models.CharField( verbose_name=_("status"), max_length=30, choices=constants.ACTION_CHOICES diff --git a/djangocms_moderation/monkeypatch.py b/djangocms_moderation/monkeypatch.py index 00d0694f..a439850e 100644 --- a/djangocms_moderation/monkeypatch.py +++ b/djangocms_moderation/monkeypatch.py @@ -1,5 +1,5 @@ from django.utils.html import format_html -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import gettext_lazy as _ from cms.models import fields from cms.utils.urlutils import add_url_parameters diff --git a/djangocms_moderation/signals.py b/djangocms_moderation/signals.py index 46c28975..dc6a53b1 100644 --- a/djangocms_moderation/signals.py +++ b/djangocms_moderation/signals.py @@ -1,19 +1,8 @@ import django.dispatch -confirmation_form_submission = django.dispatch.Signal( - providing_args=["page", "language", "user", "form_data"] -) +confirmation_form_submission = django.dispatch.Signal() -submitted_for_review = django.dispatch.Signal( - providing_args=["collection", "moderation_requests", "user", "rework"] -) +submitted_for_review = django.dispatch.Signal() -published = django.dispatch.Signal( - providing_args=[ - "collection", - "moderator", - "moderation_requests", - "workflow" - ] -) +published = django.dispatch.Signal() diff --git a/djangocms_moderation/utils.py b/djangocms_moderation/utils.py index 10c4d52b..1f29c242 100644 --- a/djangocms_moderation/utils.py +++ b/djangocms_moderation/utils.py @@ -1,10 +1,9 @@ -from __future__ import unicode_literals +from functools import lru_cache +from urllib.parse import parse_qs, urljoin from django.conf import settings from django.contrib.sites.models import Site -from django.utils.lru_cache import lru_cache from django.utils.module_loading import import_string -from django.utils.six.moves.urllib.parse import parse_qs, urljoin from django.utils.translation import override as force_language from cms.utils.urlutils import admin_reverse diff --git a/djangocms_moderation/views.py b/djangocms_moderation/views.py index cee30199..95d4e839 100644 --- a/djangocms_moderation/views.py +++ b/djangocms_moderation/views.py @@ -1,5 +1,3 @@ -from __future__ import unicode_literals - from django.contrib import admin, messages from django.db import transaction from django.http import Http404, HttpResponseRedirect @@ -7,7 +5,7 @@ from django.urls import reverse from django.utils.decorators import method_decorator from django.utils.http import is_safe_url, urlquote -from django.utils.translation import ugettext_lazy as _, ungettext +from django.utils.translation import gettext_lazy as _, ngettext from django.views.generic import FormView from cms.models import PageContent @@ -66,7 +64,7 @@ def form_valid(self, form): messages.success( self.request, - ungettext( + ngettext( "%(count)d item successfully added to moderation collection", "%(count)d items successfully added to moderation collection", total_added, diff --git a/setup.py b/setup.py index 331b4081..0835848d 100644 --- a/setup.py +++ b/setup.py @@ -4,7 +4,7 @@ INSTALL_REQUIREMENTS = [ - "Django>=1.11,<3.0", + "Django>=2.2,<4.0", "django-cms", "django-sekizai>=0.7", "django-admin-sortable2>=0.6.4", diff --git a/tests/requirements/dj11_cms40.txt b/tests/requirements/dj11_cms40.txt deleted file mode 100644 index 6019903a..00000000 --- a/tests/requirements/dj11_cms40.txt +++ /dev/null @@ -1,9 +0,0 @@ --r requirements_base.txt - -Django>=1.11,<2.0 -django-admin-sortable2<1.0.0 -django-filer<2.0.0 -django-classy-tags<2.0.0 -django-sekizai<2.0.0 -# Please note that aldryn-forms version 5 can only be used with django >2 and deprecates <2 completely. -aldryn-forms<6.0.0 diff --git a/tests/requirements/dj22_cms40.txt b/tests/requirements/dj22_cms40.txt index 4ff11633..4e51203c 100644 --- a/tests/requirements/dj22_cms40.txt +++ b/tests/requirements/dj22_cms40.txt @@ -1,7 +1,6 @@ --r requirements_base.txt +-r ./requirements_base.txt Django>=2.2,<3.0 -django-filer -django-classy-tags -django-sekizai -aldryn-forms + +django-admin-sortable2<1.0 +django_polymorphic==2.0.3 diff --git a/tests/requirements/dj32_cms40.txt b/tests/requirements/dj32_cms40.txt new file mode 100644 index 00000000..d19b191e --- /dev/null +++ b/tests/requirements/dj32_cms40.txt @@ -0,0 +1,6 @@ +-r ./requirements_base.txt + +Django>=3.2,<4.0 + +django-admin-sortable2 +django_polymorphic diff --git a/tests/requirements/requirements_base.txt b/tests/requirements/requirements_base.txt index a3789121..b7cd9390 100644 --- a/tests/requirements/requirements_base.txt +++ b/tests/requirements/requirements_base.txt @@ -1,8 +1,11 @@ +aldryn-forms cachetools coverage -django_polymorphic==2.0.3 +django-classy-tags +django-filer +django-sekizai django-simple-captcha -djangocms_helper +django-app-helper factory-boy flake8 isort @@ -11,7 +14,7 @@ pyflakes>=2.1.1 python-dateutil>=2.4 # Unreleased django-cms 4.0 compatible packages -https://github.com/django-cms/django-cms/tarball/release/4.0.x#egg=django-cms +https://github.com/django-cms/django-cms/tarball/develop-4#egg=django-cms https://github.com/django-cms/djangocms-text-ckeditor/tarball/support/4.0.x#egg=djangocms-text-ckeditor https://github.com/django-cms/djangocms-versioning/tarball/master#egg=djangocms-versioning https://github.com/FidelityInternational/djangocms-version-locking/tarball/master#egg=djangocms-version-locking diff --git a/tests/settings.py b/tests/settings.py index 311abff4..67561ba3 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -1,4 +1,5 @@ HELPER_SETTINGS = { + "SECRET_KEY": "moderationtestsuitekey", "INSTALLED_APPS": [ "tests.utils.app_1", "tests.utils.app_2", @@ -27,6 +28,7 @@ "djangocms_moderation": None, "aldryn_forms": None, }, + "DEFAULT_AUTO_FIELD": "django.db.models.AutoField", } diff --git a/tests/test_admin_actions.py b/tests/test_admin_actions.py index 0114882c..b0c3fbd8 100644 --- a/tests/test_admin_actions.py +++ b/tests/test_admin_actions.py @@ -1,7 +1,7 @@ import mock import unittest -from django.contrib.admin import ACTION_CHECKBOX_NAME +from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME from django.contrib.auth.models import Group from django.test import TransactionTestCase from django.urls import reverse @@ -639,13 +639,13 @@ def test_signal_when_published(self): self.client.post(response.url) args, kwargs = signal.calls[0] published_mr = kwargs['moderation_requests'] - self.assertEquals(signal.call_count, 1) - self.assertEquals(kwargs['sender'], ModerationRequest) - self.assertEquals(kwargs['collection'], self.collection) - self.assertEquals(kwargs['moderator'], self.collection.author) - self.assertEquals(len(published_mr), 1) - self.assertEquals(published_mr[0], self.moderation_request1) - self.assertEquals(kwargs['workflow'], self.collection.workflow) + self.assertEqual(signal.call_count, 1) + self.assertEqual(kwargs['sender'], ModerationRequest) + self.assertEqual(kwargs['collection'], self.collection) + self.assertEqual(kwargs['moderator'], self.collection.author) + self.assertEqual(len(published_mr), 1) + self.assertEqual(published_mr[0], self.moderation_request1) + self.assertEqual(kwargs['workflow'], self.collection.workflow) @unittest.skip("Skip until collection status bugs fixed") @mock.patch("django.contrib.messages.success") diff --git a/tests/test_cms_toolbars.py b/tests/test_cms_toolbars.py index 7b62650e..62727ebe 100644 --- a/tests/test_cms_toolbars.py +++ b/tests/test_cms_toolbars.py @@ -28,7 +28,7 @@ def _get_page_request(self, page, user): request.session = {} request.user = user request.current_page = page - mid = ToolbarMiddleware() + mid = ToolbarMiddleware(request) mid.process_request(request) if hasattr(request, "toolbar"): request.toolbar.populate() diff --git a/tests/test_models.py b/tests/test_models.py index 3b592261..b00b6bfa 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -622,7 +622,7 @@ def test_submit_for_review(self, mock_ncm): self.collection1.refresh_from_db() # Collection should lock itself - self.assertEquals(self.collection1.status, constants.IN_REVIEW) + self.assertEqual(self.collection1.status, constants.IN_REVIEW) # We will now have 2 actions with status STARTED. self.assertEqual( 2, @@ -652,7 +652,7 @@ def test_cancel(self): self.collection1.cancel(self.user) self.collection1.refresh_from_db() - self.assertEquals(self.collection1.status, constants.CANCELLED) + self.assertEqual(self.collection1.status, constants.CANCELLED) # Only 1 active request will be cancelled actions = ModerationRequestAction.objects.filter( diff --git a/tests/test_utils.py b/tests/test_utils.py index 9ac31098..db496568 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -23,7 +23,7 @@ def test_extract_filter_param_from_changelist_url(self): collection_id = utils.extract_filter_param_from_changelist_url( mock_request, "_changelist_filters", "collection__id__exact" ) - self.assertEquals(collection_id, "1") + self.assertEqual(collection_id, "1") mock_request = self.rf.get( "/admin/djangocms_moderation/collectioncomment/add/?_changelist_filters=collection__id__exact%3D4" @@ -31,7 +31,7 @@ def test_extract_filter_param_from_changelist_url(self): collection_id = utils.extract_filter_param_from_changelist_url( mock_request, "_changelist_filters", "collection__id__exact" ) - self.assertEquals(collection_id, "4") + self.assertEqual(collection_id, "4") mock_request = self.rf.get( "/admin/djangocms_moderation/requestcomment/add/?_changelist_filters=moderation_request__id__exact%3D1" @@ -39,7 +39,7 @@ def test_extract_filter_param_from_changelist_url(self): action_id = utils.extract_filter_param_from_changelist_url( mock_request, "_changelist_filters", "moderation_request__id__exact" ) - self.assertEquals(action_id, "1") + self.assertEqual(action_id, "1") mock_request = self.rf.get( "/admin/djangocms_moderation/requestcomment/add/?_changelist_filters=moderation_request__id__exact%3D2" @@ -47,7 +47,7 @@ def test_extract_filter_param_from_changelist_url(self): action_id = utils.extract_filter_param_from_changelist_url( mock_request, "_changelist_filters", "moderation_request__id__exact" ) - self.assertEquals(action_id, "2") + self.assertEqual(action_id, "2") def test_get_active_moderation_request(self): self.assertEqual( diff --git a/tox.ini b/tox.ini index 7483eb32..24c8a857 100644 --- a/tox.ini +++ b/tox.ini @@ -1,28 +1,23 @@ [tox] envlist = - flake8 - isort - py{36,37}-dj{111,20}-sqlite-cms4-aldrynforms4 - py{36,37}-dj{21,22}-sqlite-cms4-aldrynforms5 + dj32-flake8 + dj32-isort + py{37,38,39}-dj{22,32}-sqlite-cms4 skip_missing_interpreters=True [testenv] deps = - -r{toxinidir}/tests/requirements.txt + dj22: -r{toxinidir}/tests/requirements/django_22.txt + dj22: Django>=2.2,<2.3 - dj111: Django>=1.11,<2.0 - dj20: Django>=2.0,<2.1 - dj21: Django>=2.1,<2.2 - dj22: Django>=2.2,<3.0 - - cms4: https://github.com/divio/django-cms/archive/release/4.0.x.zip - aldrynforms5: https://github.com/divio/aldryn-forms/archive/master.zip - aldrynforms4: aldryn-forms==4.0.1 + dj32: -r{toxinidir}/tests/requirements/django_32.txt + dj32: Django>=3.2,<3.3 basepython = - py36: python3.6 py37: python3.7 + py38: python3.8 + py39: python3.9 commands = {envpython} --version @@ -32,8 +27,8 @@ commands = [testenv:flake8] commands = flake8 -basepython = python3.6 +basepython = python3.9 [testenv:isort] commands = isort --recursive --check-only --diff {toxinidir} -basepython = python3.6 +basepython = python3.9