diff --git a/djangocms_snippet/admin.py b/djangocms_snippet/admin.py index 9187fadd..48e02314 100644 --- a/djangocms_snippet/admin.py +++ b/djangocms_snippet/admin.py @@ -1,12 +1,28 @@ from django.conf import settings +from django.conf.urls import url from django.contrib import admin from django.db import models from django.forms import Textarea +from .cms_config import SnippetCMSAppConfig from .models import Snippet +from .views import SnippetPreviewView -class SnippetAdmin(admin.ModelAdmin): +# Use the version mixin if djangocms-versioning is installed and enabled +snippet_admin_classes = [admin.ModelAdmin] +djangocms_versioning_enabled = SnippetCMSAppConfig.djangocms_versioning_enabled + +try: + from djangocms_versioning.admin import ExtendedVersionAdminMixin + + if djangocms_versioning_enabled: + snippet_admin_classes.insert(0, ExtendedVersionAdminMixin) +except ImportError: + pass + + +class SnippetAdmin(*snippet_admin_classes): list_display = ('slug', 'name') search_fields = ['slug', 'name'] prepopulated_fields = {'slug': ('name',)} @@ -22,5 +38,38 @@ class SnippetAdmin(admin.ModelAdmin): models.TextField: {'widget': Textarea(attrs=text_area_attrs)} } + class Meta: + model = Snippet + + def get_urls(self): + info = self.model._meta.app_label, self.model._meta.model_name + return [ + url( + r"^$", + self.admin_site.admin_view(self.changelist_view), + name="{}_{}_changelist".format(*info), + ), + url( + r"^(?P\d+)/$", + self.admin_site.admin_view(self.changelist_view), + name="{}_{}_list".format(*info), + ), + url( + r"^add/$", + self.admin_site.admin_view(self.add_view), + name="{}_{}_add".format(*info), + ), + url( + r"^(?P\d+)/change/$", + self.admin_site.admin_view(self.change_view), + name="{}_{}_change".format(*info), + ), + url( + r"^(?P\d+)/preview/$", + self.admin_site.admin_view(SnippetPreviewView.as_view()), + name="{}_{}_preview".format(*info), + ), + ] + admin.site.register(Snippet, SnippetAdmin) diff --git a/djangocms_snippet/cms_config.py b/djangocms_snippet/cms_config.py index c23f2cd1..9d9d4629 100644 --- a/djangocms_snippet/cms_config.py +++ b/djangocms_snippet/cms_config.py @@ -3,6 +3,7 @@ from cms.app_base import CMSAppConfig from djangocms_snippet.models import Snippet +from djangocms_snippet.rendering import render_snippet try: @@ -15,12 +16,16 @@ class SnippetCMSAppConfig(CMSAppConfig): djangocms_versioning_enabled = getattr( - settings, 'DJANGOCMS_SNIPPET_VERSIONING_ENABLED', False + settings, 'DJANGOCMS_SNIPPET_VERSIONING_ENABLED', True ) djangocms_moderation_enabled = getattr( - settings, 'DJANGOCMS_SNIPPET_MODERATION_ENABLED', False + settings, 'DJANGOCMS_SNIPPET_MODERATION_ENABLED', True ) + cms_enabled = True + # cms toolbar enabled to allow for versioning compare view + cms_toolbar_enabled_models = [(Snippet, render_snippet), ] + if djangocms_moderation_enabled and djangocms_moderation_installed: moderated_models = [Snippet] diff --git a/djangocms_snippet/forms.py b/djangocms_snippet/forms.py new file mode 100644 index 00000000..8798c362 --- /dev/null +++ b/djangocms_snippet/forms.py @@ -0,0 +1,61 @@ +from django import forms +from django.db import transaction +from django.utils.translation import ugettext_lazy as _ + +from djangocms_snippet.cms_config import SnippetCMSAppConfig +from djangocms_snippet.models import Snippet, SnippetGrouper + + +try: + from djangocms_versioning import __version__ # NOQA + is_versioning_installed = True +except ImportError: + is_versioning_installed = False + +djangocms_versioning_enabled = SnippetCMSAppConfig.djangocms_versioning_enabled + + +class SnippetForm(forms.ModelForm): + class Meta: + model = Snippet + fields = ( + "name", + "html", + "slug", + "snippet_grouper", + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["snippet_grouper"].required = False + + def clean(self): + data = super().clean() + name = data.get("name") + slug = data.get("slug") + snippet_grouper = data.get("snippet_grouper") + snippet_queryset = Snippet.objects.all() + + if djangocms_versioning_enabled and is_versioning_installed: + if snippet_grouper: + snippet_queryset = snippet_queryset.exclude(snippet_grouper=snippet_grouper) + + for snippet in snippet_queryset: + if snippet.name == name: + self.add_error( + "name", _("A Snippet with this name already exists") + ) + elif snippet.slug == slug: + self.add_error( + "slug", _("A Snippet with this slug already exists") + ) + + return data + + @transaction.atomic + def save(self, **kwargs): + if not self.cleaned_data.get("snippet_grouper"): + super().save(commit=False) + self.save_m2m() + self.instance.snippet_grouper = SnippetGrouper.objects.create() + return super().save() diff --git a/djangocms_snippet/models.py b/djangocms_snippet/models.py index c5b0a34e..459cea08 100644 --- a/djangocms_snippet/models.py +++ b/djangocms_snippet/models.py @@ -1,5 +1,6 @@ from django.conf import settings from django.db import models +from django.shortcuts import reverse from django.utils.translation import gettext_lazy as _ from cms.models import CMSPlugin @@ -10,7 +11,15 @@ class SnippetGrouper(models.Model): - pass + @property + def name(self): + snippet_qs = Snippet._base_manager.filter( + snippet_grouper=self + ) + return snippet_qs.first().name or super().__str__ + + def __str__(self): + return self.name # Stores the actual data @@ -50,6 +59,14 @@ class Snippet(models.Model): def __str__(self): return self.name + def get_preview_url(self): + return reverse( + "admin:{app}_{model}_preview".format( + app=self._meta.app_label, model=self._meta.model_name, + ), + args=[self.id], + ) + class Meta: ordering = ['name'] verbose_name = _('Snippet') @@ -82,7 +99,3 @@ class Meta: @property def snippet(self): return self.snippet_grouper.snippet_set.first() - - def __str__(self): - # Return the referenced snippet's name rather than the default (ID #) - return self.snippet.name diff --git a/djangocms_snippet/rendering.py b/djangocms_snippet/rendering.py new file mode 100644 index 00000000..9525b4d5 --- /dev/null +++ b/djangocms_snippet/rendering.py @@ -0,0 +1,7 @@ +from django.template.response import TemplateResponse + + +def render_snippet(request, snippet): + template = 'djangocms_snippet/admin/preview.html' + context = {'snippet': snippet} + return TemplateResponse(request, template, context) diff --git a/djangocms_snippet/templates/djangocms_snippet/admin/preview.html b/djangocms_snippet/templates/djangocms_snippet/admin/preview.html new file mode 100644 index 00000000..723b71b5 --- /dev/null +++ b/djangocms_snippet/templates/djangocms_snippet/admin/preview.html @@ -0,0 +1,18 @@ +{% extends "admin/base_site.html" %} +{% load static %} +{{ snippet.html|safe|escape }} + +{% block extrastyle %} + {{ block.super }} + +{% endblock %} + +{% block coltype %}flex{% endblock %} + +{% block bodyclass %}{{ block.super }} app-{{ opts.app_label }} model-{{ opts.model_name }} change-list{% endblock %} + +{% block content %} + {% autoescape off %} + {{ snippet.html }} + {% endautoescape %} +{% endblock %} diff --git a/djangocms_snippet/views.py b/djangocms_snippet/views.py new file mode 100644 index 00000000..26edbe5f --- /dev/null +++ b/djangocms_snippet/views.py @@ -0,0 +1,26 @@ +from django.http import Http404 +from django.views.generic import TemplateView + +from djangocms_snippet.models import Snippet + + +class SnippetPreviewView(TemplateView): + template_name = "djangocms_snippet/admin/preview.html" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + snippet_id = kwargs.get("snippet_id", None) + + if not snippet_id: + Http404("snippet_id must be provided.") + + try: + snippet = Snippet._base_manager.get(pk=self.kwargs.get("snippet_id")) + except Snippet.DoesNotExist: + raise Http404 + + context.update({ + "snippet": snippet, + "opts": Snippet._meta + }) + return context diff --git a/tests/requirements/dj22_cms37.txt b/tests/requirements/dj22_cms37.txt deleted file mode 100644 index 6b96a102..00000000 --- a/tests/requirements/dj22_cms37.txt +++ /dev/null @@ -1,4 +0,0 @@ --r base.txt - -Django>=2.2,<3.0 -django-cms>=3.7,<3.8 diff --git a/tests/requirements/dj22_cms38.txt b/tests/requirements/dj22_cms38.txt deleted file mode 100644 index 22c4fef2..00000000 --- a/tests/requirements/dj22_cms38.txt +++ /dev/null @@ -1,4 +0,0 @@ --r base.txt - -Django>=2.2,<3.0 -django-cms>=3.8,<3.9 diff --git a/tests/requirements/dj30_cms37.txt b/tests/requirements/dj30_cms37.txt deleted file mode 100644 index edcfd450..00000000 --- a/tests/requirements/dj30_cms37.txt +++ /dev/null @@ -1,4 +0,0 @@ --r base.txt - -Django>=3.0,<3.1 -django-cms>=3.7,<3.8 diff --git a/tests/requirements/dj30_cms38.txt b/tests/requirements/dj30_cms38.txt deleted file mode 100644 index 7bc3c0be..00000000 --- a/tests/requirements/dj30_cms38.txt +++ /dev/null @@ -1,4 +0,0 @@ --r base.txt - -Django>=3.0,<3.1 -django-cms>=3.8,<3.9 diff --git a/tests/requirements/dj31_cms38.txt b/tests/requirements/dj31_cms38.txt deleted file mode 100644 index d17fdb42..00000000 --- a/tests/requirements/dj31_cms38.txt +++ /dev/null @@ -1,4 +0,0 @@ --r base.txt - -Django>=3.1,<3.2 -django-cms>=3.8,<3.9 diff --git a/tests/settings.py b/tests/settings.py index 67f86952..60b52c6d 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -15,6 +15,9 @@ 'ALLOWED_HOSTS': ['localhost'], 'DJANGOCMS_SNIPPET_VERSIONING_ENABLED': True, 'DJANGOCMS_SNIPPET_MODERATION_ENABLED': True, + 'CMS_TEMPLATES': ( + ("page.html", "Normal page"), + ), } diff --git a/tests/test_admin.py b/tests/test_admin.py new file mode 100644 index 00000000..0aced601 --- /dev/null +++ b/tests/test_admin.py @@ -0,0 +1,53 @@ +from importlib import reload + +from django.contrib import admin +from django.test import RequestFactory, override_settings + +from cms.test_utils.testcases import CMSTestCase + +from djangocms_snippet import admin as snippet_admin +from djangocms_snippet import cms_config +from djangocms_snippet.models import Snippet + +from .utils.factories import SnippetWithVersionFactory + + +class SnippetAdminTestCase(CMSTestCase): + def setUp(self): + self.snippet = SnippetWithVersionFactory() + self.snippet_admin = snippet_admin.SnippetAdmin(Snippet, admin) + self.snippet_admin_request = RequestFactory().get("/admin/djangocms_snippet") + + @override_settings(DJANGOCMS_SNIPPET_VERSIONING_ENABLED=False) + def test_admin_list_display_without_versioning(self): + """ + Without versioning enabled, list_display should not be extended with version related items + """ + admin.site.unregister(Snippet) + reload(cms_config) + reload(snippet_admin) + self.snippet_admin = snippet_admin.SnippetAdmin(Snippet, admin) + + list_display = self.snippet_admin.get_list_display(self.snippet_admin_request) + + self.assertEqual(self.snippet_admin.__class__.__bases__, (admin.ModelAdmin, )) + self.assertEqual(list_display, ('slug', 'name')) + + @override_settings(DJANGOCMS_SNIPPET_VERSIONING_ENABLED=True) + def test_admin_list_display_with_versioning(self): + """ + With versioning enabled, list_display should be populated with both versioning related items, and the + list actions items + """ + from djangocms_versioning.admin import ExtendedVersionAdminMixin + list_display = self.snippet_admin.get_list_display(self.snippet_admin_request) + + # Mixins should always come first in the class bases + self.assertEqual( + self.snippet_admin.__class__.__bases__, (ExtendedVersionAdminMixin, admin.ModelAdmin) + ) + self.assertEqual( + list_display[:-1], ('slug', 'name', 'get_author', 'get_modified_date', 'get_versioning_state') + ) + self.assertEqual(list_display[-1].short_description, 'actions') + self.assertIn("function ExtendedVersionAdminMixin._list_actions", list_display[-1].__str__()) diff --git a/tests/test_forms.py b/tests/test_forms.py new file mode 100644 index 00000000..d8653d99 --- /dev/null +++ b/tests/test_forms.py @@ -0,0 +1,159 @@ +from django.test import override_settings + +from cms.test_utils.testcases import CMSTestCase + +from djangocms_snippet.forms import SnippetForm +from djangocms_snippet.models import Snippet, SnippetGrouper + +from .utils.factories import SnippetWithVersionFactory + + +class SnippetFormTestCase(CMSTestCase): + + @override_settings(DJANGOCMS_SNIPPET_VERSIONING_ENABLED=False) + def test_snippet_form_creates_grouper_no_versioning(self): + """ + Without versioning enabled, the application still has the grouper implemented, therefore the form + should be creating one for each new snippet created. + """ + form_data = { + "name": "test_snippet", + "slug": "test_snippet", + "html": "

Test Title

" + } + form = SnippetForm(form_data) + + self.assertTrue(form.is_valid()) + + form.clean() + form.save() + + self.assertEqual(SnippetGrouper.objects.count(), 1) + self.assertEqual(Snippet._base_manager.count(), 1) + + @override_settings(DJANGOCMS_SNIPPET_VERSIONING_ENABLED=True) + def test_snippet_form_creates_grouper_with_versioning(self): + """ + With versioning enabled, groupers should also be created in the background. + """ + form_data = { + "name": "test_snippet", + "slug": "test_snippet", + "html": "

Test Title

" + } + form = SnippetForm(form_data) + + self.assertTrue(form.is_valid()) + + form.clean() + form.save() + + self.assertEqual(SnippetGrouper.objects.count(), 1) + self.assertEqual(Snippet._base_manager.count(), 1) + + @override_settings(DJANGOCMS_SNIPPET_VERSIONING_ENABLED=True) + def test_snippet_form_adds_to_existing_grouper_with_versioning(self): + """ + With versioning enabled, if a grouper already exists, a new one shouldn't be created + """ + + grouper = SnippetGrouper.objects.create() + form_data = { + "name": "test_snippet", + "slug": "test_snippet", + "html": "

Test Title

", + "snippet_grouper": grouper.id, + } + form = SnippetForm(form_data) + + self.assertTrue(form.is_valid()) + + form.clean() + form.save() + + self.assertEqual(SnippetGrouper.objects.count(), 1) + self.assertEqual(Snippet._base_manager.count(), 1) + + form_data["html"] = "

Test Title

" + + form = SnippetForm(form_data) + + self.assertTrue(form.is_valid()) + + form.clean() + form.save() + + self.assertEqual(SnippetGrouper.objects.count(), 1) + self.assertEqual(Snippet._base_manager.count(), 2) + + @override_settings(DJANGOCMS_SNIPPET_VERSIONING_ENABLED=True) + def test_snippet_form_versioning_enabled(self): + """ + With versioning enabled, the snippet form doesn't have to create groupers, but does have to validate + that no other active (i.e. the latest published snippet from a given grouper) shares the same name or slug. + """ + form_data = { + "name": "test_snippet", + "slug": "test_snippet", + "html": "

Test Title

", + } + form = SnippetForm(form_data) + + self.assertTrue(form.is_valid()) + + # Clean and save the form + form.clean() + form.save() + + # Publish the old created version + snippet = Snippet._base_manager.last() + version = snippet.versions.create(created_by=self.get_superuser()) + version.publish(user=self.get_superuser()) + + new_form_data = { + "name": "test_snippet1", + "slug": "test_snippet", + "html": "

Another Test Title

", + } + + new_form = SnippetForm(new_form_data) + + self.assertFalse(new_form.is_valid()) + + new_form.clean() + + self.assertDictEqual(new_form.errors, {'slug': ['A Snippet with this slug already exists']}) + + def test_snippet_form_validation_multiple_version_states_in_grouper(self): + """ + Snippet forms should be valid regardless of the versions, or states which already exist within its grouper. + """ + # snippet_to_archive starts as draft + snippet_to_archive = SnippetWithVersionFactory() + # Then it is published it + snippet_to_archive.versions.first().publish(user=self.get_superuser()) + # snippet_to_publish starts as a draft + snippet_to_publish = SnippetWithVersionFactory( + name=snippet_to_archive.name, + slug=snippet_to_archive.slug, + snippet_grouper=snippet_to_archive.snippet_grouper + ) + # Snippet_to_publish is published, archiving snippet_to_archive + snippet_to_publish.versions.first().publish(user=self.get_superuser()) + # Create a new draft in the same grouper + SnippetWithVersionFactory( + name=snippet_to_archive.name, + slug=snippet_to_archive.slug, + snippet_grouper=snippet_to_archive.snippet_grouper + ) + + form_data = { + "name": snippet_to_archive.name, + "slug": snippet_to_archive.slug, + "html": "

Hello World!

", + "snippet_grouper": snippet_to_archive.snippet_grouper.id, + } + + form = SnippetForm(form_data) + + self.assertTrue(form.is_valid()) diff --git a/tests/test_models.py b/tests/test_models.py index fd3cdec6..c10f5cae 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -41,4 +41,4 @@ def test_snippet_ptr_instance(self): instance = SnippetPtr.objects.first() # test strings - self.assertEqual(str(instance), "test snippet") + self.assertEqual(instance.snippet_grouper.name, "test snippet") diff --git a/tests/test_plugins.py b/tests/test_plugins.py index db1a0fe3..ba901439 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -1,9 +1,7 @@ from cms.api import add_plugin, create_page from cms.test_utils.testcases import CMSTestCase -from .utils.factories import ( - SnippetWithVersionFactory, -) +from .utils.factories import SnippetWithVersionFactory class SnippetPluginsTestCase(CMSTestCase): diff --git a/tests/test_views.py b/tests/test_views.py new file mode 100644 index 00000000..ce3c7e5a --- /dev/null +++ b/tests/test_views.py @@ -0,0 +1,38 @@ +from cms.test_utils.testcases import CMSTestCase +from cms.utils.urlutils import admin_reverse + +from .utils.factories import SnippetWithVersionFactory + + +class PreviewViewTestCase(CMSTestCase): + def setUp(self): + self.snippet = SnippetWithVersionFactory(html="

Test Title


Test paragraph

") + self.user = self.get_superuser() + + def test_preview_renders_html(self): + """ + Check that our snippet HTML is rendered, unescaped, on the page + """ + preview_url = admin_reverse( + "djangocms_snippet_snippet_preview", + kwargs={"snippet_id": self.snippet.id}, + ) + with self.login_user_context(self.user): + response = self.client.get(preview_url) + + self.assertEqual(self.snippet.html, "

Test Title


Test paragraph

") + self.assertEqual(response.status_code, 200) + self.assertContains(response, "

Test Title


Test paragraph

") + + def test_preview_raises_404_no_snippet(self): + """ + With no Snippet to preview, a 404 will be raised + """ + preview_url = admin_reverse( + "djangocms_snippet_snippet_preview", + kwargs={"snippet_id": 999}, # Non existent PK! + ) + with self.login_user_context(self.user): + response = self.client.get(preview_url) + + self.assertEqual(response.status_code, 404)