diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 8f0dc786..836e80d6 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -26,5 +26,6 @@ Use 'x' to check each item: [x] I have ... * [ ] I have opened this pull request against ``master`` * [ ] I have added or modified the tests when changing logic * [ ] I have followed [the conventional commits guidelines](https://www.conventionalcommits.org/) to add meaningful information into the changelog -* [ ] I have read the [contribution guidelines ](https://github.com/django-cms/django-cms/blob/develop/CONTRIBUTING.rst) and I have joined #workgroup-pr-review on -[Slack](https://www.django-cms.org/slack) to find a “pr review buddy” who is going to review my pull request. +* [ ] I have read the [contribution guidelines ](https://github.com/django-cms/django-cms/blob/develop/CONTRIBUTING.rst) and I have joined #pr-review on +[Discord](https://discord-pr-review-channel.django-cms.org/) to find a “pr review buddy” who is + going to review my pull request. diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index a807a968..b9fdf95f 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -27,4 +27,4 @@ jobs: pip install ruff - name: Run Ruff working-directory: ./src - run: ruff djangocms_snippet + run: ruff check djangocms_snippet diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f6fe9219..b80afec0 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -2,8 +2,6 @@ name: CodeCov on: push: - branches: - - master pull_request: concurrency: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 89254d1b..6435ba17 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -43,8 +43,3 @@ repos: - id: check-toml - id: end-of-file-fixer - id: trailing-whitespace - -# - repo: https://github.com/pre-commit/mirrors-mypy -# rev: v1.3.0 -# hooks: -# - id: mypy diff --git a/CHANGELOG.rst b/CHANGELOG.rst index acbec673..0c80af8f 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,8 +5,64 @@ Changelog Unreleased ========== -* Drop support for Django < 3.2 -* Drop support for Python < 3.9 +* feat: Universal support for django CMS 3.11 and 4.x + + +4.1.0 (2024-05-16) +================== + +* feat: Added sites support for Snippets +* add support for python 3.10 +* add support for django >= 4.2 +* drop support for django < 3.2 +* drop support python < 3.8 + + +4.0.1.dev2 (2022-11-15) +======================= + +* feat: Enable add button to create a snippet when adding a SnippetPlugin + + +4.0.1.dev1 (2022-05-10) +======================= + +* 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 +* port-feat: pre-commit config added from the v3 workstream +* fix: Added test coverage to admin preview view + + +4.0.0.dev4 (2022-02-03) +======================= + +* feat: Preview icon renders form in read only mode + + +4.0.0.dev3 (2022-01-11) +======================= + +* fix: Snippet plugin added to a page now displays name instead of ID +* fix: Slug field on list display for admin should only be displayed when versioning is not available +* fix: Removed unused contents within templates, reducing the clutter within version compare views. Previously this was causing a lot of junk to be included in the version comparison, this will now be reduced. + + +4.0.0.dev2 (2021-12-22) +======================= + +* fix: Removed tight django-treebeard restriction added when 4.5.0 contained breaking changes. The core CMS and django-treebeard have since been patched to resolve the issue. + + +4.0.0.dev1 (2021-12-14) +======================= + +* feat: Exposed the setting DJANGOCMS_SNIPPET_VERSIONING_MIGRATION_USER_ID as an environment variable for the Divio addon +* fix: Error when rendering a Draft Snippet plugin on a Published page +* fix: Publish snippets by default as they were already in that state pre-versioning and cleanup unnecessary migration files before release! +* feat: djangocms-versioning support added, including model restructure and configuration +* feat: django-cms v4.0.x support added 3.1.1 ===== diff --git a/MANIFEST.in b/MANIFEST.in index 94c37640..a7c6b987 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -3,4 +3,4 @@ include CHANGELOG.rst include LICENSE include pyproject.toml include README.rst -include src/*/py.typed +recursive-include src/djangocms_snippet * diff --git a/README.rst b/README.rst index 73a61098..2bad742f 100644 --- a/README.rst +++ b/README.rst @@ -2,7 +2,7 @@ django CMS Snippet ================== -|pypi| |coverage| |python| |django| |djangocms| +|pypi| |coverage| |python| |django| |djangocms| |django-cms4| **django CMS Snippet** provides a plugin for `django CMS `_ @@ -89,6 +89,12 @@ please set ``DJANGOCMS_SNIPPET_CACHE`` to ``False`` in your settings:: DJANGOCMS_SNIPPET_CACHE = False # default value is False +Migration 0010 requires the use of a user in order to create versions for existing snippets (if djangocms_versioning is installed and enabled), +a user can be chosen with the setting ``DJANGOCMS_SNIPPET_VERSIONING_MIGRATION_USER_ID``, the default is 1. +This setting is also exposed as an Environment variable for Divio projects using the Divio addon. + + DJANGOCMS_SNIPPET_VERSIONING_MIGRATION_USER_ID = 2 # Will use user with id: 2 + Template tag ------------ @@ -125,9 +131,11 @@ You can run tests by executing:: :target: http://badge.fury.io/py/djangocms-snippet .. |coverage| image:: https://codecov.io/gh/django-cms/djangocms-snippet/branch/master/graph/badge.svg :target: https://codecov.io/gh/django-cms/djangocms-snippet -.. |python| image:: https://img.shields.io/badge/python-3.5+-blue.svg +.. |python| image:: https://img.shields.io/badge/python-3.9+-blue.svg :target: https://pypi.org/project/djangocms-snippet/ -.. |django| image:: https://img.shields.io/badge/django-2.2,%203.0,%203.1-blue.svg +.. |django| image:: https://img.shields.io/badge/django-3.2+-blue.svg :target: https://www.djangoproject.com/ .. |djangocms| image:: https://img.shields.io/badge/django%20CMS-3.7%2B-blue.svg :target: https://www.django-cms.org/ +.. |djangocms4| image:: https://img.shields.io/badge/django%20CMS-4-blue.svg + :target: https://www.django-cms.org/ diff --git a/aldryn_config.py b/aldryn_config.py index 981c5e67..d6baf3c1 100644 --- a/aldryn_config.py +++ b/aldryn_config.py @@ -1,3 +1,5 @@ +from functools import partial + from aldryn_client import forms @@ -17,6 +19,15 @@ class Form(forms.BaseForm): ) def to_settings(self, data, settings): + from aldryn_addons.utils import djsenv + + env = partial(djsenv, settings=settings) + + # Get a migration user if the env setting has been added + migration_user_id = env("DJANGOCMS_SNIPPET_VERSIONING_MIGRATION_USER_ID", default=False) + if migration_user_id: + settings["DJANGOCMS_SNIPPET_VERSIONING_MIGRATION_USER_ID"] = int(migration_user_id) + if data["editor_theme"]: settings["DJANGOCMS_SNIPPET_THEME"] = data["editor_theme"] if data["editor_mode"]: diff --git a/pyproject.toml b/pyproject.toml index fc015bfc..8c9c06c5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "djangocms-snippet" -version = "3.1.1" +dynamic = ["version"] authors = [ {name = "Divio AG", email = "info@divio.ch"}, ] @@ -26,9 +26,9 @@ classifiers=[ "Operating System :: OS Independent", "Programming Language :: Python", "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.8", "Programming Language :: Python :: 3.9", "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", "Framework :: Django", "Framework :: Django :: 3.2", "Framework :: Django :: 4.2", @@ -37,6 +37,8 @@ classifiers=[ "Framework :: Django CMS :: 3.9", "Framework :: Django CMS :: 3.10", "Framework :: Django CMS :: 3.11", + "Framework :: Django CMS :: 4.0", + "Framework :: Django CMS :: 4.1", "Topic :: Internet :: WWW/HTTP", "Topic :: Internet :: WWW/HTTP :: Dynamic Content", "Topic :: Software Development", @@ -91,8 +93,8 @@ allow_untyped_defs = true [tool.ruff] # https://beta.ruff.rs/docs/configuration/ -line-length = 79 -select = [ +line-length = 120 +lint.select = [ "E", # pycodestyle errors "W", # pycodestyle warnings "F", # pyflakes @@ -126,17 +128,20 @@ exclude = [ "venv", ] -ignore = [ +lint.ignore = [ "E501", # line-too-long "W191", # tab-indentation ] -[tool.ruff.per-file-ignores] +[tool.ruff.lint.per-file-ignores] "__init__.py" = [ "F401" # unused-import ] +"test_plugins.py" = [ + "FBT003", # Boolean positional value in function call +] -[tool.ruff.isort] +[tool.ruff.lint.isort] combine-as-imports = true known-first-party = [ "djangocms_snippet", diff --git a/requirements.in b/requirements.in index 664cf5d1..f9de1b07 100644 --- a/requirements.in +++ b/requirements.in @@ -1,6 +1,6 @@ bump2version django-cms>=3.7 -django-treebeard>=4.3,<4.5 +django-treebeard pip-tools pre-commit wheel diff --git a/requirements.txt b/requirements.txt index 462a0650..b6070459 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,72 +1,85 @@ # -# This file is autogenerated by pip-compile with python 3.9 -# To update, run: +# This file is autogenerated by pip-compile with Python 3.9 +# by the following command: # -# pip-compile +# pip-compile --output-file=requirements.txt requirements.in # -asgiref==3.6.0 +asgiref==3.8.1 # via django +build==1.2.1 + # via pip-tools bump2version==1.0.1 # via -r requirements.in -cfgv==3.3.1 +cfgv==3.4.0 # via pre-commit -click==8.0.3 +click==8.1.7 # via pip-tools -distlib==0.3.4 +distlib==0.3.8 # via virtualenv -django==4.1.10 +django==4.2.13 # via # django-classy-tags # django-cms # django-formtools # django-sekizai # django-treebeard -django-classy-tags==3.0.0 + # djangocms-admin-style +django-classy-tags==4.1.0 # via # django-cms # django-sekizai -django-cms==3.9.0 +django-cms==4.1.1 # via -r requirements.in -django-formtools==2.3 +django-formtools==2.5.1 # via django-cms -django-sekizai==3.0.0 +django-sekizai==4.1.0 # via django-cms -django-treebeard==4.4 +django-treebeard==4.7.1 # via # -r requirements.in # django-cms -djangocms-admin-style==3.0.0 +djangocms-admin-style==3.3.1 # via django-cms -filelock==3.4.2 +filelock==3.14.0 # via virtualenv -identify==2.4.4 +identify==2.5.36 # via pre-commit -nodeenv==1.6.0 +importlib-metadata==7.1.0 + # via build +nodeenv==1.8.0 # via pre-commit -pep517==0.12.0 - # via pip-tools -pip-tools==6.4.0 +packaging==24.0 + # via + # build + # django-cms +pip-tools==7.4.1 # via -r requirements.in -platformdirs==2.4.1 +platformdirs==4.2.2 # via virtualenv -pre-commit==2.17.0 +pre-commit==3.7.1 # via -r requirements.in -pyyaml==6.0 +pyproject-hooks==1.1.0 + # via + # build + # pip-tools +pyyaml==6.0.1 # via pre-commit -six==1.16.0 - # via virtualenv sqlparse==0.5.0 # via django -toml==0.10.2 - # via pre-commit -tomli==2.0.0 - # via pep517 -virtualenv==20.13.0 +tomli==2.0.1 + # via + # build + # pip-tools +typing-extensions==4.11.0 + # via asgiref +virtualenv==20.26.2 # via pre-commit -wheel==0.38.1 +wheel==0.43.0 # via # -r requirements.in # pip-tools +zipp==3.18.2 + # via importlib-metadata # The following packages are considered to be unsafe in a requirements file: # pip diff --git a/setup.cfg b/setup.cfg index 2b640dc8..9933bbf1 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,5 @@ [bumpversion] -current_version = 3.1.0 +current_version = 5.0.0a commit = True tag = False diff --git a/src/djangocms_snippet/__init__.py b/src/djangocms_snippet/__init__.py index d539d50c..10daf977 100644 --- a/src/djangocms_snippet/__init__.py +++ b/src/djangocms_snippet/__init__.py @@ -1 +1 @@ -__version__ = "3.1.1" +__version__ = "5.0.0a1" diff --git a/src/djangocms_snippet/admin.py b/src/djangocms_snippet/admin.py index 5c34d7a7..ce540a2b 100644 --- a/src/djangocms_snippet/admin.py +++ b/src/djangocms_snippet/admin.py @@ -1,25 +1,52 @@ from typing import Any, ClassVar +from cms.utils import get_current_site +from cms.utils.permissions import get_model_permission_codename from django.conf import settings from django.contrib import admin +from django.contrib.admin import helpers +from django.contrib.admin.exceptions import DisallowedModelAdminToField +from django.contrib.admin.options import IS_POPUP_VAR, TO_FIELD_VAR +from django.contrib.admin.utils import flatten_fieldsets, unquote from django.db import models from django.forms import Textarea +from django.urls import path +from django.utils.translation import gettext as _ +from .forms import SnippetForm from .models import Snippet +# Use the version mixin if djangocms-versioning is installed and enabled +snippet_admin_classes = [admin.ModelAdmin] +djangocms_versioning_enabled = getattr(settings, "DJANGOCMS_SNIPPET_VERSIONING_ENABLED", True) + +try: + try: + from djangocms_versioning.admin import ( + ExtendedIndicatorVersionAdminMixin, + ) + except ImportError: + from djangocms_versioning.admin import ( + ExtendedVersionAdminMixin as ExtendedIndicatorVersionAdminMixin, + ) + + if djangocms_versioning_enabled: + snippet_admin_classes.insert(0, ExtendedIndicatorVersionAdminMixin) +except ImportError: + djangocms_versioning_enabled = False + @admin.register(Snippet) -class SnippetAdmin(admin.ModelAdmin): +class SnippetAdmin(*snippet_admin_classes): class Media: js = ( "admin/vendor/ace/ace.js" if "djangocms_static_ace" in settings.INSTALLED_APPS - else "https://cdnjs.cloudflare.com/ajax/libs/ace/1.9.6/ace.js", + else "https://cdnjs.cloudflare.com/ajax/libs/ace/1.33.3/ace.js", ) - list_display = ("slug", "name") - search_fields: ClassVar[list[str]] = ["slug", "name"] - prepopulated_fields: ClassVar[dict[str, list[str]]] = {"slug": ("name",)} + list_display = ("name",) + search_fields: ClassVar[list[str]] = ["name"] change_form_template = "djangocms_snippet/admin/change_form.html" text_area_attrs: ClassVar[dict[str, Any]] = { "rows": 20, @@ -27,7 +54,135 @@ class Media: "data-mode": getattr(settings, "DJANGOCMS_SNIPPET_THEME", "html"), "data-theme": getattr(settings, "DJANGOCMS_SNIPPET_MODE", "github"), } + form = SnippetForm + formfield_overrides: ClassVar[dict] = {models.TextField: {"widget": Textarea(attrs=text_area_attrs)}} + # This was move here from model, otherwise first() and last() return the same when handling grouper queries + ordering = ("name",) - formfield_overrides: ClassVar[dict[Any, dict[str, Any]]] = { - models.TextField: {"widget": Textarea(attrs=text_area_attrs)} - } + class Meta: + model = Snippet + + def get_queryset(self, request): + site = get_current_site() + queryset = super().get_queryset(request) + # Filter queryset with current site and no site + queryset = queryset.filter(models.Q(site=site) | models.Q(site=None)) + return queryset + + def get_list_display(self, request): + list_display = super().get_list_display(request) + list_display = list(list_display) + + if not djangocms_versioning_enabled: + list_display.insert(0, "slug") + + list_display = tuple(list_display) + return list_display + + def get_search_fields(self, request): + search_fields = super().get_search_fields(request) + if not djangocms_versioning_enabled: + search_fields.append("slug") + return search_fields + + def get_prepopulated_fields(self, obj, request): + prepopulated_fields = super().get_prepopulated_fields(request) + if not djangocms_versioning_enabled: + prepopulated_fields = {"slug": ("name",)} + return prepopulated_fields + + def get_list_display_links(self, request, list_display): + if not djangocms_versioning_enabled: + return list(list_display)[:1] + else: + self.list_display_links = (None,) + return self.list_display_links + + def preview_view(self, request, snippet_id=None, form_url="", extra_context=None): + """ + Custom preview endpoint to display a change form in read only mode + Solution based on django changeform view implementation + https://github.com/django/django/blob/4b8e9492d9003ca357a4402f831112dd72efd2f8/django/contrib/admin/options.py#L1553 + """ + to_field = request.POST.get(TO_FIELD_VAR, request.GET.get(TO_FIELD_VAR)) + + if to_field and not self.to_field_allowed(request, to_field): + raise DisallowedModelAdminToField(f"The field {to_field} cannot be referenced.") + + model = self.model + opts = model._meta + + obj = self.get_object(request, unquote(str(snippet_id)), to_field) + + if obj is None: + return self._get_obj_does_not_exist_redirect(request, opts, str(snippet_id)) + + fieldsets = self.get_fieldsets(request, obj) + model_form = self.get_form(request, obj, change=False, fields=flatten_fieldsets(fieldsets)) + form = model_form(instance=obj) + formsets, inline_instances = self._create_formsets(request, obj, change=True) + + readonly_fields = flatten_fieldsets(fieldsets) + + admin_form = helpers.AdminForm( + form, + list(fieldsets), + # Clear prepopulated fields on a view-only form to avoid a crash. + {}, + readonly_fields, + model_admin=self, + ) + media = self.media + admin_form.media + + inline_formsets = self.get_inline_formsets(request, formsets, inline_instances, obj) + for inline_formset in inline_formsets: + media = media + inline_formset.media + + title = _("View %s") + context = { + **self.admin_site.each_context(request), + "title": title % opts.verbose_name, + "subtitle": str(obj) if obj else None, + "adminform": admin_form, + "object_id": snippet_id, + "original": obj, + "is_popup": IS_POPUP_VAR in request.POST or IS_POPUP_VAR in request.GET, + "to_field": to_field, + "media": media, + "inline_admin_formsets": inline_formsets, + "errors": [], + "preserved_filters": self.get_preserved_filters(request), + } + + context.update(extra_context or {}) + + return self.render_change_form( + request, + context, + add=False, + change=False, + obj=obj, + form_url=form_url, + ) + + def get_urls(self): + return [ + path( + "/preview/", + self.admin_site.admin_view(self.preview_view), + name=f"{self.model._meta.app_label}_{self.model._meta.model_name}_preview", + ), + *super().get_urls(), + ] + + def has_delete_permission(self, request, obj=None): + """ + When versioning is enabled, delete option is not available. + If versioning is disabled, it may be possible to delete, as long as a user also has add permissions, and they + are not in use. + """ + if obj and not djangocms_versioning_enabled: + return request.user.has_perm( + get_model_permission_codename(self.model, "add"), + ) + return False diff --git a/src/djangocms_snippet/cms_config.py b/src/djangocms_snippet/cms_config.py new file mode 100644 index 00000000..6aa0fe50 --- /dev/null +++ b/src/djangocms_snippet/cms_config.py @@ -0,0 +1,38 @@ +from cms.app_base import CMSAppConfig +from django.conf import settings + +from djangocms_snippet.models import Snippet +from djangocms_snippet.rendering import render_snippet + +try: + from djangocms_moderation import __version__ # NOQA: F401 + + djangocms_moderation_installed = True +except ImportError: + djangocms_moderation_installed = False + + +class SnippetCMSAppConfig(CMSAppConfig): + djangocms_versioning_enabled = getattr(settings, "DJANGOCMS_SNIPPET_VERSIONING_ENABLED", True) + djangocms_moderation_enabled = getattr(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] + + if djangocms_versioning_enabled: + from djangocms_versioning.datastructures import ( + VersionableItem, + default_copy, + ) + + versioning = [ + VersionableItem( + content_model=Snippet, + grouper_field_name="snippet_grouper", + copy_function=default_copy, + ) + ] diff --git a/src/djangocms_snippet/cms_plugins.py b/src/djangocms_snippet/cms_plugins.py index e6b62b11..877002b9 100644 --- a/src/djangocms_snippet/cms_plugins.py +++ b/src/djangocms_snippet/cms_plugins.py @@ -6,7 +6,9 @@ from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ +from .forms import SnippetPluginForm from .models import SnippetPtr +from .utils import show_draft_content CACHE_ENABLED = getattr(settings, "DJANGOCMS_SNIPPET_CACHE", False) @@ -18,22 +20,27 @@ class SnippetPlugin(CMSPluginBase): text_enabled = True text_editor_preview = False cache = CACHE_ENABLED + form = SnippetPluginForm def render(self, context, instance, placeholder): + snippet = instance.snippet_grouper.snippet(show_editable=show_draft_content(context["request"])) + + # Handle the potential for no snippet to be found i.e. Draft + if not snippet: + return context + try: - if instance.snippet.template: + if snippet.template: context = context.flatten() - context.update({"html": mark_safe(instance.snippet.html)}) - t = template.loader.get_template(instance.snippet.template) + context.update({"html": mark_safe(snippet.html)}) + t = template.loader.get_template(snippet.template) content = t.render(context) else: # only html provided - t = template.Template(instance.snippet.html) + t = template.Template(snippet.html) content = t.render(context) except template.TemplateDoesNotExist: - content = _("Template %(template)s does not exist.") % { - "template": instance.snippet.template - } + content = _("Template %(template)s does not exist.") % {"template": snippet.template} except Exception as e: content = escape(str(e)) @@ -41,7 +48,7 @@ def render(self, context, instance, placeholder): { "placeholder": placeholder, "object": instance, - "html": mark_safe(instance.snippet.html), + "html": mark_safe(snippet.html), "content": content, } ) diff --git a/src/djangocms_snippet/conf.py b/src/djangocms_snippet/conf.py new file mode 100644 index 00000000..e69de29b diff --git a/src/djangocms_snippet/forms.py b/src/djangocms_snippet/forms.py new file mode 100644 index 00000000..d2638682 --- /dev/null +++ b/src/djangocms_snippet/forms.py @@ -0,0 +1,80 @@ +from cms.utils.urlutils import admin_reverse +from django import forms +from django.contrib import admin +from django.db import transaction +from django.utils.translation import gettext_lazy as _ + +from djangocms_snippet.models import Snippet, SnippetGrouper, SnippetPtr +from djangocms_snippet.utils import ( + djangocms_versioning_enabled, + is_versioning_installed, +) + + +class SnippetForm(forms.ModelForm): + class Meta: + model = Snippet + fields = ( + "name", + "html", + "slug", + "snippet_grouper", + "template", + "site", + ) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + if self.fields.get("snippet_grouper"): + self.fields["snippet_grouper"].required = False + self.fields["snippet_grouper"].widget = forms.HiddenInput() + + 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 and 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): + commit = kwargs.get("commit", True) + snippet = super().save(commit=False) + if not hasattr(snippet, "snippet_grouper"): + snippet.snippet_grouper = SnippetGrouper.objects.create() + if commit: + snippet.save() + return snippet + + +class SnippetPluginForm(forms.ModelForm): + class Meta: + model = SnippetPtr + fields = ("cmsplugin_ptr", "snippet_grouper") + + def __init__(self, *args, **kwargs): + """ + Initialise the form with the add button enabled to allow adding a new snippet from the plugin form. To enable + this the get_related_url method on the widget is overridden to build a URL for the Snippet admin instead of + the SnippetGrouper, as this is not enabled in the admin. + """ + super().__init__(*args, **kwargs) + self.fields["snippet_grouper"].widget.can_add_related = True + self.fields["snippet_grouper"].widget.get_related_url = self.get_related_url_for_snippet + + def get_related_url_for_snippet(self, info, action, *args): + """ + Build URL to the Snippet admin for the given action + """ + return admin_reverse(f"djangocms_snippet_snippet_{action}", current_app=admin.site.name, args=args) diff --git a/src/djangocms_snippet/migrations/0005_set_related_name_for_cmsplugin_ptr.py b/src/djangocms_snippet/migrations/0005_set_related_name_for_cmsplugin_ptr.py index 77ca9123..f27fa954 100644 --- a/src/djangocms_snippet/migrations/0005_set_related_name_for_cmsplugin_ptr.py +++ b/src/djangocms_snippet/migrations/0005_set_related_name_for_cmsplugin_ptr.py @@ -12,6 +12,6 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='snippetptr', name='cmsplugin_ptr', - field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, related_name='djangocms_snippet_snippetptr', serialize=False, to='cms.CMSPlugin'), + field=models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, related_name='%(app_label)s_%(class)s', serialize=False, to='cms.cmsplugin'), ), ] diff --git a/src/djangocms_snippet/migrations/0009_auto_20210915_0445.py b/src/djangocms_snippet/migrations/0009_auto_20210915_0445.py new file mode 100644 index 00000000..ee7ffd67 --- /dev/null +++ b/src/djangocms_snippet/migrations/0009_auto_20210915_0445.py @@ -0,0 +1,30 @@ +# Generated by Django 2.2.24 on 2021-09-15 04:45 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('djangocms_snippet', '0008_auto_change_name'), + ] + + operations = [ + migrations.CreateModel( + name='SnippetGrouper', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ], + ), + migrations.AddField( + model_name='snippet', + name='snippet_grouper', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='djangocms_snippet.SnippetGrouper'), + ), + migrations.AddField( + model_name='snippetptr', + name='snippet_grouper', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='djangocms_snippet.SnippetGrouper'), + ), + ] diff --git a/src/djangocms_snippet/migrations/0010_cms4_grouper_version_data_migration.py b/src/djangocms_snippet/migrations/0010_cms4_grouper_version_data_migration.py new file mode 100644 index 00000000..1354cb0a --- /dev/null +++ b/src/djangocms_snippet/migrations/0010_cms4_grouper_version_data_migration.py @@ -0,0 +1,65 @@ +from django.apps import apps as global_apps +from django.conf import settings +from django.contrib.contenttypes.management import create_contenttypes +from django.db import migrations + + +try: + from djangocms_versioning.constants import DRAFT, PUBLISHED + + djangocms_versioning_installed = True +except ImportError: + djangocms_versioning_installed = False + +djangocms_versioning_config_enabled = getattr( + settings, 'DJANGOCMS_SNIPPET_VERSIONING_ENABLED', True +) + + +def cms4_grouper_version_migration(apps, schema_editor): + create_contenttypes(global_apps.get_app_config("djangocms_snippet")) + + + + ContentType = apps.get_model('contenttypes', 'ContentType') + Snippet = apps.get_model('djangocms_snippet', 'Snippet') + SnippetGrouper = apps.get_model('djangocms_snippet', 'SnippetGrouper') + User = apps.get_model(*settings.AUTH_USER_MODEL.split('.')) + + snippet_contenttype = ContentType.objects.get(app_label='djangocms_snippet', model='snippet') + snippet_queryset = Snippet.objects.all() + + # Get a migration user to create a version. + if djangocms_versioning_config_enabled and djangocms_versioning_installed and len(snippet_queryset): + Version = apps.get_model('djangocms_versioning', 'Version') + migration_user = User.objects.get(id=getattr(settings, "DJANGOCMS_SNIPPET_VERSIONING_MIGRATION_USER_ID", 1)) + + for snippet in snippet_queryset: + grouper = SnippetGrouper.objects.create() + snippet.snippet_grouper = grouper + snippet.save() + + # Create initial Snippet Versions if versioning is enabled and installed. + # Publish the snippet because all snippets were assumed published before + if djangocms_versioning_config_enabled and djangocms_versioning_installed: + Version.objects.create( + created_by=migration_user, + state=PUBLISHED, + number=1, + object_id=snippet.pk, + content_type=snippet_contenttype, + ) + + +class Migration(migrations.Migration): + dependencies = [ + # ('cms', '0034_remove_pagecontent_placeholders'), # Run after the CMS4 migrations + ('djangocms_snippet', '0009_auto_20210915_0445'), + ] + + if djangocms_versioning_installed: + dependencies += [('djangocms_versioning', '0015_version_modified'), ] + + operations = [ + migrations.RunPython(cms4_grouper_version_migration) + ] diff --git a/src/djangocms_snippet/migrations/0011_cms4_plugin_data_migration.py b/src/djangocms_snippet/migrations/0011_cms4_plugin_data_migration.py new file mode 100644 index 00000000..449236d8 --- /dev/null +++ b/src/djangocms_snippet/migrations/0011_cms4_plugin_data_migration.py @@ -0,0 +1,22 @@ +# Generated by Django 2.2.24 on 2021-08-31 10:45 +from django.db import migrations + + +def cms4_migration(apps, schema_editor): + SnippetPtr = apps.get_model('djangocms_snippet', 'SnippetPtr') + + for snippet_plugin in SnippetPtr.objects.all(): + snippet = snippet_plugin.snippet + snippet_plugin.snippet_grouper = snippet.snippet_grouper + snippet_plugin.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('djangocms_snippet', '0010_cms4_grouper_version_data_migration'), + ] + + operations = [ + migrations.RunPython(cms4_migration) + ] diff --git a/src/djangocms_snippet/migrations/0012_auto_20210915_0721.py b/src/djangocms_snippet/migrations/0012_auto_20210915_0721.py new file mode 100644 index 00000000..bf228431 --- /dev/null +++ b/src/djangocms_snippet/migrations/0012_auto_20210915_0721.py @@ -0,0 +1,42 @@ +# Generated by Django 2.2.24 on 2021-09-15 07:21 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('djangocms_snippet', '0011_cms4_plugin_data_migration'), + ] + + operations = [ + migrations.RemoveField( + model_name='snippetptr', + name='snippet', + ), + migrations.AlterField( + model_name='snippet', + name='name', + field=models.CharField(max_length=255, verbose_name='Name'), + ), + migrations.AlterField( + model_name='snippet', + name='slug', + field=models.SlugField(default='', max_length=255, verbose_name='Slug'), + ), + migrations.AlterModelOptions( + name='snippet', + options={'verbose_name': 'Snippet', 'verbose_name_plural': 'Snippets'}, + ), + migrations.AlterField( + model_name='snippetptr', + name='snippet_grouper', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='djangocms_snippet.SnippetGrouper'), + ), + migrations.AlterField( + model_name='snippet', + name='snippet_grouper', + field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='djangocms_snippet.SnippetGrouper'), + ), + ] diff --git a/src/djangocms_snippet/migrations/0013_snippet_site.py b/src/djangocms_snippet/migrations/0013_snippet_site.py new file mode 100644 index 00000000..a7948851 --- /dev/null +++ b/src/djangocms_snippet/migrations/0013_snippet_site.py @@ -0,0 +1,20 @@ +# Generated by Django 4.2.11 on 2024-05-07 03:43 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('sites', '0002_alter_domain_unique'), + ('djangocms_snippet', '0012_auto_20210915_0721'), + ] + + operations = [ + migrations.AddField( + model_name='snippet', + name='site', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='sites.site'), + ), + ] diff --git a/src/djangocms_snippet/migrations/0014_merge_20240519_2117.py b/src/djangocms_snippet/migrations/0014_merge_20240519_2117.py new file mode 100644 index 00000000..1585cc8b --- /dev/null +++ b/src/djangocms_snippet/migrations/0014_merge_20240519_2117.py @@ -0,0 +1,40 @@ +# Generated by Django 5.0.1 on 2024-05-19 21:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("djangocms_snippet", "0010_alter_snippet_id"), + ("djangocms_snippet", "0013_snippet_site"), + ] + + operations = [ + migrations.AlterModelOptions( + name="snippet", + options={ + "ordering": ["name"], + "verbose_name": "Snippet", + "verbose_name_plural": "Snippets", + }, + ), + migrations.AlterField( + model_name="snippet", + name="name", + field=models.CharField(max_length=255, verbose_name="Name"), + ), + migrations.AlterField( + model_name="snippet", + name="slug", + field=models.SlugField( + default="", max_length=255, verbose_name="Slug" + ), + ), + migrations.AlterField( + model_name="snippetgrouper", + name="id", + field=models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ] diff --git a/src/djangocms_snippet/migrations/0015_alter_snippetgrouper_id.py b/src/djangocms_snippet/migrations/0015_alter_snippetgrouper_id.py new file mode 100644 index 00000000..10ad7f83 --- /dev/null +++ b/src/djangocms_snippet/migrations/0015_alter_snippetgrouper_id.py @@ -0,0 +1,12 @@ +# Generated by Django 5.0.6 on 2024-05-22 10:32 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("djangocms_snippet", "0014_merge_20240519_2117"), + ] + + operations = [ + ] diff --git a/src/djangocms_snippet/models.py b/src/djangocms_snippet/models.py index 692f1bad..c4294510 100644 --- a/src/djangocms_snippet/models.py +++ b/src/djangocms_snippet/models.py @@ -2,13 +2,57 @@ from cms.models import CMSPlugin from django.conf import settings +from django.contrib.sites.models import Site from django.db import models +from django.shortcuts import reverse from django.utils.translation import gettext_lazy as _ # Search is enabled by default to keep backwards compatibility. SEARCH_ENABLED = getattr(settings, "DJANGOCMS_SNIPPET_SEARCH", False) +class AdminQuerySet(models.QuerySet): + def current_content(self, **kwargs): + """If a versioning package is installed, this returns the currently valid content + that matches the filter given in kwargs. Used to find content to be copied, e.g.. + Without versioning every page is current.""" + return self.filter(**kwargs) + + def latest_content(self, **kwargs): + """If a versioning package is installed, returns the latest version that matches the + filter given in kwargs including discarded or unpublished page content. Without versioning + every page content is the latest.""" + return self.filter(**kwargs) + + +class SnippetGrouper(models.Model): + """ + The Grouper model for snippet, this is required for versioning + """ + + def __str__(self): + return self.name + + @property + def name(self): + snippet_qs = Snippet.admin_manager.filter(snippet_grouper=self) + return snippet_qs.first().name or super().__str__ + + def snippet(self, show_editable=False): # NOQA: FBT002 + if show_editable: + # When in "edit" or "preview" mode we should be able to see the latest content + return ( + Snippet.admin_manager.current_content() + .filter( + snippet_grouper=self, + ) + .order_by("-pk") + .first() + ) + # When in "live" mode we should only be able to see the default published version + return Snippet.objects.filter(snippet_grouper=self).first() + + # Stores the actual data class Snippet(models.Model): """ @@ -17,9 +61,12 @@ class Snippet(models.Model): name = models.CharField( verbose_name=_("Name"), - unique=True, max_length=255, ) + snippet_grouper = models.ForeignKey( + SnippetGrouper, + on_delete=models.PROTECT, + ) html = models.TextField( verbose_name=_("HTML"), blank=True, @@ -38,11 +85,14 @@ class Snippet(models.Model): ) slug = models.SlugField( verbose_name=_("Slug"), - unique=True, blank=False, default="", max_length=255, ) + site = models.ForeignKey(Site, on_delete=models.CASCADE, null=True, blank=True) + + objects = models.Manager() + admin_manager = AdminQuerySet.as_manager() class Meta: ordering: ClassVar[list[str]] = ["name"] @@ -52,6 +102,12 @@ class Meta: def __str__(self): return self.name + def get_preview_url(self): + return reverse( + f"admin:{self._meta.app_label}_{self._meta.model_name}_preview", + args=[self.id], + ) + # Plugin model - just a pointer to Snippet class SnippetPtr(CMSPlugin): @@ -65,18 +121,21 @@ class SnippetPtr(CMSPlugin): parent_link=True, on_delete=models.CASCADE, ) - - snippet = models.ForeignKey( - Snippet, + snippet_grouper = models.ForeignKey( + SnippetGrouper, on_delete=models.CASCADE, ) search_fields = ["snippet__html"] if SEARCH_ENABLED else [] + def get_short_description(self): + snippet_label = SnippetGrouper.objects.filter(pk=self.snippet_grouper.pk).first() + return snippet_label + class Meta: verbose_name = _("Snippet Ptr") verbose_name_plural = _("Snippet Ptrs") def __str__(self): # Return the referenced snippet's name rather than the default (ID #) - return self.snippet.name + return self.snippet_grouper.name diff --git a/src/djangocms_snippet/rendering.py b/src/djangocms_snippet/rendering.py new file mode 100644 index 00000000..c36c2a71 --- /dev/null +++ b/src/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/src/djangocms_snippet/settings.py b/src/djangocms_snippet/settings.py new file mode 100644 index 00000000..4eb1dba4 --- /dev/null +++ b/src/djangocms_snippet/settings.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python +HELPER_SETTINGS = { + "SECRET_KEY": "djangocmssnippetstestsuitekey", + "INSTALLED_APPS": [ + "tests.utils", + "djangocms_versioning", + "djangocms_snippet", + ], + "CMS_LANGUAGES": { + 1: [ + { + "code": "en", + "name": "English", + } + ] + }, + "LANGUAGE_CODE": "en", + "ALLOWED_HOSTS": ["localhost"], + "DJANGOCMS_SNIPPET_VERSIONING_ENABLED": True, + "DJANGOCMS_SNIPPET_MODERATION_ENABLED": True, + "CMS_TEMPLATES": (("page.html", "Normal page"),), + "DEFAULT_AUTO_FIELD": "django.db.models.AutoField", +} + + +def run(): + from app_helper import runner + + runner.cms("djangocms_snippet") + + +if __name__ == "__main__": + run() diff --git a/src/djangocms_snippet/templates/djangocms_snippet/admin/preview.html b/src/djangocms_snippet/templates/djangocms_snippet/admin/preview.html new file mode 100644 index 00000000..d737fc80 --- /dev/null +++ b/src/djangocms_snippet/templates/djangocms_snippet/admin/preview.html @@ -0,0 +1 @@ +{{ snippet.html|safe|escape }} diff --git a/src/djangocms_snippet/templatetags/snippet_tags.py b/src/djangocms_snippet/templatetags/snippet_tags.py index 1baa2b1f..a4818971 100644 --- a/src/djangocms_snippet/templatetags/snippet_tags.py +++ b/src/djangocms_snippet/templatetags/snippet_tags.py @@ -72,9 +72,7 @@ def render(self, context): elif isinstance(snippet_instance, int): # pragma: no cover snippet_instance = Snippet.objects.get(pk=snippet_instance) - return mark_safe( - self.get_content_render(context, snippet_instance) - ) + return mark_safe(self.get_content_render(context, snippet_instance)) # Rely on the fact that manager something went wrong # render the fallback template @@ -100,9 +98,7 @@ def get_content_render(self, context, instance): t = template.Template(instance.html) content = t.render(context) except template.TemplateDoesNotExist: - content = _("Template %(template)s does not exist.") % { - "template": instance.template - } + content = _("Template %(template)s does not exist.") % {"template": instance.template} except Exception as e: # pragma: no cover content = escape(str(e)) if self.parse_until: @@ -127,9 +123,7 @@ def do_snippet_fragment(parser, token): """ args = token.split_contents() if len(args) < EXPECTED_LENGTH: - raise template.TemplateSyntaxError( - 'You need to specify at least a "snippet" ID, slug or instance' - ) + raise template.TemplateSyntaxError('You need to specify at least a "snippet" ID, slug or instance') if "or" in args: # Catch contents between tags and pass to renderer args.append(parser.parse(("endsnippet_fragment",))) diff --git a/src/djangocms_snippet/utils.py b/src/djangocms_snippet/utils.py new file mode 100644 index 00000000..a82015a4 --- /dev/null +++ b/src/djangocms_snippet/utils.py @@ -0,0 +1,23 @@ +from cms.toolbar.utils import get_toolbar_from_request +from django.conf import settings + +try: + import djangocms_versioning # NOQA: F401 + + is_versioning_installed = True +except ImportError: + is_versioning_installed = False + +djangocms_versioning_enabled = is_versioning_installed and getattr( + settings, "DJANGOCMS_SNIPPET_VERSIONING_ENABLED", True +) + + +def show_draft_content(request=None): + """ + Returns True if draft contents should be shown. + """ + if not request: + return False + request_toolbar = get_toolbar_from_request(request) + return request_toolbar.edit_mode_active or getattr(request_toolbar, "preview_mode_active", True) diff --git a/tests/requirements/requirements.in b/tests/requirements/base.txt similarity index 78% rename from tests/requirements/requirements.in rename to tests/requirements/base.txt index 77fedde3..95964b6b 100644 --- a/tests/requirements/requirements.in +++ b/tests/requirements/base.txt @@ -1,5 +1,6 @@ -django-app-helper -tox coverage -isort +django-app-helper +factory-boy flake8 +isort +tox diff --git a/tests/requirements/dj32_cms40.txt b/tests/requirements/dj32_cms40.txt new file mode 100644 index 00000000..3255b63e --- /dev/null +++ b/tests/requirements/dj32_cms40.txt @@ -0,0 +1,7 @@ +-r base.txt + +Django>=3.2,<4.0 + +# Unreleased django 3.2 & django-cms 4.0.x compatible packages +https://github.com/django-cms/django-cms/tarball/release/4.0.1.x#egg=django-cms +https://github.com/django-cms/djangocms-versioning/tarball/1.2.2#egg=djangocms-versioning diff --git a/tests/requirements/dj42-cms311.txt b/tests/requirements/dj42-cms311.txt new file mode 100644 index 00000000..14b92c68 --- /dev/null +++ b/tests/requirements/dj42-cms311.txt @@ -0,0 +1,3 @@ +-r requirements.in +Django>=4.2,<5.0 +django-cms>=3.11,<4 diff --git a/tests/requirements/dj42_cms40.txt b/tests/requirements/dj42_cms40.txt new file mode 100644 index 00000000..75e2b129 --- /dev/null +++ b/tests/requirements/dj42_cms40.txt @@ -0,0 +1,7 @@ +-r base.txt + +Django>=4.2,<5.0 + +# Unreleased django 4.2 & django-cms 4.0.x compatible packages +https://github.com/django-cms/django-cms/tarball/release/4.0.1.x#egg=django-cms +https://github.com/django-cms/djangocms-versioning/tarball/support/django-cms-4.0.x#egg=djangocms-versioning diff --git a/tests/requirements/dj42_cms41.txt b/tests/requirements/dj42_cms41.txt new file mode 100644 index 00000000..7d7c9144 --- /dev/null +++ b/tests/requirements/dj42_cms41.txt @@ -0,0 +1,7 @@ +-r base.txt + +Django>=4.2,<5.0 + +# Unreleased django 4.2 & django-cms 4.0.x compatible packages +django-cms>=4.1,<4.2 +djangocms-versioning>=2.0.2 diff --git a/tests/settings.py b/tests/settings.py index 99246714..21b9170c 100644 --- a/tests/settings.py +++ b/tests/settings.py @@ -1,7 +1,18 @@ #!/usr/bin/env python + +try: + import djangocms_versioning # NOQA: F401 + + add_apps = ["djangocms_versioning"] +except ImportError: + add_apps = [] + HELPER_SETTINGS = { + "SECRET_KEY": "djangocmssnippetstestsuitekey", "INSTALLED_APPS": [ "tests.utils", + "djangocms_snippet", + *add_apps, ], "CMS_LANGUAGES": { 1: [ @@ -13,6 +24,10 @@ }, "LANGUAGE_CODE": "en", "ALLOWED_HOSTS": ["localhost"], + "DJANGOCMS_SNIPPET_VERSIONING_ENABLED": True, + "DJANGOCMS_SNIPPET_MODERATION_ENABLED": True, + "CMS_TEMPLATES": (("page.html", "Normal page"),), + "DEFAULT_AUTO_FIELD": "django.db.models.AutoField", "CMS_CONFIRM_VERSION4": True, } diff --git a/tests/test_admin.py b/tests/test_admin.py new file mode 100644 index 00000000..a8bcb7c5 --- /dev/null +++ b/tests/test_admin.py @@ -0,0 +1,276 @@ +from importlib import reload +from unittest import skipIf + +from cms import __version__ as cms_version +from cms.test_utils.testcases import CMSTestCase +from cms.utils import get_current_site +from django.contrib import admin +from django.contrib.sites.models import Site +from django.shortcuts import reverse +from django.test import RequestFactory, override_settings + +try: + from djangocms_versioning.models import Version +except ImportError: + from tests.utils.models import Version + +from djangocms_snippet import admin as snippet_admin +from djangocms_snippet.forms import SnippetForm +from djangocms_snippet.models import Snippet, SnippetGrouper + + +class SnippetAdminTestCase(CMSTestCase): + def setUp(self): + self.superuser = self.get_superuser() + self.snippet = Snippet.objects.create( + name="Test Snippet", + slug="test-snippet", + html="

This is a test

", + snippet_grouper=SnippetGrouper.objects.create(), + ) + self.snippet_version = Version.objects.create( + content=self.snippet, created_by=self.superuser, state="published" + ) + self.snippet_admin = snippet_admin.SnippetAdmin(Snippet, admin) + self.snippet_admin_request = RequestFactory().get("/admin/djangocms_snippet") + self.edit_url = reverse( + "admin:djangocms_snippet_snippet_change", + args=(self.snippet.id,), + ) + self.delete_url = reverse( + "admin:djangocms_snippet_snippet_delete", + args=(self.snippet.id,), + ) + + def test_get_queryset(self): + current_site = get_current_site() + another_site = Site.objects.create(domain="http://www.django-cms.org", name="Django CMS", pk=3) + current_site_snippet = Snippet.objects.create( + name="Test Snippet 1", + slug="test-snippet-one", + html="

This is a test snippet one

", + snippet_grouper=SnippetGrouper.objects.create(), + site=current_site, + ) + another_site_snippet = Snippet.objects.create( + name="Test Snippet 2", + slug="test-snippet-two", + html="

This is a test snippet two

", + snippet_grouper=SnippetGrouper.objects.create(), + site=another_site, + ) + # Create versions of snippets + Version.objects.create(content=current_site_snippet, created_by=self.superuser, state="published") + Version.objects.create(content=another_site_snippet, created_by=self.superuser, state="published") + queryset = self.snippet_admin.get_queryset(self.snippet_admin_request) + # Test for snippet of current site + self.assertIn(current_site_snippet, queryset) + # Test for snippet with no site + self.assertIn(self.snippet, queryset) + # Test for snippet with another site + self.assertNotIn(another_site_snippet, queryset) + + @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(snippet_admin) + # This has to be declared again, since it will now be constructed without the versioning extension + 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")) + + @skipIf(not cms_version.startswith("4.0."), "Django CMS 4 required") + @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], ("name", "get_author", "get_modified_date", "get_versioning_state")) + self.assertEqual(list_display[-1].short_description.lower(), "actions") + + def test_admin_uses_form(self): + """ + The SnippetForm provides functionality to make SnippetGroupers irrelevant to the user, + ensure the admin uses this. + """ + self.assertEqual(self.snippet_admin.form, SnippetForm) + + @skipIf(cms_version < "4", "Django CMS 4 required") + @override_settings(DJANGOCMS_SNIPPET_VERSIONING_ENABLED=True) + def test_admin_delete_button_disabled_versioning_enabled(self): + """ + If versioning is enabled, the delete button should not be rendered on the change form + """ + admin.site.unregister(Snippet) + reload(snippet_admin) + + with self.login_user_context(self.superuser): + response = self.client.get(self.edit_url) + + self.assertNotContains( + response, 'Delete' + ) + + @override_settings(DJANGOCMS_SNIPPET_VERSIONING_ENABLED=False) + def test_admin_delete_button_available_versioning_disabled(self): + """ + If versioning is disabled, the delete button should be rendered on the change form + """ + admin.site.unregister(Snippet) + reload(snippet_admin) + + with self.login_user_context(self.superuser): + response = self.client.get(self.edit_url) + + self.assertContains( + response, 'Delete' + ) + + @skipIf(cms_version < "4", "Django CMS 4 required") + @override_settings(DJANGOCMS_SNIPPET_VERSIONING_ENABLED=True) + def test_admin_delete_endpoint_inaccessible_versioning_enabled(self): + """ + If versioning is enabled, the delete endpoint should not be accessible. + """ + admin.site.unregister(Snippet) + reload(snippet_admin) + + with self.login_user_context(self.superuser): + response = self.client.post(self.delete_url) + + # The delete endpoint should return a 403 forbidden if we try to access it with versioning enabled + self.assertEqual(response.status_code, 403) + + @override_settings(DJANGOCMS_SNIPPET_VERSIONING_ENABLED=False) + def test_admin_delete_endpoint_accessible_versioning_disabled(self): + """ + If versioning is disabled, the delete endpoint should be accessible. + """ + admin.site.unregister(Snippet) + reload(snippet_admin) + + with self.login_user_context(self.superuser): + response = self.client.post(self.delete_url) + + # The delete endpoint should return a 200 success if we try to access it with versioning disabled + self.assertEqual(response.status_code, 200) + + +class SnippetAdminFormTestCase(CMSTestCase): + def setUp(self): + self.add_url = reverse("admin:djangocms_snippet_snippet_add") + self.changelist_url = reverse("admin:djangocms_snippet_snippet_changelist") + self.superuser = self.get_superuser() + self.snippet_grouper = SnippetGrouper.objects.create() + self.snippet = Snippet.objects.create( + name="Test Snippet", + slug="test-snippet", + html="

This is a test

", + snippet_grouper=self.snippet_grouper, + ) + self.snippet_version = Version.objects.create(content=self.snippet, created_by=self.superuser) + + @skipIf(cms_version < "4", "Django CMS 4 required") + @override_settings(DJANGOCMS_SNIPPET_VERSIONING_ENABLED=True) + def test_admin_form_save_method(self): + with self.login_user_context(self.superuser): + response = self.client.post( + self.add_url, + { + "name": "Test Snippet 2", + "html": "

Test Save Snippet

", + "slug": "test-snippet-2", + }, + ) + self.assertRedirects(response, self.changelist_url) + + # We should have 2 groupers and snippets, due to the creation of the others in setUp + self.assertEqual(Snippet._base_manager.count(), 2) + self.assertEqual(SnippetGrouper._base_manager.count(), 2) + + @skipIf(cms_version < "4", "Django CMS 4 required") + @override_settings(DJANGOCMS_SNIPPET_VERSIONING_ENABLED=True) + def test_admin_form_edit_when_locked(self): + """ + When a form is initialised in read-only mode, it should not require self.fields to be populated, and + should return a read-only form. + """ + self.snippet_version.publish(user=self.superuser) + with self.login_user_context(self.superuser): + edit_url = reverse( + "admin:djangocms_snippet_snippet_change", + args=(self.snippet.id,), + ) + response = self.client.get(edit_url) + + # Check that we are loading in readonly mode + self.assertContains(response, '
Test Snippet
') + # We should have the same number of snippets as before + self.assertEqual(Snippet.objects.count(), 1) + + @override_settings(DJANGOCMS_SNIPPET_VERSIONING_ENABLED=False) + def test_slug_colomn_should_hyperlinked_with_versioning_disabled(self): + """ + Slug column should be visible and hyperlinked when versioning is disabled + """ + admin.site.unregister(Snippet) + reload(snippet_admin) + + with self.login_user_context(self.get_superuser()): + response = self.client.get(self.changelist_url) + self.assertContains( + response, + 'test-snippet', + ) + + @skipIf(cms_version < "4", "Django CMS 4 required") + @override_settings(DJANGOCMS_SNIPPET_VERSIONING_ENABLED=True) + def test_name_colomn_should_not_be_hyperlinked_with_versioning_enabled(self): + """ + Name column should be visible and not hyperlinked when versioning is enabled. + Slug column should not be visible when versioning is enabled. + """ + admin.site.unregister(Snippet) + reload(snippet_admin) + + with self.login_user_context(self.get_superuser()): + response = self.client.get(self.changelist_url) + self.assertContains(response, 'Test Snippet') + self.assertNotContains( + response, + 'test-snippet', + ) + + def test_preview_renders_read_only_fields(self): + """ + Check that the preview endpoint is rendered in read only mode + """ + self.snippet_version.publish(user=self.superuser) + with self.login_user_context(self.superuser): + edit_url = reverse( + "admin:djangocms_snippet_snippet_preview", + args=(self.snippet.id,), + ) + response = self.client.get(edit_url) + + # Snippet name + self.assertContains(response, '
Test Snippet
') + # Snippet slug + self.assertContains(response, '
test-snippet
') + # Snippet HTML + self.assertContains(response, '
<h1>This is a test</h1>
') + # Snippet template + self.assertContains(response, '
') diff --git a/tests/test_config.py b/tests/test_config.py new file mode 100644 index 00000000..00285287 --- /dev/null +++ b/tests/test_config.py @@ -0,0 +1,32 @@ +from unittest import skipIf + +from cms import __version__ as cms_version +from cms.test_utils.testcases import CMSTestCase +from django.apps import apps + +from djangocms_snippet.models import Snippet, SnippetGrouper + +from .utils.factories import SnippetWithVersionFactory + + +@skipIf(cms_version < "4", "Django CMS 4 required") +class VersioningConfigTestCase(CMSTestCase): + def test_snippet_copy_method(self): + """ + App should use the default copy method, and return an identical model (apart from PK) + """ + snippet_cms_config = apps.get_app_config("djangocms_snippet").cms_config + old_snippet = SnippetWithVersionFactory( + name="snippet", + html="

Hello World

", + slug="snippet", + ) + + new_snippet = snippet_cms_config.versioning[0].copy_function(old_snippet) + + self.assertNotEqual(old_snippet, new_snippet) + self.assertEqual(old_snippet.name, new_snippet.name) + self.assertEqual(old_snippet.html, new_snippet.html) + self.assertEqual(old_snippet.snippet_grouper, new_snippet.snippet_grouper) + self.assertEqual(1, SnippetGrouper.objects.count()) + self.assertEqual(2, Snippet._base_manager.count()) diff --git a/tests/test_forms.py b/tests/test_forms.py new file mode 100644 index 00000000..3d8005c0 --- /dev/null +++ b/tests/test_forms.py @@ -0,0 +1,194 @@ +from importlib import reload +from unittest import skipIf + +from cms import __version__ as cms_version +from cms.test_utils.testcases import CMSTestCase +from django.test import override_settings + +from djangocms_snippet import forms +from djangocms_snippet.forms import SnippetPluginForm +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. + """ + reload(forms) + form_data = {"name": "test_snippet", "slug": "test_snippet", "html": "

Test Title

"} + form = forms.SnippetForm(form_data) + + self.assertTrue(form.is_valid()) + + form.clean() + form.save(commit=True) + + 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. + """ + reload(forms) + form_data = {"name": "test_snippet", "slug": "test_snippet", "html": "

Test Title

"} + form = forms.SnippetForm(form_data) + + self.assertTrue(form.is_valid()) + + form.clean() + form.save(commit=True) + + self.assertEqual(SnippetGrouper.objects.count(), 1) + self.assertEqual(Snippet._base_manager.count(), 1) + + @override_settings(DJANGOCMS_SNIPPET_VERSIONING_ENABLED=True) + def test_snippet_form_doesnt_create_grouper_or_snippet_with_no_commit(self): + """ + With versioning enabled, but no commit flag, models should still be created + """ + reload(forms) + form_data = {"name": "test_snippet", "slug": "test_snippet", "html": "

Test Title

"} + form = forms.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) + + @skipIf(cms_version < "4", "Django CMS 4 required") + @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 + """ + reload(forms) + grouper = SnippetGrouper.objects.create() + form_data = { + "name": "test_snippet", + "slug": "test_snippet", + "html": "

Test Title

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

Test Title

" + + form = forms.SnippetForm(form_data) + + self.assertTrue(form.is_valid()) + + form.clean() + form.save(commit=True) + + 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. + """ + reload(forms) + form_data = { + "name": "test_snippet", + "slug": "test_snippet", + "html": "

Test Title

", + } + form = forms.SnippetForm(form_data) + + self.assertTrue(form.is_valid()) + + # Clean and save the form + form.clean() + snippet = form.save(commit=True) + + 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 = forms.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"]}) + + @skipIf(cms_version < "4", "Django CMS 4 required") + 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. + """ + reload(forms) + # 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 = forms.SnippetForm(form_data) + + self.assertTrue(form.is_valid()) + + +class SnippetPluginFormTestCase(CMSTestCase): + def setUp(self): + self.form = SnippetPluginForm() + + def test_get_related_url_for_snippet(self): + """ + Check that the url to add a snippet in the admin is returned + """ + self.assertEqual(self.form.get_related_url_for_snippet("", "add"), "/en/admin/djangocms_snippet/snippet/add/") + + def test_get_related_url_for_snippet_used(self): + """ + Checks that the get_related_url widget is overridden + """ + snippet_grouper_widget = self.form.fields["snippet_grouper"].widget + self.assertEqual(snippet_grouper_widget.get_related_url, self.form.get_related_url_for_snippet) + self.assertTrue(snippet_grouper_widget.can_add_related) diff --git a/tests/test_migrations.py b/tests/test_migrations.py index fe371b7b..65ba6381 100644 --- a/tests/test_migrations.py +++ b/tests/test_migrations.py @@ -1,12 +1,15 @@ # original from # http://tech.octopus.energy/news/2016/01/21/testing-for-missing-migrations-in-django.html from io import StringIO +from unittest import skipIf +from cms import __version__ as cms_version from django.core.management import call_command from django.test import TestCase, override_settings class MigrationTestCase(TestCase): + @skipIf(cms_version.startswith("4.0."), "This test fails on django-cms 4.0") @override_settings(MIGRATION_MODULES={}) def test_for_missing_migrations(self): output = StringIO() diff --git a/tests/test_models.py b/tests/test_models.py index 127a0a0d..24d40f28 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -1,27 +1,31 @@ -from django.test import TestCase +from cms.test_utils.testcases import CMSTestCase from djangocms_snippet.models import SEARCH_ENABLED, Snippet, SnippetPtr +from .utils.factories import SnippetPluginFactory, SnippetWithVersionFactory -class SnippetModelTestCase(TestCase): - def setUp(self): - pass - def tearDown(self): - pass +class SnippetModelTestCase(CMSTestCase): + def setUp(self): + self.snippet = SnippetWithVersionFactory( + name="test snippet", + html="

hello world

", + slug="test_snippet", + ) + self.snippet.versions.last().publish(user=self.get_superuser()) + self.snippet_grouper = self.snippet.snippet_grouper + SnippetPluginFactory(snippet_grouper=self.snippet_grouper, language=["en"]) def test_settings(self): self.assertEqual(SEARCH_ENABLED, False) def test_snippet_instance(self): - Snippet.objects.create( - name="test snippet", - html="

hello world

", - slug="test_snippet", - ) instance = Snippet.objects.all() + self.assertEqual(instance.count(), 1) + instance = Snippet.objects.first() + self.assertEqual(instance.name, "test snippet") self.assertEqual(instance.html, "

hello world

") self.assertEqual(instance.slug, "test_snippet") @@ -29,16 +33,11 @@ def test_snippet_instance(self): self.assertEqual(str(instance), "test snippet") def test_snippet_ptr_instance(self): - snippet = Snippet.objects.create( - name="test snippet", - html="

hello world

", - slug="test_snippet", - ) - SnippetPtr.objects.create( - snippet=snippet, - ) instance = SnippetPtr.objects.all() + self.assertEqual(instance.count(), 1) + 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 58d92af9..02c1e504 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -1,51 +1,63 @@ +import datetime +from unittest import skipIf + +from cms import __version__ as cms_version from cms.api import add_plugin, create_page from cms.test_utils.testcases import CMSTestCase -from djangocms_snippet.models import Snippet +try: + from djangocms_versioning.models import Version +except ImportError: + from tests.utils.models import Version + +from djangocms_snippet.models import Snippet, SnippetGrouper + +from .utils.factories import SnippetWithVersionFactory class SnippetPluginsTestCase(CMSTestCase): def setUp(self): self.language = "en" - self.home = create_page( - title="home", - template="page.html", - language=self.language, - ) - self.home.publish(self.language) + self.superuser = self.get_superuser() self.page = create_page( title="help", template="page.html", language=self.language, + created_by=self.superuser, ) - self.page.publish(self.language) - self.placeholder = self.page.placeholders.get(slot="content") - self.superuser = self.get_superuser() + if cms_version < "4": + self.page.publish(self.language) + self.placeholder, _ = self.page.placeholders.get_or_create(slot="content") + else: + # Publish our page content + from cms.models import PageContent - def tearDown(self): - self.page.delete() - self.home.delete() - self.superuser.delete() + self.pagecontent = PageContent._base_manager.filter(page=self.page, language=self.language).first() + version = self.pagecontent.versions.first() + version.publish(self.superuser) + self.placeholder, _ = self.pagecontent.placeholders.get_or_create(slot="content") def test_html_rendering(self): - request_url = ( - self.page.get_absolute_url(self.language) + "?toolbar_off=true" - ) - snippet = Snippet.objects.create( + snippet = SnippetWithVersionFactory( name="plugin_snippet", html="

Hello World

", slug="plugin_snippet", ) + snippet_grouper = snippet.snippet_grouper plugin = add_plugin( - self.page.placeholders.get(slot="content"), + self.placeholder, "SnippetPlugin", self.language, - snippet=snippet, + snippet_grouper=snippet_grouper, ) - self.page.publish(self.language) - self.assertEqual(plugin.snippet.name, "plugin_snippet") - self.assertEqual(plugin.snippet.html, "

Hello World

") - self.assertEqual(plugin.snippet.slug, "plugin_snippet") + + snippet.versions.last().publish(user=self.get_superuser()) + request_url = self.page.get_absolute_url("en") + result_snippet = plugin.snippet_grouper.snippet(True) + + self.assertEqual(result_snippet.name, "plugin_snippet") + self.assertEqual(result_snippet.html, "

Hello World

") + self.assertEqual(result_snippet.slug, "plugin_snippet") with self.login_user_context(self.superuser): response = self.client.get(request_url) @@ -53,60 +65,53 @@ def test_html_rendering(self): self.assertIn(b"

Hello World

", response.content) def test_failing_html_rendering(self): - request_url = ( - self.page.get_absolute_url(self.language) + "?toolbar_off=true" - ) - snippet = Snippet.objects.create( + request_url = self.page.get_absolute_url(self.language) + "?toolbar_off=true" + snippet = SnippetWithVersionFactory( name="plugin_snippet", html="{% import weirdness %}", slug="plugin_snippet", ) + snippet_grouper = snippet.snippet_grouper + snippet.versions.last().publish(user=self.get_superuser()) + add_plugin( - self.page.placeholders.get(slot="content"), + self.placeholder, "SnippetPlugin", self.language, - snippet=snippet, + snippet_grouper=snippet_grouper, ) - self.page.publish(self.language) with self.login_user_context(self.superuser): response = self.client.get(request_url) self.assertContains(response, "Invalid block tag on line 1") - self.assertContains( - response, "Did you forget to register or load this tag?" - ) + self.assertContains(response, "Did you forget to register or load this tag?") def test_template_rendering(self): - request_url = ( - self.page.get_absolute_url(self.language) + "?toolbar_off=true" - ) + request_url = self.page.get_absolute_url() template = "snippet.html" - snippet = Snippet.objects.create( + snippet = SnippetWithVersionFactory( name="plugin_snippet", template=template, slug="plugin_snippet", ) - snippet.save() + snippet_grouper = snippet.snippet_grouper + snippet.versions.last().publish(user=self.get_superuser()) plugin = add_plugin( - self.page.placeholders.get(slot="content"), + self.placeholder, "SnippetPlugin", self.language, - snippet=snippet, + snippet_grouper=snippet_grouper, ) - self.page.publish(self.language) - self.assertEqual(plugin.snippet.name, "plugin_snippet") - self.assertEqual(plugin.snippet.slug, "plugin_snippet") + result_snippet = plugin.snippet_grouper.snippet(True) + self.assertEqual(result_snippet.name, "plugin_snippet") + self.assertEqual(result_snippet.slug, "plugin_snippet") with self.login_user_context(self.superuser): response = self.client.get(request_url) - self.assertNotIn( - f"Template {template} does not exist".encode(), response.content - ) - self.assertNotIn( - b"context must be a dict rather than Context", response.content - ) + self.assertNotIn(f"Template {template} does not exist".encode(), response.content) + self.assertNotIn(b"context must be a dict rather than Context", response.content) self.assertNotIn( b"context must be a dict rather than PluginContext", response.content, @@ -114,25 +119,204 @@ def test_template_rendering(self): self.assertContains(response, "

Hello World Template

") def test_failing_template_rendering(self): - request_url = ( - self.page.get_absolute_url(self.language) + "?toolbar_off=true" - ) + request_url = self.page.get_absolute_url(self.language) + "?toolbar_off=true" template = "some_template" - snippet = Snippet.objects.create( + snippet = SnippetWithVersionFactory( name="plugin_snippet", template=template, slug="plugin_snippet", ) - snippet.save() + snippet_grouper = snippet.snippet_grouper + snippet.versions.last().publish(user=self.get_superuser()) add_plugin( - self.page.placeholders.get(slot="content"), + self.placeholder, "SnippetPlugin", self.language, - snippet=snippet, + snippet_grouper=snippet_grouper, ) - self.page.publish(self.language) with self.login_user_context(self.superuser): response = self.client.get(request_url) self.assertContains(response, "Template some_template does not exist") + + +@skipIf(cms_version < "4", "Django CMS 4 required") +class SnippetPluginVersioningRenderTestCase(CMSTestCase): + def setUp(self): + from cms.models import PageContent + + self.language = "en" + self.superuser = self.get_superuser() + snippet_grouper = SnippetGrouper.objects.create() + # Create a draft snippet, to be published later + self.snippet = Snippet.objects.create( + name="plugin_snippet", + html="

live content

", + slug="plugin_snippet", + snippet_grouper=snippet_grouper, + ) + + # Publish the snippet + snippet_version = Version.objects.create( + content=self.snippet, created_by=self.superuser, created=datetime.datetime.now() + ) + snippet_version.publish(user=self.superuser) + # Copy the snippet to create a draft + draft_user = self.get_staff_page_user() + draft_snippet_version = snippet_version.copy(draft_user) + self.draft_snippet = draft_snippet_version.content + self.draft_snippet.html = "

draft content

" + self.draft_snippet.save() + + # Create a page + self.page = create_page( + title="help", + template="page.html", + language=self.language, + created_by=self.superuser, + ) + # Publish its page content + self.pagecontent = PageContent._base_manager.filter(page=self.page, language=self.language).first() + self.pagecontent_version = self.pagecontent.versions.first() + self.pagecontent_version.publish(self.superuser) + + # Copy our published pagecontent to make a draft + draft_pagecontent_version = self.pagecontent_version.copy(self.superuser) + self.draft_pagecontent = draft_pagecontent_version.content + + def test_correct_versioning_state_published_snippet_and_page(self): + """ + If a page is published, the published snippet should be rendered + """ + # Add plugin to our published page! + add_plugin( + self.pagecontent.placeholders.get(slot="content"), + "SnippetPlugin", + self.language, + snippet_grouper=self.snippet.snippet_grouper, + ) + # Add plugin to our draft page + add_plugin( + self.draft_pagecontent.placeholders.get(slot="content"), + "SnippetPlugin", + self.language, + snippet_grouper=self.draft_snippet.snippet_grouper, + ) + + # Request for published page + request_url = self.page.get_absolute_url(self.language) + with self.login_user_context(self.superuser): + response = self.client.get(request_url) + + self.assertContains(response, "

live content

") + self.assertNotIn("draft content", str(response.content)) + + def test_correct_versioning_state_draft_snippet_and_page(self): + """ + If we have a draft, the draft snippet should be rendered. + """ + # Add plugin to our published page! + add_plugin( + self.pagecontent.placeholders.get(slot="content"), + "SnippetPlugin", + self.language, + snippet_grouper=self.snippet.snippet_grouper, + ) + # Add plugin to our draft page + add_plugin( + self.draft_pagecontent.placeholders.get(slot="content"), + "SnippetPlugin", + self.language, + snippet_grouper=self.draft_snippet.snippet_grouper, + ) + + # Request for draft page + from cms.toolbar.utils import get_object_edit_url + + request_url = get_object_edit_url(self.draft_pagecontent, "en") + with self.login_user_context(self.superuser): + response = self.client.get(request_url) + + self.assertContains(response, "

draft content

") + self.assertNotIn("live content", str(response.content)) + + def test_draft_snippet_and_page_live_url_rendering(self): + """ + If a page is published with a draft snippet created + nothing should be rendered! + """ + snippet_grouper = SnippetGrouper.objects.create() + snippet = Snippet.objects.create( + name="plugin_snippet", + html="

Draft snippet

", + slug="plugin_snippet", + snippet_grouper=snippet_grouper, + ) + Version.objects.create(content=snippet, created_by=self.superuser, created=datetime.datetime.now()) + + add_plugin( + self.pagecontent.placeholders.get(slot="content"), + "SnippetPlugin", + self.language, + snippet_grouper=snippet_grouper, + ) + + request_url = self.page.get_absolute_url(self.language) + with self.login_user_context(self.superuser): + response = self.client.get(request_url) + + self.assertEqual(response.status_code, 200) + self.assertNotIn("Draft snippet", str(response.content)) + self.assertNotIn("Published snippet", str(response.content)) + + def test_published_snippet_and_page_live_url_rendering(self): + """ + If a page is published with a published snippet + created the snippet should be rendered! + """ + snippet_grouper = SnippetGrouper.objects.create() + snippet = Snippet.objects.create( + name="plugin_snippet", + html="

Published snippet

", + slug="plugin_snippet", + snippet_grouper=snippet_grouper, + ) + snippet_version = Version.objects.create( + content=snippet, created_by=self.superuser, created=datetime.datetime.now() + ) + snippet_version.publish(user=self.superuser) + + add_plugin( + self.pagecontent.placeholders.get(slot="content"), + "SnippetPlugin", + self.language, + snippet_grouper=snippet_grouper, + ) + + request_url = self.page.get_absolute_url(self.language) + with self.login_user_context(self.superuser): + response = self.client.get(request_url) + + self.assertContains(response, "

Published snippet

") + self.assertNotIn("Draft snippet", str(response.content)) + + def test_correct_name_is_displayed_for_snippet_component_on_page(self): + """ + If a component is added to the page, it should show the snippet name and not ID + """ + add_plugin( + self.draft_pagecontent.placeholders.get(slot="content"), + "SnippetPlugin", + self.language, + snippet_grouper=self.draft_snippet.snippet_grouper, + ) + + # Request structure endpoint on page + from cms.toolbar.utils import get_object_structure_url + + request_url = get_object_structure_url(self.draft_pagecontent, "en") + with self.login_user_context(self.superuser): + response = self.client.get(request_url) + + self.assertContains(response, "Snippet plugin_snippet") diff --git a/tests/test_templatetags.py b/tests/test_templatetags.py index 344d74f9..234746d9 100644 --- a/tests/test_templatetags.py +++ b/tests/test_templatetags.py @@ -1,62 +1,56 @@ +from cms.test_utils.testcases import CMSTestCase from django.core.exceptions import ObjectDoesNotExist from django.template import Context, Template from django.template.exceptions import TemplateSyntaxError -from django.test import TestCase -from djangocms_snippet.models import Snippet, SnippetPtr +from .utils.factories import SnippetPluginFactory, SnippetWithVersionFactory -class SnippetTemplateTagTestCase(TestCase): +class SnippetTemplateTagTestCase(CMSTestCase): def test_html_rendered(self): - snippet = Snippet.objects.create( + snippet = SnippetWithVersionFactory( name="test snippet", html="

hello {{ title }}

", slug="test_snippet", ) - SnippetPtr.objects.create( - snippet=snippet, - ) + snippet.versions.last().publish(user=self.get_superuser()) + snippet_grouper = snippet.snippet_grouper + SnippetPluginFactory(snippet_grouper=snippet_grouper, language=["en"]) context = Context({"title": "world"}) - template_to_render = Template( - "{% load snippet_tags %}" '{% snippet_fragment "test_snippet" %}' - ) + template_to_render = Template("{% load snippet_tags %}" '{% snippet_fragment "test_snippet" %}') rendered_template = template_to_render.render(context) + self.assertInHTML("

hello world

", rendered_template) # test html errors context = Context({"title": "world"}) - template_to_render = Template( - "{% load snippet_tags %}" '{% snippet_fragment "test_snippet_2" %}' - ) + template_to_render = Template("{% load snippet_tags %}" '{% snippet_fragment "test_snippet_2" %}') with self.assertRaises(ObjectDoesNotExist): # Snippet matching query does not exist. rendered_template = template_to_render.render(context) def test_template_rendered(self): template = "snippet.html" - snippet = Snippet.objects.create( + snippet = SnippetWithVersionFactory( name="test snippet", + html="

hello {{ title }}

", template=template, slug="test_snippet", ) - SnippetPtr.objects.create( - snippet=snippet, - ) + snippet.versions.last().publish(user=self.get_superuser()) + snippet_grouper = snippet.snippet_grouper + SnippetPluginFactory(snippet_grouper=snippet_grouper, language=["en"]) # use a string to identify context = Context({}) - template_to_render = Template( - "{% load snippet_tags %}" '{% snippet_fragment "test_snippet" %}' - ) + template_to_render = Template("{% load snippet_tags %}" '{% snippet_fragment "test_snippet" %}') rendered_template = template_to_render.render(context) self.assertInHTML("

Hello World Template

", rendered_template) # use an id to identify context = Context({}) - template_to_render = Template( - "{% load snippet_tags %}" "{% snippet_fragment 1 %}" - ) + template_to_render = Template("{% load snippet_tags %}{% snippet_fragment 1 %}") rendered_template = template_to_render.render(context) self.assertInHTML("

Hello World Template

", rendered_template) @@ -71,28 +65,23 @@ def test_template_rendered(self): def test_template_errors(self): template = "does_not_exist.html" - snippet = Snippet.objects.create( + snippet = SnippetWithVersionFactory( name="test snippet", + html="

hello {{ title }}

", template=template, slug="test_snippet", ) - SnippetPtr.objects.create( - snippet=snippet, - ) + snippet.versions.last().publish(user=self.get_superuser()) + snippet_grouper = snippet.snippet_grouper + SnippetPluginFactory(snippet_grouper=snippet_grouper, language=["en"]) context = Context({}) - template_to_render = Template( - "{% load snippet_tags %}" '{% snippet_fragment "test_snippet" %}' - ) + template_to_render = Template("{% load snippet_tags %}" '{% snippet_fragment "test_snippet" %}') rendered_template = template_to_render.render(context) - self.assertIn( - "Template does_not_exist.html does not exist.", rendered_template - ) + self.assertIn("Template does_not_exist.html does not exist.", rendered_template) context = Context({}) - template_to_render = Template( - "{% load snippet_tags %}" '{% snippet_fragment "test_snippet_1" %}' - ) + template_to_render = Template('{% load snippet_tags %}{% snippet_fragment "test_snippet_1" %}') with self.assertRaises(ObjectDoesNotExist): # Snippet object does not exist rendered_template = template_to_render.render(context) @@ -100,6 +89,4 @@ def test_template_errors(self): context = Context({}) with self.assertRaises(TemplateSyntaxError): # You need to specify at least a "snippet" ID, slug or instance - template_to_render = Template( - "{% load snippet_tags %}" "{% snippet_fragment %}" - ) + template_to_render = Template("{% load snippet_tags %}{% snippet_fragment %}") diff --git a/tests/test_views.py b/tests/test_views.py new file mode 100644 index 00000000..a5879542 --- /dev/null +++ b/tests/test_views.py @@ -0,0 +1,40 @@ +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) + # Removing html escaping, means the content is rendered including the tags on the page, but also means that + # the response will contain character entity references. + self.assertContains(response, "<h1>Test Title</h1><br><p>Test paragraph</p>") + + def test_preview_raises_302_no_snippet(self): + """ + With no Snippet to preview, a 302 will be raised and the user will be redirected to the admin + """ + 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, 302) diff --git a/tests/utils/factories.py b/tests/utils/factories.py new file mode 100644 index 00000000..7f712301 --- /dev/null +++ b/tests/utils/factories.py @@ -0,0 +1,133 @@ +import string + +import factory +from cms.models import Placeholder +from django.contrib.auth.models import User +from django.contrib.contenttypes.models import ContentType +from django.db import models +from factory.fuzzy import FuzzyInteger, FuzzyText + +from djangocms_snippet.models import Snippet, SnippetGrouper, SnippetPtr + +try: + from djangocms_versioning.models import Version +except ImportError: + from tests.utils.models import Version + + +class UserFactory(factory.django.DjangoModelFactory): + username = FuzzyText(length=12) + first_name = factory.Faker("first_name") + last_name = factory.Faker("last_name") + email = factory.LazyAttribute(lambda u: f"{u.first_name.lower()}.{u.last_name.lower()}@example.com") + + class Meta: + model = User + + @classmethod + def _create(cls, model_class, *args, **kwargs): + """Override the default ``_create`` with our custom call.""" + manager = cls._get_manager(model_class) + # The default would use ``manager.create(*args, **kwargs)`` + return manager.create_user(*args, **kwargs) + + +class AbstractVersionFactory(factory.django.DjangoModelFactory): + object_id = factory.SelfAttribute("content.id") + content_type = factory.LazyAttribute(lambda o: ContentType.objects.get_for_model(o.content)) + created_by = factory.SubFactory(UserFactory) + + class Meta: + exclude = ("content",) + abstract = True + + +class PlaceholderFactory(factory.django.DjangoModelFactory): + default_width = FuzzyInteger(0, 25) + slot = FuzzyText(length=2, chars=string.digits) + # NOTE: When using this factory you will probably want to set + # the source field manually + + class Meta: + model = Placeholder + + +class SnippetGrouperFactory(factory.django.DjangoModelFactory): + class Meta: + model = SnippetGrouper + + +class AbstractSnippetFactory(factory.django.DjangoModelFactory): + name = FuzzyText(length=12) + slug = FuzzyText(length=12) + snippet_grouper = factory.SubFactory(SnippetGrouperFactory) + html = "" + template = "" + + class Meta: + abstract = True + + +class SnippetFactory(AbstractSnippetFactory): + class Meta: + model = Snippet + + +class SnippetVersionFactory(AbstractVersionFactory): + content = factory.SubFactory(SnippetFactory) + + class Meta: + model = Version + + +class SnippetWithVersionFactory(AbstractSnippetFactory): + @factory.post_generation + def version(self, create, extracted, **kwargs): + # NOTE: Use this method as below to define version attributes: + # PageContentWithVersionFactory(version__label='label1') + if not create: + # Simple build, do nothing. + return + SnippetVersionFactory(content=self, **kwargs) + + class Meta: + model = Snippet + + +def get_plugin_position(plugin): + """Helper function to correctly calculate the plugin position. + Use this in plugin factory classes + """ + if hasattr(plugin.placeholder, "get_last_plugin_position"): + # Placeholder is a CMS v4 Placeholder + return (plugin.placeholder.get_last_plugin_position(language=plugin.language) or 0) + 1 + last_plugin_pos = ( + plugin.placeholder.cmsplugin_set.filter( + parent=None, + language=plugin.language, + ) + .aggregate(models.Max("position")) + .get("position__max") + ) + offset = (last_plugin_pos or -1) + 1 + return offset + 1 + + +def get_plugin_language(plugin): + """Helper function to get the language from a plugin's relationships. + Use this in plugin factory classes + """ + if plugin.placeholder.source: + return plugin.placeholder.source.language + + +class SnippetPluginFactory(factory.django.DjangoModelFactory): + plugin_type = "SnippetPlugin" + parent = None + snippet_grouper = factory.SubFactory(SnippetGrouperFactory) + placeholder = factory.SubFactory(PlaceholderFactory) + position = factory.LazyAttribute(get_plugin_position) + language = factory.LazyAttribute(get_plugin_language) + + class Meta: + model = SnippetPtr diff --git a/tests/utils/models.py b/tests/utils/models.py new file mode 100644 index 00000000..9e64e500 --- /dev/null +++ b/tests/utils/models.py @@ -0,0 +1,26 @@ +from cms import __version__ as cms_version +from django.conf import settings +from django.db import models + +if cms_version < "4": + + class Version(models.Model): + content = models.ForeignKey("djangocms_snippet.Snippet", related_name="versions", on_delete=models.CASCADE) + created_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.PROTECT, + ) + state = models.CharField(max_length=50, default="draft") + + def __str__(self): + return f"{self.content} - {self.state}" + + def __init__(self, *args, **kwargs): + kwargs.pop("content_type", None) + obj_id = kwargs.pop("object_id", None) + if obj_id: + kwargs["content_id"] = obj_id + super().__init__(*args, **kwargs) + + def publish(self, user): + pass diff --git a/tox.ini b/tox.ini index b9514364..60635f92 100644 --- a/tox.ini +++ b/tox.ini @@ -2,14 +2,53 @@ requires = tox>=4.2 envlist = - py{39,310,311}-django{32,42}-cms311 + py{39,310,311}-dj{32,42}-cms{311,40,41} + skip_missing_interpreters=True +[flake8] +max-line-length = 119 +exclude = + *.egg-info, + .eggs, + .git, + .settings, + .tox, + build, + data, + dist, + docs, + *migrations*, + requirements, + tmp + +[isort] +line_length = 79 +skip = manage.py, *migrations*, .tox, .eggs, data +include_trailing_comma = true +multi_line_output = 5 +not_skip = __init__.py +lines_after_imports = 2 +default_section = THIRDPARTY +sections = FUTURE, STDLIB, DJANGO, CMS, THIRDPARTY, FIRSTPARTY, LIB, LOCALFOLDER +known_first_party = djangocms_snippet +known_cms = cms, menus +known_django = django + [testenv] commands = - python -m coverage run setup.py test -deps = -r tests/requirements/{envname}.txt + {envpython} --version + {env:COMMAND:coverage} run setup.py test +deps = + dj32: Django>=3.2,<4.0 + dj42: Django>=4.2,<5.0 + cms311: django-cms>=3.11,<4.0 + cms40: https://github.com/django-cms/django-cms/tarball/release/4.0.1.x#egg=django-cms + cms40: https://github.com/django-cms/djangocms-versioning/tarball/support/django-cms-4.0.x#egg=djangocms-versioning + cms41: django-cms>=4.1,<4.2 + cms41: djangocms-versioning>=2.0.2 + -r tests/requirements/base.txt package = wheel set_env = PYTHONDEVMODE = 1