From 65d1b091c98102f86041e0cbf63bdebf3c2acb6d Mon Sep 17 00:00:00 2001 From: David THENON Date: Thu, 21 Nov 2024 00:09:57 +0000 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(plugins)=20added=20Slider=20plugin?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This won't make it because djangocms-text-ckeditor has a bug with inline forms, newly added item does not properly initialize the CKEditor Javascript https://github.com/django-cms/djangocms-text-ckeditor/issues/680 So finally we need to make slide item through children plugin of SliderPlugin. --- sandbox/settings.py | 1 + src/richie/apps/courses/settings/__init__.py | 13 ++- src/richie/plugins/slider/__init__.py | 0 src/richie/plugins/slider/admin.py | 37 ++++++ src/richie/plugins/slider/cms_plugins.py | 39 +++++++ src/richie/plugins/slider/factories.py | 41 +++++++ src/richie/plugins/slider/forms.py | 38 ++++++ .../plugins/slider/migrations/0001_initial.py | 48 ++++++++ .../plugins/slider/migrations/__init__.py | 0 src/richie/plugins/slider/models.py | 109 ++++++++++++++++++ .../templates/richie/slider/slider.html | 27 +++++ tests/plugins/slider/test_cms_plugins.py | 87 ++++++++++++++ 12 files changed, 439 insertions(+), 1 deletion(-) create mode 100644 src/richie/plugins/slider/__init__.py create mode 100644 src/richie/plugins/slider/admin.py create mode 100644 src/richie/plugins/slider/cms_plugins.py create mode 100644 src/richie/plugins/slider/factories.py create mode 100644 src/richie/plugins/slider/forms.py create mode 100644 src/richie/plugins/slider/migrations/0001_initial.py create mode 100644 src/richie/plugins/slider/migrations/__init__.py create mode 100644 src/richie/plugins/slider/models.py create mode 100644 src/richie/plugins/slider/templates/richie/slider/slider.html create mode 100644 tests/plugins/slider/test_cms_plugins.py diff --git a/sandbox/settings.py b/sandbox/settings.py index b584e5a304..80311b6c19 100644 --- a/sandbox/settings.py +++ b/sandbox/settings.py @@ -422,6 +422,7 @@ class Base(StyleguideMixin, DRFMixin, RichieCoursesConfigurationMixin, Configura "richie.plugins.section", "richie.plugins.simple_picture", "richie.plugins.simple_text_ckeditor", + "richie.plugins.slider", "richie.plugins.lti_consumer", "richie", # Third party apps diff --git a/src/richie/apps/courses/settings/__init__.py b/src/richie/apps/courses/settings/__init__.py index 877e0f10b4..3f14b00f90 100644 --- a/src/richie/apps/courses/settings/__init__.py +++ b/src/richie/apps/courses/settings/__init__.py @@ -114,7 +114,7 @@ def richie_placeholder_conf(name): # Homepage "richie/homepage.html maincontent": { "name": _("Main content"), - "plugins": ["LargeBannerPlugin", "SectionPlugin"], + "plugins": ["LargeBannerPlugin", "SectionPlugin", "SliderPlugin"], "child_classes": { "SectionPlugin": [ "BlogPostPlugin", @@ -578,3 +578,14 @@ def richie_placeholder_conf(name): "sizes": "60px", }, } + +# If true the toolbar item will already be showed. If false only a page which already +# have the extension will have the toolbar item and users won't be able to add +# MainMenuEntry extension on existing page, only create new page with index extension +# through the wizard. +RICHIE_MAINMENUENTRY_ALLOW_CREATION = False + +# Define which node level can be processed to search for MainMenuEntry extension. You +# can set it to 'None' for never processing any node. +# This is a limit against performance issues to avoid making querysets for nothing. +RICHIE_MAINMENUENTRY_MENU_ALLOWED_LEVEL = 0 diff --git a/src/richie/plugins/slider/__init__.py b/src/richie/plugins/slider/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/richie/plugins/slider/admin.py b/src/richie/plugins/slider/admin.py new file mode 100644 index 0000000000..0afd6387f9 --- /dev/null +++ b/src/richie/plugins/slider/admin.py @@ -0,0 +1,37 @@ +from django.contrib import admin +from django.utils.translation import gettext_lazy as _ + +from .models import SlideItem +from .forms import SlideItemForm + + +class SlideItemAdmin(admin.StackedInline): + """ + Plugin admin form to enable inline mode inside SliderPlugin + """ + model = SlideItem + form = SlideItemForm + extra = 0 + verbose_name = _("Slide") + ordering = ["order"] + fieldsets = [ + (None, { + "fields": ( + "slider", + ), + }), + (_("Content"), { + "fields": ( + "title", + "image", + "link_url", + "content", + ), + }), + (_("Options"), { + "fields": ( + "order", + "link_open_blank", + ), + }), + ] diff --git a/src/richie/plugins/slider/cms_plugins.py b/src/richie/plugins/slider/cms_plugins.py new file mode 100644 index 0000000000..4f32e00798 --- /dev/null +++ b/src/richie/plugins/slider/cms_plugins.py @@ -0,0 +1,39 @@ +""" +Slider CMS plugin +""" + +from django.utils.translation import gettext_lazy as _ + +from cms.plugin_base import CMSPluginBase +from cms.plugin_pool import plugin_pool + +from richie.apps.core.defaults import PLUGINS_GROUP + +from .admin import SlideItemAdmin +from .forms import SliderForm +from .models import Slider + + +@plugin_pool.register_plugin +class SliderPlugin(CMSPluginBase): + """ + Slider interface is able to add/edit/remove slide items as inline forms. + """ + cache = True + module = PLUGINS_GROUP + name = _("Slider") + model = Slider + form = SliderForm + inlines = (SlideItemAdmin,) + render_template = "richie/slider/slider.html" + fieldsets = ( + (None, {"fields": ["title"]}), + ) + + def render(self, context, instance, placeholder): + context.update({ + "instance": instance, + "placeholder": placeholder, + "slides": instance.slide_item.all().order_by("order") + }) + return context diff --git a/src/richie/plugins/slider/factories.py b/src/richie/plugins/slider/factories.py new file mode 100644 index 0000000000..46c9d84f4c --- /dev/null +++ b/src/richie/plugins/slider/factories.py @@ -0,0 +1,41 @@ +""" +Slider CMS plugin factories +""" + +import factory +import faker + +from .models import Slider, SlideItem + + +class SliderFactory(factory.django.DjangoModelFactory): + """ + Factory to create instance of a Slider. + """ + title = factory.Faker("text", max_nb_chars=20) + + class Meta: + model = Slider + + +class SlideItemFactory(factory.django.DjangoModelFactory): + """ + Factory to create instance of a SlideItem. + """ + slider = factory.SubFactory(SliderFactory) + title = factory.Faker("text", max_nb_chars=20) + content = factory.Faker("text", max_nb_chars=42) + order = factory.Sequence(lambda n: 10 * n) + image = factory.SubFactory("richie.apps.core.factories.FilerImageFactory") + link_open_blank = factory.Faker("pybool") + + class Meta: + model = SlideItem + + @factory.lazy_attribute + def link_url(self): + """ + Set a random url + """ + Faker = faker.Faker() + return Faker.url() diff --git a/src/richie/plugins/slider/forms.py b/src/richie/plugins/slider/forms.py new file mode 100644 index 0000000000..7a9df9d131 --- /dev/null +++ b/src/richie/plugins/slider/forms.py @@ -0,0 +1,38 @@ +""" +Slider plugin forms +""" + +from django import forms + +from djangocms_text_ckeditor.widgets import TextEditorWidget + +from .models import Slider, SlideItem + +CKEDITOR_CONFIGURATION_NAME = "CKEDITOR_SETTINGS" + + +class SliderForm(forms.ModelForm): + class Meta: + model = Slider + exclude = [] + fields = [ + "title", + ] + + +class SlideItemForm(forms.ModelForm): + class Meta: + model = SlideItem + exclude = [] + fields = [ + "slider", + "title", + "order", + "image", + "content", + "link_url", + "link_open_blank", + ] + widgets = { + "content": TextEditorWidget, + } diff --git a/src/richie/plugins/slider/migrations/0001_initial.py b/src/richie/plugins/slider/migrations/0001_initial.py new file mode 100644 index 0000000000..b8d1b3750e --- /dev/null +++ b/src/richie/plugins/slider/migrations/0001_initial.py @@ -0,0 +1,48 @@ +# Generated by Django 4.2.16 on 2024-11-20 02:07 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import filer.fields.image + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('cms', '0022_auto_20180620_1551'), + migrations.swappable_dependency(settings.FILER_IMAGE_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Slider', + fields=[ + ('cmsplugin_ptr', models.OneToOneField(auto_created=True, 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')), + ('title', models.CharField(max_length=255, verbose_name='title')), + ], + options={ + 'verbose_name': 'Slider', + 'verbose_name_plural': 'Sliders', + }, + bases=('cms.cmsplugin',), + ), + migrations.CreateModel( + name='SlideItem', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(default='', max_length=150, verbose_name='title')), + ('content', models.TextField(blank=True, default='', verbose_name='content')), + ('order', models.IntegerField(default=0, verbose_name='order')), + ('link_url', models.URLField(blank=True, help_text='Make the slide as a link with an URL.', max_length=255, null=True, verbose_name='link URL')), + ('link_open_blank', models.BooleanField(default=False, help_text='If checked the link will be open in a new window', verbose_name='open new window')), + ('image', filer.fields.image.FilerImageField(default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='slide_image', to=settings.FILER_IMAGE_MODEL, verbose_name='image')), + ('slider', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='slide_item', to='slider.slider')), + ], + options={ + 'verbose_name': 'Slide item', + 'verbose_name_plural': 'Slide items', + }, + ), + ] diff --git a/src/richie/plugins/slider/migrations/__init__.py b/src/richie/plugins/slider/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/richie/plugins/slider/models.py b/src/richie/plugins/slider/models.py new file mode 100644 index 0000000000..90362f0a8e --- /dev/null +++ b/src/richie/plugins/slider/models.py @@ -0,0 +1,109 @@ +""" +Slider plugin models +""" + +from django.conf import settings +from django.db import models +from django.utils.html import strip_tags +from django.utils.text import Truncator +from django.utils.translation import gettext_lazy as _ + +from cms.models.pluginmodel import CMSPlugin +from filer.fields.image import FilerImageField + + +class Slider(CMSPlugin): + """ + Slide container for items. + + .. Note:: + We did slide item with common foreign key for slides instead of by the way of + child plugins. + + The first is more natural and efficient but the latter has natural drag-n-drop + ordering (in CMS content structure) and maybe less huge form (since slide form + is delegate to child plugin form instead of including slide items as inline + forms). + """ + title = models.CharField(_("title"), max_length=255) + + def __str__(self): + return Truncator(self.title).words(6, truncate="...") + + def copy_relations(self, oldinstance): + """ + Copy FK relations when plugin object is copied as another object + """ + super().copy_relations(oldinstance) + + self.slide_item.all().delete() + + for slide_item in oldinstance.slide_item.all(): + slide_item.pk = None + slide_item.slider = self + slide_item.save() + + class Meta: + verbose_name = _("Slider") + verbose_name_plural = _("Sliders") + + +class SlideItem(models.Model): + """ + Slide item to include in container. + + .. Note:: + Glimpse plugin has similar link feature but: + + * It contains both external free link and CMS page link; + * It does not have option field to enable opening link a blank window (option + is enabled automatically for external links only); + + Should we harmonize with Glimpse or does this implementation is better like + this? + """ + slider = models.ForeignKey( + Slider, + related_name="slide_item", + on_delete=models.CASCADE + ) + title = models.CharField( + _("title"), + max_length=150, + default="", + ) + image = FilerImageField( + related_name="slide_image", + verbose_name=_("image"), + on_delete=models.SET_NULL, + null=True, + default=None, + ) + content = models.TextField( + _("content"), + blank=True, + default="", + ) + order = models.IntegerField( + _("order"), + default=0 + ) + link_url = models.URLField( + verbose_name=_("link URL"), + blank=True, + null=True, + max_length=255, + help_text=_("Make the slide as a link with an URL."), + ) + link_open_blank = models.BooleanField( + _("open new window"), + default=False, + help_text=_("If checked the link will be open in a new window"), + ) + + def __str__(self): + return Truncator(self.title).words(6, truncate="...") + + class Meta: + verbose_name = _("Slide item") + verbose_name_plural = _("Slide items") diff --git a/src/richie/plugins/slider/templates/richie/slider/slider.html b/src/richie/plugins/slider/templates/richie/slider/slider.html new file mode 100644 index 0000000000..51a07393d3 --- /dev/null +++ b/src/richie/plugins/slider/templates/richie/slider/slider.html @@ -0,0 +1,27 @@ +{% load i18n thumbnail %}{% spaceless %} + +
+
+ {% for item in slides %} +
+ +

{{ item.title }}

+ + {% if item.content %} +
{{ item.content|safe }}
+ {% endif %} + + {% if item.link_url %} + + {% endif %} +
+ {% endfor %} +
+
+ +{% endspaceless %} diff --git a/tests/plugins/slider/test_cms_plugins.py b/tests/plugins/slider/test_cms_plugins.py new file mode 100644 index 0000000000..9af76aa63e --- /dev/null +++ b/tests/plugins/slider/test_cms_plugins.py @@ -0,0 +1,87 @@ +""" +Large banner plugin tests +""" + +import re + +from django.db import IntegrityError, transaction +from django.test.client import RequestFactory + +from cms.api import add_plugin +from cms.models import Placeholder +from cms.plugin_rendering import ContentRenderer + +from richie.apps.core.tests.utils import CMSPluginTestCase +from richie.plugins.slider.cms_plugins import SliderPlugin +from richie.plugins.slider.factories import SliderFactory, SlideItemFactory + + +class SliderCMSPluginsTestCase(CMSPluginTestCase): + """Large banner plugin tests case""" + + @transaction.atomic + def test_cms_plugins_slider_title_required(self): + """ + A "title" is required when instantiating a slider. + """ + with self.assertRaises(IntegrityError) as cm: + SliderFactory(title=None) + self.assertTrue( + ( + 'null value in column "title" of relation "slider_slider"' + " violates not-null constraint" + ) + in str(cm.exception) + or "Column 'title' cannot be null" in str(cm.exception) + ) + + def test_cms_plugins_slider_create_success(self): + """ + Slider object creation success + """ + slider = SliderFactory(title="Foo") + self.assertEqual("Foo", slider.title) + + SlideItemFactory(slider=slider) + SlideItemFactory(slider=slider) + self.assertEqual(2, slider.slide_item.all().count()) + + @transaction.atomic + def test_cms_plugins_slider_context_and_html(self): + """ + Instanciating this plugin with an instance should populate the context + and render in the template. + """ + placeholder = Placeholder.objects.create(slot="test") + + # Create random values for parameters with a factory + slider = SliderFactory() + slide_bar = SlideItemFactory(slider=slider, title="Slided bar") + slide_foo = SlideItemFactory(slider=slider, title="Slided foo") + self.assertEqual(2, slider.slide_item.all().count()) + + model_instance = add_plugin( + placeholder, SliderPlugin, "en", title=slider.title + ) + model_instance.copy_relations(slider) + plugin_instance = model_instance.get_plugin_class_instance() + plugin_context = plugin_instance.render({}, model_instance, None) + + # Check if "instance" is in plugin context + self.assertIn("instance", plugin_context) + self.assertIn("slides", plugin_context) + + # Check if parameters, generated by the factory, are correctly set in + # "instance" of plugin context + self.assertEqual(plugin_context["instance"].title, slider.title) + + # Template context + context = self.get_practical_plugin_context() + + # Get generated html for slider title + html = context["cms_content_renderer"].render_plugin(model_instance, {}) + + # Check expected slide titles + title_pattern = "

{}

" + self.assertInHTML(title_pattern.format(slide_bar.title), html) + self.assertInHTML(title_pattern.format(slide_foo.title), html)