Repository: django-cms/django-cms (language: python)

Issue:
[BUG] Special characters in page title
## Description

If a page title contains special characters, such as `&`, then this character is rendered as HTML-entity, for instance `&amp;`.
In browser tabs this does not look right.


## Steps to reproduce

* Create a page template where the page title is used. This usually is done by adding

```html
…
<meta>
  {% page_attribute "page_title" as page_title %}
  <title>{{ page_title}}</title>
  …
</meta>
…
```


* Create or edit a page and use `A & B` as the page title.
* Load the page.

The browser tab shows `A &amp; B`.

## Expected behaviour

The browser tab should show `A & B` as page title.


## Do you want to help fix this issue?

By patching the templatetag `cms.templatetags.cms_tags.PageAttribute` and [not escaping the value](https://github.com/django-cms/django-cms/blob/fa7b89ceec3bd22e031be8e9cf2328a3f9434b12/cms/templatetags/cms_tags.py#L411), this can be fixed easily. However, I don't know what security implications this fix may have.

I would exempt escaping the string, whenever the name is `page_title`, just as we do it with values of type `datetime`.

Any objections to this?


* [x] Yes, I want to help fix this issue and I will join #workgroup-pr-review on [Slack](https://www.django-cms.org/slack) to confirm with the community that a PR is welcome. 
* [ ] No, I only want to report the issue.



Discussion / hints:
@jrief Wouldn't it suffice to add `safe`? 
```
<meta>
  {% page_attribute "page_title" as page_title %}
  <title>{{ page_title|safe }}</title>
  …
</meta>
```
Obviously, this would open up a vulnerability to scripting attacks. Maybe a custom filter that removes any tags but keeps symbols is the right way to go? (I am thinking of an `escape` like filter that keeps the ampersand and quote symbols. Not sure, though, which malicious things you could do with that.)
Another thought: `<title>{{ page_title|striptags }}</title>`?
Unfortunately both of them do not work.

Problem is, that the string is already escaped before creating the rendering context.
This could be circumvented by unescaping that string again. However, Django does not provide any template filter for this. 
As a quick fix (use it as `...|unescape|safe`):
```
def unescape(value): 
    """Unescapes ampersands"""
    return str(value).replace("&amp;","&")
```

Still, I would expect `page_attribute` to deliver unescaped strings, which Django would autoescape if no `safe` filter is added. I'll have a look at the code...
I found a solution for that problem. It could be solved by applying this [patch](https://github.com/jrief/django-cms/commit/3750ae321bd6eb39670e675c0ff92e1961fde58f) without sacrificing the security.
An alternative work-around is to use

```
<meta>
  <title>{{ request.current_page.get_paget_title|striptags }}</title>
  …
</meta>
```
`<title>{{ request.current_page.get_page_title|striptags }}</title>` is quite a lot of inderiction for an object for which we already have a canonical handle.

Shouldn't we disallow HTML tags in the `title`-field in the `clean()`-method before saving rather then on rendering?

Fix patch:
diff --git a/.github/ISSUE_TEMPLATE/---bug-report.md b/.github/ISSUE_TEMPLATE/---bug-report.md
index d9617326e1a..5b754e5dd3e 100644
--- a/.github/ISSUE_TEMPLATE/---bug-report.md
+++ b/.github/ISSUE_TEMPLATE/---bug-report.md
@@ -57,9 +57,9 @@ CMS/Python/Django versions, logs etc. here.
 
 ## Do you want to help fix this issue?
 
-<!-- 
+<!--
 The django CMS project is managed and kept alive by its open source community and is backed by the [django CMS Association](https://www.django-cms.org/en/about-us/). We therefore welcome any help and are grateful if people contribute to the project. Please use 'x' to check the items below.
 -->
 
-* [ ] Yes, I want to help fix this issue and I will join #workgroup-pr-review on [Slack](https://www.django-cms.org/slack) to confirm with the community that a PR is welcome. 
+* [ ] Yes, I want to help fix this issue and I will join the channel #pr-reviews on [the Discord Server](https://discord-pr-review-channel.django-cms.org) to confirm with the community that a PR is welcome.
 * [ ] No, I only want to report the issue.
diff --git a/.github/ISSUE_TEMPLATE/---documentation-report.md b/.github/ISSUE_TEMPLATE/---documentation-report.md
index 3f6bda9822e..ab4ce851cd3 100644
--- a/.github/ISSUE_TEMPLATE/---documentation-report.md
+++ b/.github/ISSUE_TEMPLATE/---documentation-report.md
@@ -35,9 +35,9 @@ CMS/Python/Django versions, logs etc. here.
 
 ## Do you want to help fix this documentation issue?
 
-<!-- 
+<!--
 The django CMS project is managed and kept alive by its open source community and is backed by the [django CMS Association](https://www.django-cms.org/en/about-us/). We therefore welcome any help and are grateful if people contribute to the project. Please use 'x' to check the items below.
 -->
 
-* [ ] Yes, I want to help fix this issue and I will join #workgroup-documentation on [Slack](https://www.django-cms.org/slack) to confirm with the team that a PR is welcome. 
+* [ ] Yes, I want to help fix this issue and I will join the channel #documentation on our [Discord Server](https://discord-docs-channel.django-cms.org) to confirm with the team that a PR is welcome.
 * [ ] No, I only want to report the issue.
diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md
index 308c8c4b1d0..84aa36c3ed9 100644
--- a/.github/PULL_REQUEST_TEMPLATE.md
+++ b/.github/PULL_REQUEST_TEMPLATE.md
@@ -27,4 +27,4 @@ Use 'x' to check each item: [x] I have ...
 * [ ] I have opened this pull request against ``develop-4``
 * [ ] 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 the channel #pr-reviews on our [Discord Server](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-pr.yml b/.github/workflows/lint-pr.yml
index df9bbde4f16..27471d9d127 100644
--- a/.github/workflows/lint-pr.yml
+++ b/.github/workflows/lint-pr.yml
@@ -15,6 +15,6 @@ jobs:
     name: Validate PR title
     runs-on: ubuntu-latest
     steps:
-      - uses: amannn/action-semantic-pull-request@v5.4.0
+      - uses: amannn/action-semantic-pull-request@v5.5.2
         env:
           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/new_contributor_pr.yml b/.github/workflows/new_contributor_pr.yml
index f338437c15f..e4400aede29 100644
--- a/.github/workflows/new_contributor_pr.yml
+++ b/.github/workflows/new_contributor_pr.yml
@@ -20,7 +20,7 @@ jobs:
 
             As it's your first contribution, be sure to check out the [contribution docs](https://docs.django-cms.org/en/latest/contributing/index.html).
 
-            If you're a Slack user and haven't joined us, please do [here](https://www.django-cms.org/slack)!
+            We invite you to join us on our [Discord Server](https://discord-main-channel.django-cms.org)!
 
             Welcome aboard ⛵️!
   discord:
diff --git a/CONTRIBUTING.rst b/CONTRIBUTING.rst
index 895afe3fb8d..38c10317582 100644
--- a/CONTRIBUTING.rst
+++ b/CONTRIBUTING.rst
@@ -29,7 +29,7 @@ Here’s what the pull request process looks like in brief:
 1. Fork our GitHub repository, https://github.com/django-cms/django-cms
 2. Work locally and push your changes to your repository.
 3. When you feel your code is good enough for inclusion, send us a pull request.
-4. After that, please join the `Slack Channel <https://www.django-cms.org/slack>`_ of our Pull Request Review work group (#workgroup-pr-review). This group of friendly community members is dedicated to reviewing pull requests. Report your PR and find a “pr review buddy” who is going to review your pull request.
+4. After that, please join our `Discord Server <https://discord-pr-review-channel.django-cms.org>`_ . This group of friendly community members is dedicated to reviewing pull requests. Report your PR and find a “pr review buddy” who is going to review your pull request.
 5. Get acknowledged by the django CMS community for your contribution :-)
 
 To learn more about basic requirements and standards of code contribution, please familiarize yourself with our comprehensive `documentation <https://docs.django-cms.org/en/latest/contributing/code.html#>`_.
@@ -48,7 +48,7 @@ To create a proposal...
 
 2. create a discussion in the main `django CMS repository <https://github.com/django-cms/django-cms/discussions>`_
 
-3. join the `Tech Committee <https:/github.com/django-cms/django-cms-mgmt/blob/master/tech-committee/about.md>`_ `Slack Channel <https://www.django-cms.org/slack>`_ (#technical-committee) and ask for a RFC.
+3. join the `Tech Committee <https:/github.com/django-cms/django-cms-mgmt/blob/master/tech-committee/about.md>`_ `Channel <https://discord-tech-committee-channel.django-cms.org>`_ (#technical-committee) and ask for a RFC.
 
 4. After RFC is completed, add the proposal to the `agenda <https://hackmd.io/ddhvq_aqS6my9gwhLddyPg>`_ of the TC for approval.
 
@@ -75,16 +75,12 @@ Community & Association
 
 You can join us online:
 
-* in our `django CMS Slack channel <https://www.django-cms.org/slack>`_
+* at our `django CMS Discord Server <https://discord-main-channel.django-cms.org>`_
 
 You can join a work group and work collaboratively on django CMS
 
 * `work groups <https://www.django-cms.org/en/join-work-group/>`_
 
-You can also follow:
-
-* the `@djangocms <https://twitter.com/djangocms>`_ Twitter account for general announcements
-
 You can become a member of the django CMS Association and receive benefits
 
 * `Membership <https://www.django-cms.org/en/memberships/>`_
diff --git a/README.rst b/README.rst
index b49a1230fc7..f254a7c60a0 100644
--- a/README.rst
+++ b/README.rst
@@ -96,7 +96,7 @@ The demo platform is kindly provided by Divio, platinum member of the django CMS
 Getting Help
 ************
 
-Please head over to our `discord forum <https://www.django-cms.org/discord>`_ for support.
+Please head over to our `Discord Server <https://discord-support-channel.django-cms.org>`_ or Stackoverflow for support.
 
 ********************
 Professional support
diff --git a/cms/admin/forms.py b/cms/admin/forms.py
index 6c778ff68ed..d26e71618a2 100644
--- a/cms/admin/forms.py
+++ b/cms/admin/forms.py
@@ -1161,6 +1161,7 @@ class Meta:
             "can_add",
             "can_change",
             "can_delete",
+            "can_publish",
             "can_change_advanced_settings",
             "can_change_permissions",
             "can_move_page",
@@ -1204,6 +1205,7 @@ class Meta:
             "can_add",
             "can_change",
             "can_delete",
+            "can_publish",
             "can_change_advanced_settings",
             "can_change_permissions",
             "can_move_page",
diff --git a/cms/admin/pageadmin.py b/cms/admin/pageadmin.py
index a9b706d2655..17f0e19bb97 100644
--- a/cms/admin/pageadmin.py
+++ b/cms/admin/pageadmin.py
@@ -1051,6 +1051,24 @@ def has_change_permission(self, request, obj=None):
         )
         return can_change_page
 
+    def has_view_permission(self, request, obj=None):
+        """
+        Return true if the current user has permission on the page.
+        Return the string 'All' if the user has all rights.
+        """
+        # Identical to has_change_permission, but will remain untouched by any subclassing
+        # as done, e.g., by djangocms-versioning
+        site = get_site(request)
+
+        if obj:
+            return page_permissions.user_can_change_page(request.user, page=obj.page, site=site)
+        can_view_page = page_permissions.user_can_change_at_least_one_page(
+            user=request.user,
+            site=get_site(request),
+            use_cache=False,
+        )
+        return can_view_page
+
     def has_delete_permission(self, request, obj=None):
         """
         Returns True if the current user has permission to delete the page.
diff --git a/cms/admin/permissionadmin.py b/cms/admin/permissionadmin.py
index af981bd8e4e..83dce0c4c9b 100644
--- a/cms/admin/permissionadmin.py
+++ b/cms/admin/permissionadmin.py
@@ -97,6 +97,8 @@ def get_formset(self, request, obj=None, **kwargs):
                 exclude.append('can_add')
             if not obj.has_delete_permission(user):
                 exclude.append('can_delete')
+            if not obj.has_publish_permission(user):
+                exclude.append('can_publish')
             if not obj.has_advanced_settings_permission(user):
                 exclude.append('can_change_advanced_settings')
             if not obj.has_move_page_permission(user):
@@ -120,8 +122,8 @@ class ViewRestrictionInlineAdmin(PagePermissionInlineAdmin):
 
 
 class GlobalPagePermissionAdmin(admin.ModelAdmin):
-    list_display = ['user', 'group', 'can_change', 'can_delete', 'can_change_permissions']
-    list_filter = ['user', 'group', 'can_change', 'can_delete', 'can_change_permissions']
+    list_display = ['user', 'group', 'can_change', 'can_delete', 'can_publish', 'can_change_permissions']
+    list_filter = ['user', 'group', 'can_change', 'can_delete', 'can_publish', 'can_change_permissions']
 
     form = GlobalPagePermissionAdminForm
     search_fields = []
diff --git a/cms/api.py b/cms/api.py
index 08447ad1e3e..179f5f18d09 100644
--- a/cms/api.py
+++ b/cms/api.py
@@ -437,7 +437,7 @@ def add_plugin(placeholder, plugin_type, language, position='last-child',
 def create_page_user(created_by, user,
                      can_add_page=True, can_view_page=True,
                      can_change_page=True, can_delete_page=True,
-                     can_recover_page=True, can_add_pageuser=True,
+                     can_publish_page=True, can_add_pageuser=True,
                      can_change_pageuser=True, can_delete_pageuser=True,
                      can_add_pagepermission=True,
                      can_change_pagepermission=True,
@@ -466,7 +466,7 @@ def create_page_user(created_by, user,
         'can_view_page': can_view_page,
         'can_change_page': can_change_page,
         'can_delete_page': can_delete_page,
-        'can_recover_page': can_recover_page,
+        'can_publish_page': can_publish_page,
         'can_add_pageuser': can_add_pageuser,
         'can_change_pageuser': can_change_pageuser,
         'can_delete_pageuser': can_delete_pageuser,
@@ -506,15 +506,13 @@ def assign_user_to_page(page, user, grant_on=ACCESS_PAGE_AND_DESCENDANTS,
     :param can_*: Permissions to grant
     :param bool grant_all: Grant all permissions to the user
     """
-    if can_publish is not None:
-        warnings.warn('This API function no longer accepts a "can_publish" argument.',
-                      UserWarning, stacklevel=2)
 
     grant_all = grant_all and not global_permission
     data = {
         'can_add': can_add or grant_all,
         'can_change': can_change or grant_all,
         'can_delete': can_delete or grant_all,
+        'can_publish': can_publish or grant_all,
         'can_change_advanced_settings': can_change_advanced_settings or grant_all,
         'can_change_permissions': can_change_permissions or grant_all,
         'can_move_page': can_move_page or grant_all,
diff --git a/cms/cache/permissions.py b/cms/cache/permissions.py
index 2ba2ac13974..2956441fb01 100644
--- a/cms/cache/permissions.py
+++ b/cms/cache/permissions.py
@@ -3,7 +3,7 @@
 PERMISSION_KEYS = [
     'add_page', 'change_page', 'change_page_advanced_settings',
     'change_page_permissions', 'delete_page', 'move_page',
-    'view_page',
+    'publish_page', 'view_page',
 ]
 
 
diff --git a/cms/cache/placeholder.py b/cms/cache/placeholder.py
index 2c464dd55e6..12623b85868 100644
--- a/cms/cache/placeholder.py
+++ b/cms/cache/placeholder.py
@@ -96,7 +96,8 @@ def _get_placeholder_cache_key(placeholder, lang, site_id, request, soft=False):
     """
     prefix = get_cms_setting('CACHE_PREFIX')
     version, vary_on_list = _get_placeholder_cache_version(placeholder, lang, site_id)
-    main_key = f'{prefix}|render_placeholder|id:{placeholder.pk}|lang:{lang}|site:{site_id}|tz:{get_timezone_name()}|v:{version}'
+    tz = get_timezone_name()
+    main_key = f"{prefix}|render_placeholder|id:{placeholder.pk}|lang:{lang}|site:{site_id}|tz:{tz}|v:{version}"
 
     if not soft:
         # We are about to write to the cache, so we want to get the latest
@@ -119,7 +120,13 @@ def _get_placeholder_cache_key(placeholder, lang, site_id, request, soft=False):
     if sub_key_list:
         cache_key += '|' + '|'.join(sub_key_list)
 
-    if len(cache_key) > 250:
+    # django itself adds "version" add the end of cache-keys, e.g. "<key>:1".
+    # -> If `cache.set()` is for example called with `version=""`, it still adds
+    #    `:` at the end. So if we check the length for `> 250`, a length of 249
+    #    or even 250 ends up in an InvalidCacheKey-exception.
+    # In order to avoid these errors, we hash the keys at a lower length to also
+    # have a little buffer.
+    if len(cache_key) > 200:
         cache_key = '{prefix}|{hash}'.format(
             prefix=prefix,
             hash=hashlib.sha1(cache_key.encode('utf-8')).hexdigest(),
diff --git a/cms/management/commands/startcmsproject.py b/cms/management/commands/startcmsproject.py
index 74cbe6fa3f9..3c1353c7ab2 100644
--- a/cms/management/commands/startcmsproject.py
+++ b/cms/management/commands/startcmsproject.py
@@ -100,7 +100,7 @@ def postprocess(self, project, options):
 $ {self.style.SUCCESS("python -m manage runserver")}
 
 Learn more at https://docs.django-cms.org/
-Join the django CMS Slack channel http://www.django-cms.org/slack
+Join the django CMS Discord Server at https://discord-main-channel.django-cms.org
 
 Enjoy!
 """
diff --git a/cms/migrations/0035_auto_20230822_2208_squashed_0036_auto_20240311_1028.py b/cms/migrations/0035_auto_20230822_2208_squashed_0036_auto_20240311_1028.py
new file mode 100644
index 00000000000..6136f521fac
--- /dev/null
+++ b/cms/migrations/0035_auto_20230822_2208_squashed_0036_auto_20240311_1028.py
@@ -0,0 +1,23 @@
+# Generated by Django 3.2.23 on 2024-03-11 09:32
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    replaces = [('cms', '0035_auto_20230822_2208'), ('cms', '0036_auto_20240311_1028')]
+
+    dependencies = [
+        ('cms', '0034_remove_pagecontent_placeholders'),
+    ]
+
+    operations = [
+        migrations.AlterModelOptions(
+            name='pagecontent',
+            options={'default_permissions': [], 'verbose_name': 'page content', 'verbose_name_plural': 'page contents'},
+        ),
+        migrations.AlterModelOptions(
+            name='page',
+            options={'default_permissions': ('add', 'change', 'delete'), 'permissions': (('view_page', 'Can view page'), ('publish_page', 'Can publish page'), ('edit_static_placeholder', 'Can edit static placeholders')), 'verbose_name': 'page', 'verbose_name_plural': 'pages'},
+        ),
+    ]
diff --git a/cms/migrations/0036_auto_20240311_1028.py b/cms/migrations/0036_auto_20240311_1028.py
new file mode 100644
index 00000000000..2b228d9e693
--- /dev/null
+++ b/cms/migrations/0036_auto_20240311_1028.py
@@ -0,0 +1,27 @@
+# Generated by Django 3.2.23 on 2024-03-11 10:28
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('cms', '0035_auto_20230822_2208'),
+    ]
+
+    operations = [
+        migrations.AlterModelOptions(
+            name='page',
+            options={'default_permissions': ('add', 'change', 'delete'), 'permissions': (('view_page', 'Can view page'), ('publish_page', 'Can publish page'), ('edit_static_placeholder', 'Can edit static placeholders')), 'verbose_name': 'page', 'verbose_name_plural': 'pages'},
+        ),
+        migrations.AddField(
+            model_name='globalpagepermission',
+            name='can_publish',
+            field=models.BooleanField(default=True, verbose_name='can publish'),
+        ),
+        migrations.AddField(
+            model_name='pagepermission',
+            name='can_publish',
+            field=models.BooleanField(default=True, verbose_name='can publish'),
+        ),
+    ]
diff --git a/cms/models/contentmodels.py b/cms/models/contentmodels.py
index 2b4946577b2..0a45249357c 100644
--- a/cms/models/contentmodels.py
+++ b/cms/models/contentmodels.py
@@ -172,6 +172,9 @@ def toggle_in_navigation(self, set_to=None):
     def has_placeholder_change_permission(self, user):
         return self.page.has_change_permission(user)
 
+    def has_publish_permission(self, user):
+        return self.page.has_publish_permission(user)
+
     def rescan_placeholders(self):
         """
         Rescan and if necessary create placeholders in the current template.
diff --git a/cms/models/pagemodel.py b/cms/models/pagemodel.py
index bcd53ae3e91..a290b6e3ec7 100644
--- a/cms/models/pagemodel.py
+++ b/cms/models/pagemodel.py
@@ -187,6 +187,7 @@ class Meta:
         default_permissions = ('add', 'change', 'delete')
         permissions = (
             ('view_page', 'Can view page'),
+            ('publish_page', 'Can publish page'),
             ('edit_static_placeholder', 'Can edit static placeholders'),
         )
         verbose_name = _('page')
@@ -995,6 +996,10 @@ def has_delete_translation_permission(self, user, language):
         from cms.utils.page_permissions import user_can_delete_page_translation
         return user_can_delete_page_translation(user, page=self, language=language)
 
+    def has_publish_permission(self, user):
+        from cms.utils.page_permissions import user_can_publish_page
+        return user_can_publish_page(user, page=self)
+
     def has_advanced_settings_permission(self, user):
         from cms.utils.page_permissions import (
             user_can_change_page_advanced_settings,
diff --git a/cms/models/permissionmodels.py b/cms/models/permissionmodels.py
index c6b86ae233a..51884f13a51 100644
--- a/cms/models/permissionmodels.py
+++ b/cms/models/permissionmodels.py
@@ -83,6 +83,7 @@ class AbstractPagePermission(models.Model):
     can_change = models.BooleanField(_("can edit"), default=True)
     can_add = models.BooleanField(_("can add"), default=True)
     can_delete = models.BooleanField(_("can delete"), default=True)
+    can_publish = models.BooleanField(_("can publish"), default=True)
     can_change_advanced_settings = models.BooleanField(_("can change advanced settings"), default=False)
     can_change_permissions = models.BooleanField(
         _("can change permissions"), default=False, help_text=_("on page level")
@@ -113,6 +114,11 @@ def clean(self):
                         "to change the page. Edit permissions required.")
             raise ValidationError(message)
 
+        if self.can_publish:
+            message = _("Users can't publish a page without permissions "
+                        "to change the page. Edit permissions required.")
+            raise ValidationError(message)
+
         if self.can_change_advanced_settings:
             message = _("Users can't change page advanced settings without permissions "
                         "to change the page. Edit permissions required.")
@@ -156,6 +162,7 @@ def get_all_permissions(cls):
             'can_add',
             'can_change',
             'can_delete',
+            'can_publish',
             'can_change_advanced_settings',
             'can_change_permissions',
             'can_move_page',
@@ -174,6 +181,7 @@ def get_permissions_by_action(cls):
             'change_page_permissions': ['can_change', 'can_change_permissions'],
             'delete_page': ['can_change', 'can_delete'],
             'delete_page_translation': ['can_change', 'can_delete'],
+            'publish_page': ['can_change', 'can_publish'],
             'move_page': ['can_change', 'can_move_page'],
             'view_page': ['can_view'],
         }
diff --git a/cms/plugin_rendering.py b/cms/plugin_rendering.py
index 0c37f6b46fc..9a27c25c33c 100644
--- a/cms/plugin_rendering.py
+++ b/cms/plugin_rendering.py
@@ -13,7 +13,7 @@
 from django.utils.translation import override
 
 from cms.cache.placeholder import get_placeholder_cache, set_placeholder_cache
-from cms.models import PageContent
+from cms.models import PageContent, Placeholder
 from cms.toolbar.utils import (
     get_placeholder_toolbar_js,
     get_plugin_toolbar_js,
@@ -554,6 +554,28 @@ def _get_cached_placeholder_content(self, placeholder, language):
                 language_cache[placeholder.pk] = cached_value
         return language_cache.get(placeholder.pk)
 
+
+    def _get_content_object(self, page, slots=None):
+        if self.toolbar.get_object() == page:
+            # Current object belongs to the page itself
+            page_content = self.toolbar.get_object()
+            placeholders = Placeholder.objects.get_for_obj(page_content)
+        elif slots:
+            # If looking for inherited placeholders, slots is specified
+            if self.toolbar.preview_mode_active or self.toolbar.edit_mode_active:
+                page_content = (page.pagecontent_set(manager="admin_manager")
+                                .current_content(language=self.request_language).first())
+            else:
+                page_content = page.pagecontent_set.filter(language=self.request_language).first()
+            placeholders = Placeholder.objects.get_for_obj(page_content) if page_content else Placeholder.objects.none()
+        else:
+            page_content = page.get_content_obj(self.request_language, fallback=False)
+
+            PageContent.page.field.set_cached_value(page_content, page)
+            # Creates any placeholders missing on the page
+            placeholders = page_content.rescan_placeholders().values()
+        return placeholders
+
     def _preload_placeholders_for_page(self, page, slots=None, inherit=False):
         """
         Populates the internal plugin cache of each placeholder
@@ -562,14 +584,7 @@ def _preload_placeholders_for_page(self, page, slots=None, inherit=False):
         """
         from cms.utils.plugins import assign_plugins
 
-        if slots:
-            placeholders = page.get_placeholders(self.request_language).filter(slot__in=slots)
-        else:
-            title = page.get_content_obj(self.request_language, fallback=False)
-
-            PageContent.page.field.set_cached_value(title, page)
-            # Creates any placeholders missing on the page
-            placeholders = title.rescan_placeholders().values()
+        placeholders = self._get_content_object(page, slots=slots)
 
         if inherit:
             # When the inherit flag is True,
diff --git a/cms/static/cms/js/modules/cms.modal.js b/cms/static/cms/js/modules/cms.modal.js
index 7fe59a5def3..95e416d794b 100644
--- a/cms/static/cms/js/modules/cms.modal.js
+++ b/cms/static/cms/js/modules/cms.modal.js
@@ -576,6 +576,7 @@ class Modal {
         var height = this.ui.modal.height();
         var modalLeft = this.ui.modal.position().left;
         var modalTop = this.ui.modal.position().top;
+        var resizeDir = this.ui.resize.css('direction') === 'rtl' ? -1 : +1;
 
         // create event for stopping
         this.ui.body.on(this.pointerUp, function(e) {
@@ -588,11 +589,11 @@ class Modal {
             .on(this.pointerMove, function(e) {
                 var mvX = pointerEvent.originalEvent.pageX - e.originalEvent.pageX;
                 var mvY = pointerEvent.originalEvent.pageY - e.originalEvent.pageY;
-                var w = width - mvX * 2;
+                var w = width - resizeDir * mvX * 2;
                 var h = height - mvY * 2;
                 var wMin = that.options.minWidth;
                 var hMin = that.options.minHeight;
-                var left = mvX + modalLeft;
+                var left = resizeDir * mvX + modalLeft;
                 var top = mvY + modalTop;
 
                 // add some limits
diff --git a/cms/static/cms/js/modules/cms.structureboard.js b/cms/static/cms/js/modules/cms.structureboard.js
index d8012e08efd..02c656b7cc6 100644
--- a/cms/static/cms/js/modules/cms.structureboard.js
+++ b/cms/static/cms/js/modules/cms.structureboard.js
@@ -175,7 +175,7 @@ class StructureBoard {
 
     _events() {
         this.ui.window.on('resize.cms.structureboard', () => {
-            if (!this._loadedContent) {
+            if (!this._loadedContent || CMS.config.mode !== 'draft') {
                 return;
             }
             const width = this.ui.window[0].innerWidth;
@@ -683,7 +683,7 @@ class StructureBoard {
 
         if (CMS.settings.mode === 'structure') {
             history.replaceState({}, '', CMS.config.settings.structure);
-            this.ui.html.addClass('cms-overflow');
+            $('html.cms-structure-mode-structure').addClass('cms-overflow');
         }
 
         this.ui.container.css('right', 0);
diff --git a/cms/static/cms/sass/cms.base.scss b/cms/static/cms/sass/cms.base.scss
index c252d30f52d..49ef9670b77 100644
--- a/cms/static/cms/sass/cms.base.scss
+++ b/cms/static/cms/sass/cms.base.scss
@@ -54,9 +54,7 @@ div.cms {
     @import "components/pagetree/node-state";
 }
 
-//##############################################################################
-// DJANGO DEBUG TOOLBAR FIX
-// removed as of: //github.com/divio/django-cms/issues/2476
-// #djDebug #djDebugToolbarHandle {
-//     top:35px !important; border-right:none !important;
-// }
+ // Can be removed once suffucently many users have browsers the support `:dir()`
+div.cms[dir=rtl] {
+    @import "libs/rtl_patch";
+}
diff --git a/cms/static/cms/sass/cms.wizard.scss b/cms/static/cms/sass/cms.wizard.scss
index 86d5862379a..e5d47badcf8 100644
--- a/cms/static/cms/sass/cms.wizard.scss
+++ b/cms/static/cms/sass/cms.wizard.scss
@@ -45,6 +45,9 @@ $wizard-input-size: 16px;
         // tablet and up
         @media (min-width: $screen-tablet) {
             float: left !important;
+            &:dir(rtl) {
+                float: right !important;
+            }
             width: 48% !important;
         }
 
diff --git a/cms/static/cms/sass/components/_modal.scss b/cms/static/cms/sass/components/_modal.scss
index e9093180f05..be423ab899f 100644
--- a/cms/static/cms/sass/components/_modal.scss
+++ b/cms/static/cms/sass/components/_modal.scss
@@ -193,7 +193,7 @@
 
 .cms-modal-resize {
     position: absolute;
-    right: 0;
+    inset-inline-end: 0;
     bottom: 0;
     z-index: z(modal, resize);
     font-size: 10px;
@@ -201,11 +201,17 @@
     width: $modal-resize-size;
     height: $modal-resize-size;
     cursor: nw-resize;
+    &:dir(rtl) {
+        cursor: ne-resize;
+    }
     span {
         position: absolute;
         bottom: 5px;
         right: 5px;
         font-size: $font-size-small;
+        &:dir(rtl) {
+            transform: scale(-1,1) translate(2px, 0);  // flip drag triangle
+        }
     }
 }
 
@@ -256,6 +262,9 @@
     $margin: math.div($toolbar-height - $toolbar-button-height, 2);
     @extend .cms-toolbar-item-buttons;
     float: right;
+    &:dir(rtl) {
+        float: left;
+    }
     margin-inline-start: $margin;
 
     .cancel-link {
@@ -264,6 +273,9 @@
 }
 .cms-modal-item-buttons-left {
     float: left;
+    &:dir(rtl) {
+        float: right;
+    }
 }
 
 // alter footer when html markup is loaded
diff --git a/cms/static/cms/sass/components/_sideframe.scss b/cms/static/cms/sass/components/_sideframe.scss
index 82f4f893f0d..d72bbe7e467 100644
--- a/cms/static/cms/sass/components/_sideframe.scss
+++ b/cms/static/cms/sass/components/_sideframe.scss
@@ -8,7 +8,7 @@
     display: none;
     position: fixed;
     top: 0;
-    left: 0;
+    inline-start: 0;
     width: 0;
     bottom: 0;
     z-index: z(sideframe, base);
@@ -60,7 +60,7 @@
 .cms-sideframe-controls {
     position: absolute;
     top: $sideframe-buttons-position-top + $sideframe-buttons-offset;
-    right: $sideframe-buttons-position-right;
+    inset-inline-end: $sideframe-buttons-position-right;
     z-index: z(sideframe, buttons);
     box-shadow: $toolbar-shadow;
     background-color: $white;
diff --git a/cms/static/cms/sass/components/_structureboard.scss b/cms/static/cms/sass/components/_structureboard.scss
index 04aece4afe6..8a0300b31c3 100644
--- a/cms/static/cms/sass/components/_structureboard.scss
+++ b/cms/static/cms/sass/components/_structureboard.scss
@@ -17,12 +17,12 @@
         content: "";
         position: absolute;
         top: -8px;
-        left: -4px;
+        inset-inline-start: -4px;
         width: 0;
         height: 0;
         border-top: 8px solid transparent;
         border-bottom: 8px solid transparent;
-        border-left: 8px solid $color-primary;
+        border-inline-start: 8px solid $color-primary;
     }
 }
 .cms-draggables > .cms-droppable:first-child {
@@ -371,7 +371,7 @@
             margin-inline-start: $structure-dragarea-padding-horizontal - 10px;
             cursor: pointer;
             transform: rotate(180deg);
-            [dir="rtl"] & {
+            &:dir(rtl) {
                 transform: rotate(0deg);
             }
         }
@@ -381,7 +381,7 @@
     }
     .cms-dragitem-expanded:before {
         transform: rotate(-90deg);
-        [dir="rtl"] & {
+        &:dir(rtl) {
             transform: rotate(-90deg);
         }
     }
diff --git a/cms/static/cms/sass/components/_toolbar.scss b/cms/static/cms/sass/components/_toolbar.scss
index dd4a2d6a9d0..aa35b4618c4 100644
--- a/cms/static/cms/sass/components/_toolbar.scss
+++ b/cms/static/cms/sass/components/_toolbar.scss
@@ -56,7 +56,7 @@
     // items
     .cms-toolbar-item {
         float: left;
-        [dir=rtl] & {
+        &:dir(rtl) {
             float: right;
         }
     }
@@ -85,7 +85,7 @@
         z-index: 30;
 
         @at-root .cms-structure-mode-structure & {
-            right: $toolbar-height;
+            inset-inline-end: $toolbar-height;
         }
     }
     .cms-toolbar .cms-btn-action {
@@ -107,13 +107,13 @@
     li {
         position: relative;
         float: left;
-        [dir=rtl] & {
+        &:dir(rtl) {
             float: right;
         }
     }
     li a {
         float: left;
-        [dir=rtl] & {
+        &:dir(rtl) {
             float: right;
         }
         color: $toolbar-menu-item-color;
@@ -184,7 +184,7 @@
                 display: block;
                 .cms-icon {
                     display: block;
-                    [dir="rtl"] & {
+                    &:dir(rtl) {
                         // undo arrow rotation for RTL
                         transform: rotate(0deg);
                     }
@@ -241,7 +241,7 @@
 
     .cms-toolbar-item {
         float: none;
-        [dir=rtl] & {
+        [dir="rtl"] & {
             // a bit weird, but if removed, it will best match
             // with [dir="rtl"] div.cms .cms-toolbar .cms-toolbar-item
             // and will float to the right
@@ -254,7 +254,7 @@
     }
     .cms-toolbar-item-cms-mode-switcher a {
         float: left !important;
-        [dir=rtl] & {
+        &:dir(rtl) {
             float: right !important;
         }
         width: 50%;
@@ -415,7 +415,7 @@
         cursor: pointer;
         display: none;
         float: right;
-        [dir=rtl] & {
+        &:dir(rtl) {
             float: left;
         }
         position: relative;
@@ -570,7 +570,7 @@
 
     a {
         float: left;
-        [dir=rtl] & {
+        &:dir(rtl) {
             float: right;
         }
         line-height: $toolbar-button-height;
diff --git a/cms/static/cms/sass/components/pagetree/_tree.scss b/cms/static/cms/sass/components/pagetree/_tree.scss
index 18d0a73f570..e67d1542555 100644
--- a/cms/static/cms/sass/components/pagetree/_tree.scss
+++ b/cms/static/cms/sass/components/pagetree/_tree.scss
@@ -390,7 +390,7 @@
         color: $gray-darkest;
         background: none;
 
-        [dir=rtl] & {
+        &:dir(rtl) {
             // inline-start & inline-end are not widely supported
             float: right;
         }
@@ -451,7 +451,7 @@
     min-width: $pagetree-cell-inner-height;
     height: $pagetree-cell-inner-height;
 
-    [dir=rtl] & {
+    &:dir(rtl) {
         // inline-start & inline-end are not widely supported
         float: left;
     }
@@ -501,7 +501,7 @@
 .cms-tree-reload {
     float: right;
 
-    [dir=rtl] & {
+    &:dir(rtl) {
         // inline-start & inline-end are not widely supported
         float: left;
     }
@@ -652,7 +652,7 @@
     float: left;
     margin-inline-end: 5px;
 
-    [dir=rtl] & {
+    &:dir(rtl) {
         // inline-start & inline-end are not widely supported
         float: right;
     }
@@ -676,7 +676,7 @@
 .cms-tree-filters {
     float: right;
 
-    [dir=rtl] & {
+    &:dir(rtl) {
         // inline-start & inline-end are not widely supported
         float: left;
     }
@@ -822,7 +822,7 @@
         margin-inline-start: -1px;
     }
     .jstree-ocl {
-        float: left; 
+        float: left;
         position: relative;
         inset-inline-start: $pagetree-nesting-padding;
         font-size: 12px;
@@ -832,7 +832,7 @@
         padding-block-end: 10px;
         padding-inline-start: 0px;
 
-        [dir=rtl] & {
+        &:dir(rtl) {
             // inline-start & inline-end are not widely supported
             float: right;
         }
@@ -892,7 +892,7 @@
             color: $gray !important;
         }
 
-        [dir=rtl] & {
+        &:dir(rtl) {
             // no direct logical property for background-position
             background-position: calc(100% - 2px) center;
         }
diff --git a/cms/static/cms/sass/libs/_rtl_patch.scss b/cms/static/cms/sass/libs/_rtl_patch.scss
new file mode 100644
index 00000000000..e67e5395e27
--- /dev/null
+++ b/cms/static/cms/sass/libs/_rtl_patch.scss
@@ -0,0 +1,87 @@
+/* This patch file contains all :dir(rtl) selectors for browsers that do not support it yet */
+/* Once, sufficiently many users have browsers that support `:dir()`, this file can be removed */
+
+/* component: toolbar */
+
+.cms-toolbar {
+    .cms-toolbar-item {
+        float: right;
+    }
+}
+
+.cms-toolbar-item-navigation {
+    li, li a {
+        float: right;
+    }
+    .cms-toolbar-item-navigation-children > a span .cms-icon {
+        transform: rotate(0deg);
+    }
+}
+
+.cms-toolbar-more .cms-toolbar-item-cms-mode-switcher a {
+    float: right !important;
+}
+
+.cms-messages .cms-messages-close {
+    float: left;
+}
+
+.cms-toolbar-item-buttons a {
+    float: right;
+}
+
+
+/* component: modal */
+
+.cms-modal-item-buttons {
+    float: left;
+}
+
+.cms-modal-item-buttons-left {
+    float: right;
+}
+
+.cms-modal-resize {
+    cursor: ne-resize;
+    span {
+        transform: scale(-1,1) translate(2px, 0);  // flip drag triangle
+    }
+}
+
+
+/* component: structureboard */
+
+.cms-structure {
+    .cms-dragitem-collapsable:before {
+        transform: rotate(0deg);
+    }
+    .cms-dragitem-expanded:before {
+        transform: rotate(-90deg);
+    }
+}
+
+
+/* component: tree */
+
+.cms-pagetree-section {
+    h2 {
+        float: right;
+    }
+
+}
+
+.cms-tree-col, .cms-tree-reload, .cms-tree-filters {
+    float: left;
+}
+
+.cms-tree-search {
+    float: right;
+}
+
+.jstree-django-cms .jstree-ocl {
+    float: right;
+}
+
+.jstree-anchor {
+    background-position: calc(100% - 2px) center;
+}
diff --git a/cms/static/cms/sass/settings/_cms.scss b/cms/static/cms/sass/settings/_cms.scss
index 651b9d6d0f8..91a957fbd82 100644
--- a/cms/static/cms/sass/settings/_cms.scss
+++ b/cms/static/cms/sass/settings/_cms.scss
@@ -137,8 +137,8 @@ $z-layers: (
     "messages":         999999,
     "toolbar": (
         "base":         9999999,
-        "left":         10,
-        "right":        10,
+        "inset-inline-start": 10,
+        "inset-inline-end": 10,
     ),
     "sideframe": (
         "base":         999999,
diff --git a/cms/templates/admin/cms/page/permissions.html b/cms/templates/admin/cms/page/permissions.html
index fa90c1170c4..51db1e50739 100644
--- a/cms/templates/admin/cms/page/permissions.html
+++ b/cms/templates/admin/cms/page/permissions.html
@@ -38,6 +38,7 @@
                     <td class="can_change">{{ permission.can_change|boolean_icon }}</td>
                     <td class="can_add">{{ permission.can_add|boolean_icon }}</td>
                     <td class="can_delete">{{ permission.can_delete|boolean_icon }}</td>
+                    <td class="can_publish">{{ permission.can_publish|boolean_icon }}</td>
                     <td class="can_change_permissions">{{ permission.can_change_permissions|boolean_icon }}</td>
                     <td class="can_move_page">{{ permission.can_move_page|boolean_icon }}</td>
                     <td class="can_view">{{ permission.can_view|boolean_icon }}</td>
diff --git a/cms/templates/cms/toolbar/toolbar.html b/cms/templates/cms/toolbar/toolbar.html
index dac191103ca..1383f195e08 100644
--- a/cms/templates/cms/toolbar/toolbar.html
+++ b/cms/templates/cms/toolbar/toolbar.html
@@ -4,6 +4,8 @@
      class="cms cms-reset
      {% if not user.is_authenticated %} cms-toolbar-auth{% endif %}
      {% if debug %} cms-toolbar-debug{% endif %}"
+     lang="{{ request.toolbar.toolbar_language }}"
+     dir="{{ request.toolbar.toolbar_language_bidi|yesno:'rtl,ltr,auto' }}"
      data-touch-action="none">
 
     {% block toolbar_top %}
diff --git a/cms/templatetags/cms_tags.py b/cms/templatetags/cms_tags.py
index 792bbed898e..3b2681bcc9c 100644
--- a/cms/templatetags/cms_tags.py
+++ b/cms/templatetags/cms_tags.py
@@ -20,7 +20,7 @@
 from django.template.loader import render_to_string
 from django.urls import reverse
 from django.utils.encoding import smart_str
-from django.utils.html import escape
+from django.utils.html import escape, strip_tags
 from django.utils.http import urlencode
 from django.utils.translation import (
     get_language,
@@ -407,7 +407,9 @@ def get_value(self, context, name, page_lookup):
         if page and name in self.valid_attributes:
             func = getattr(page, "get_%s" % name)
             ret_val = func(language=lang, fallback=True)
-            if not isinstance(ret_val, datetime):
+            if name == 'page_title':
+                 ret_val = strip_tags(ret_val)
+            elif not isinstance(ret_val, datetime):
                 ret_val = escape(ret_val)
             return ret_val
         return ''
diff --git a/cms/toolbar/toolbar.py b/cms/toolbar/toolbar.py
index 1daff29f411..8bbae40a91e 100644
--- a/cms/toolbar/toolbar.py
+++ b/cms/toolbar/toolbar.py
@@ -168,6 +168,7 @@ def __init__(self, request, request_path=None, _async=False):
         self.is_staff = None
         self.clipboard = None
         self.toolbar_language = None
+        self.toolbar_language_bidi = None
         self.show_toolbar = True
         self.init_toolbar(request, request_path=request_path)
         # Internal attribute to track whether we can cache
@@ -252,6 +253,7 @@ def init_toolbar(self, request, request_path=None):
                 user_settings.language = self.request_language
                 user_settings.save()
             self.clipboard = user_settings.clipboard
+        self.toolbar_language_bidi = self.toolbar_language in settings.LANGUAGES_BIDI
 
         if hasattr(self, 'toolbars'):
             for key, toolbar in self.toolbars.items():
@@ -576,3 +578,6 @@ class EmptyToolbar(BaseToolbar):
     def __init__(self, request):
         self.request = request
         super().__init__()
+
+    def get_object(self):
+        return None
diff --git a/cms/utils/apphook_reload.py b/cms/utils/apphook_reload.py
index 3ee04fece81..83c34725986 100644
--- a/cms/utils/apphook_reload.py
+++ b/cms/utils/apphook_reload.py
@@ -89,8 +89,9 @@ def reload_urlconf(urlconf=None, new_revision=None):
 
 
 def log_reloading_apphook(global_revision, local_revision):
-    debug_msg = f"   New revision!!!! RELOAD!\n      {global_revision} ({type(global_revision)})\n   -> {local_revision} ({type(local_revision)})"
-    logger.debug(debug_msg)
+    logger.debug(f"   New revision!!!! RELOAD!\n"
+                 f"      {global_revision} ({type(global_revision)})\n"
+                 f"   -> {local_revision} ({type(local_revision)})")
 
 
 def debug_check_url(url_name):
diff --git a/cms/utils/page_permissions.py b/cms/utils/page_permissions.py
index 259920337c7..cf5796bab36 100644
--- a/cms/utils/page_permissions.py
+++ b/cms/utils/page_permissions.py
@@ -16,6 +16,7 @@
 PAGE_ADD_CODENAME = get_model_permission_codename(Page, 'add')
 PAGE_CHANGE_CODENAME = get_model_permission_codename(Page, 'change')
 PAGE_DELETE_CODENAME = get_model_permission_codename(Page, 'delete')
+PAGE_PUBLISH_CODENAME = get_model_permission_codename(Page, 'publish')
 PAGE_VIEW_CODENAME = get_model_permission_codename(Page, 'view')
 
 
@@ -28,6 +29,7 @@
     'delete_page': [PAGE_CHANGE_CODENAME, PAGE_DELETE_CODENAME],
     'delete_page_translation': [PAGE_CHANGE_CODENAME, PAGE_DELETE_CODENAME],
     'move_page': [PAGE_CHANGE_CODENAME],
+    'publish_page': [PAGE_CHANGE_CODENAME, PAGE_PUBLISH_CODENAME],
 }
 
 
@@ -196,6 +198,18 @@ def user_can_delete_page_translation(user, page, language, site=None):
     return True
 
 
+@cached_func
+@auth_permission_required('publish_page')
+def user_can_publish_page(user, page, site=None):
+    has_perm = has_generic_permission(
+        page=page,
+        user=user,
+        action='publish_page',
+        site=site,
+    )
+    return has_perm
+
+
 @cached_func
 @auth_permission_required('change_page_advanced_settings')
 def user_can_change_page_advanced_settings(user, page, site=None):
@@ -416,6 +430,21 @@ def get_move_page_id_list(user, site, check_global=True, use_cache=True):
     return page_ids
 
 
+def get_publish_id_list(user, site, check_global=True, use_cache=True):
+     """
+     Give a list of page where the user has publish rights or the string "All" if
+     the user has all rights.
+     """
+     page_ids = _get_page_ids_for_action(
+         user=user,
+         site=site,
+         action='publish_page',
+         check_global=check_global,
+         use_cache=use_cache,
+     )
+     return page_ids
+
+
 def get_view_id_list(user, site, check_global=True, use_cache=True):
     """Give a list of pages which user can view.
     """
@@ -441,6 +470,7 @@ def has_generic_permission(page, user, action, site=None, check_global=True):
         'change_page_permissions': get_change_permissions_id_list,
         'delete_page': get_delete_id_list,
         'delete_page_translation': get_delete_id_list,
+        'publish_page': get_publish_id_list,
         'move_page': get_move_page_id_list,
         'view_page': get_view_id_list,
     }
diff --git a/cms/utils/plugins.py b/cms/utils/plugins.py
index df3bbe36e46..410aa7c10f4 100644
--- a/cms/utils/plugins.py
+++ b/cms/utils/plugins.py
@@ -3,6 +3,8 @@
 from collections import OrderedDict, defaultdict, deque
 from copy import deepcopy
 from functools import lru_cache
+from itertools import starmap
+from operator import itemgetter
 
 from django.utils.encoding import force_str
 from django.utils.translation import gettext as _
@@ -12,6 +14,7 @@
 from cms.plugin_base import CMSPluginBase
 from cms.plugin_pool import plugin_pool
 from cms.utils import get_language_from_request
+from cms.utils.permissions import has_plugin_permission
 from cms.utils.placeholder import get_placeholder_conf
 
 logger = logging.getLogger(__name__)
@@ -91,6 +94,9 @@ def assign_plugins(request, placeholders, template=None, lang=None):
         .objects
         .filter(placeholder__in=placeholders, language=lang)
     )
+    if not plugins:
+        # Create default plugins if enabled
+        plugins = create_default_plugins(request, placeholders, template, lang)
     plugins = downcast_plugins(plugins, placeholders, request=request)
 
     # split the plugins up by placeholder
@@ -112,6 +118,47 @@ def assign_plugins(request, placeholders, template=None, lang=None):
         placeholder._plugins_cache = layered_plugins
 
 
+def create_default_plugins(request, placeholders, template, lang):
+    """
+    Create all default plugins for the given ``placeholders`` if they have
+    a "default_plugins" configuration value in settings.
+    return all plugins, children, grandchildren (etc.) created
+    """
+    from cms.api import add_plugin
+
+    def _create_default_plugins(placeholder, confs, parent=None):
+        """
+        Auxiliary function that builds all of a placeholder's default plugins
+        at the current level and drives the recursion down the tree.
+        Returns the plugins at the current level along with all descendants.
+        """
+        plugins, descendants = [], []
+        addable_confs = (conf for conf in confs
+                         if has_plugin_permission(request.user,
+                                                  conf['plugin_type'], 'add'))
+        for conf in addable_confs:
+            plugin = add_plugin(placeholder, conf['plugin_type'], lang,
+                                target=parent, **conf['values'])
+            if 'children' in conf:
+                args = placeholder, conf['children'], plugin
+                descendants += _create_default_plugins(*args)
+            plugin.notify_on_autoadd(request, conf)
+            plugins.append(plugin)
+        if parent:
+            parent.notify_on_autoadd_children(request, conf, plugins)
+        return plugins + descendants
+
+    unfiltered_confs = ((ph, get_placeholder_conf('default_plugins',
+                                                  ph.slot, template))
+                        for ph in placeholders)
+    # Empty confs must be filtered before filtering on add permission
+    mutable_confs = ((ph, default_plugin_confs)
+                     for ph, default_plugin_confs
+                     in filter(itemgetter(1), unfiltered_confs)
+                     if ph.has_change_permission(request.user))
+    return sum(starmap(_create_default_plugins, mutable_confs), [])
+
+
 def get_plugins_as_layered_tree(plugins):
     """
     Given an iterable of plugins ordered by position,
diff --git a/docs/contributing/code.rst b/docs/contributing/code.rst
index 326d2c855ca..2cfd57e7d66 100644
--- a/docs/contributing/code.rst
+++ b/docs/contributing/code.rst
@@ -17,7 +17,7 @@ Here's what the contribution process looks like in brief:
 #. Fork our `GitHub`_ repository, https://github.com/django-cms/django-cms
 #. Work locally and push your changes to your repository.
 #. When you feel your code is good enough for inclusion, send us a pull request.
-#. After that, please join our Discord server (`#contributors <https://www.django-cms.org/discord>`_). This group of friendly community members is dedicated to reviewing pull requests. Report your PR and find a “pr review buddy” who is going to review your pull request.
+#. After that, please join our Discord server (`#contributors <https://discord-contributors-channel.django-cms.org>`_). This group of friendly community members is dedicated to reviewing pull requests. Report your PR and find a “pr review buddy” who is going to review your pull request.
 #. Get acknowledged by the django CMS community for your contribution
 
 See the :ref:`contributing_patch` how-to document for a walk-through of this process.
@@ -28,7 +28,7 @@ Basic requirements and standards
 ********************************
 
 If you're interested in developing a new feature for the CMS, it is recommended
-that you first discuss it on `Discord <https://www.django-cms.org/discord>`_ so as
+that you first discuss it on `Discord <https://discord-main-channel.django-cms.org>`_ so as
 not to do any work that will not get merged in anyway.
 
 - Code will be reviewed and tested by at least one core developer, preferably
@@ -107,7 +107,7 @@ coverage will only be accepted with a very good reason; bug-fixing patches
 **must** demonstrate the bug with a test to avoid regressions and to check
 that the fix works.
 
-We have a `Discord Server <https://www.django-cms.org/discord>`_ and of course the code reviews mechanism on GitHub - do use them.
+We have a `Discord Server <https://discord-contributors-channel.django-cms.org>`_ and of course the code reviews mechanism on GitHub - do use them.
 
 
 .. _contributing_frontend:
diff --git a/docs/contributing/development-policies.rst b/docs/contributing/development-policies.rst
index 6c56e0e88d1..ffc9e2bc9f3 100644
--- a/docs/contributing/development-policies.rst
+++ b/docs/contributing/development-policies.rst
@@ -62,7 +62,7 @@ To create a proposal...
 1. please use this `DEP template <https://github.com/django/deps/blob/main/template.rst>`_
 2. create a discussion in the main `Github repository <https://github.com/django-cms/django-cms/discussions>`_
 3. discuss, discuss, discuss
-4. join the Tech Committee (`#technical-committee <https://www.django-cms.org/discord/tech-comm>`_) and make the team aware of your proposal after the proposal has been reviewed by the Technical Committee, it is put to a vote at one of the weekly meetings of the technical committee
+4. join the Tech Committee (`#technical-committee <https://discord-tech-committee-channel.django-cms.org>`_) and make the team aware of your proposal after the proposal has been reviewed by the Technical Committee, it is put to a vote at one of the weekly meetings of the technical committee
 
 
 ****************
@@ -82,7 +82,7 @@ Example of retrospective approach.
 * ...
 * Unscheduled Releases -> e.g. bug fix -> 3.x.x
 
-Release management is managed on `Discord <https://www.django-cms.org/discord/tech-comm>`_ in the #technical-committee channel.
+Release management is managed on `Discord <https://discord-tech-committee-channel.django-cms.org>`_ in the #technical-committee channel.
 For questions regarding the release process please join the channel and reach out. We're happy to help.
 
 Long-Term Support Release
diff --git a/docs/contributing/guideline-for-django-cms-projects.rst b/docs/contributing/guideline-for-django-cms-projects.rst
index 2eb135b1117..877a3c4099a 100644
--- a/docs/contributing/guideline-for-django-cms-projects.rst
+++ b/docs/contributing/guideline-for-django-cms-projects.rst
@@ -32,7 +32,7 @@ New projects or project transfers under the django CMS patronage must first be a
 by the `Tech Committee
 <https://github.com/django-cms/django-cms-mgmt/blob/master/tech-committee/about.md>`_.
 For that you should join the #tech-committee channel on `Discord
-<https://www.django-cms.org/discord>`_ and simply submit your proposal. Then, the TC
+<https://discord-tech-committee-channel.django-cms.org>`_ and simply submit your proposal. Then, the TC
 decides whether or not your project is in line with the product roadmap and overall
 vision for django CMS.
 
diff --git a/docs/how_to/04-templates.rst b/docs/how_to/04-templates.rst
index 8f5575354b7..c47600e7f5a 100644
--- a/docs/how_to/04-templates.rst
+++ b/docs/how_to/04-templates.rst
@@ -2,27 +2,189 @@
 How to work with templates
 ##########################
 
-Application can reuse cms templates by mixing cms template tags and normal django
-templating language.
+django CMS uses Django's template system to manage the layout of the CMS pages.
 
+Django's Template System
+========================
 
-static_alias
-------------
+Django’s template language is designed to strike a balance between power and
+ease. It’s designed to feel comfortable to those used to working with HTML.
+If you have any exposure to other text-based template languages, such as Smarty
+or Jinja2, you should feel right at home with Django’s templates.
+
+The template system, out of the box, should be familiar to those who have
+worked with desktop publishing or web design. Tags are surrounded by {% and %}
+and denote the actions like loops and conditionals. Variables are surrounded by
+{{ and }} and get replaced with values when the template is rendered.
+
+Learn more about Django's template system in the
+`Django documentation <https://docs.djangoproject.com/en/dev/topics/templates/>`_.
+
+
+Django CMS and Django's Template System
+=======================================
+
+Django templates
+----------------
+
+You are totally free on how to name your templates, but we encourage you
+to use the general Django conventions, including letting all templates inherit
+from a base template by using the ``extends`` template tag or putting templates
+in a folder named after the application providing it.
+
+.. note::
+
+   Some django CMS apps, like django CMS Alias, assume the base template is
+   called ``base.html``. If you happen to prefer a different name for the base
+   template and need to use such apps, you can create a ``base.html`` template
+   that just consists of the ``{% extends "your_base_template.html" %}`` tag.
+
+A fresh installation of django CMS using the quickstarter project or the
+``djangocms`` command comes with a default template that for reasons of
+convenience is provided by
+`django CMS frontend <https://github.com/django-cms/djangocms-frontend>`_
+and based on Bootstrap. We encourage you to create your own templates
+as you would do for any Django project.
+
+Generally speaking, django CMS is wholly frontend-agnostic. It doesn’t care
+what your site’s frontend is built on or uses: You are free to decide which
+CSS framework or JS library to use (if any).
+
+When editing, the frontend editor will replace part of the current document's
+DOM. This might require some JS widgets to be reinitialized.
+See :ref:`frontend-integration` for more information.
+
+
+CMS templates
+-------------
+
+You need to configure which templates django CMS should use. You can do this by
+either setting the :setting:`CMS_TEMPLATES` or the :setting:`CMS_TEMPLATES_DIR`
+settings.
+
+You can select the template by page (and language) in the page menu of django
+CMS' toolbar. By default, a page inherits its template from its parent page.
+A root page uses the first template in :setting:`CMS_TEMPLATES` if no other
+template is explicitly set.
+
+To work seamlessly with django CMS, **your templates should include the**
+``{% cms_toolbar %}`` **tag right as the first item in your template's**
+``<body>``. This tag will render the toolbar for logged-in users.
+
+.. note::
+
+    The toolbar can also be displayed in views independent of django CMS.
+    To provide a consistent user experience, many projects include the toolbar
+    in their base template and share it with the whole Django project.
+
+
+Also, you need to tell django CMS where to place the content of your pages. This
+is done using **placeholders**. A placeholder is a named area in your template
+where you can add content plugins. You can add as many placeholders as you want
+to your templates.
+
+To add a placeholder to your template, use the
+``{% placeholder "name" %}`` template tag. The name is the name of the template
+slot. It will be shown in the structure board of the frontend editor. Typical
+names are "main", "sidebar", "footer", etc.
+
+Finally, you need to add ``{% render_block "css" %}`` in the ``<head>`` section
+of your CMS templates and ``{% render_block "js" %}`` right before the closing
+``</body>`` tag of your CMS templates. This will render the CSS and JavaScript
+at the appropriate places in your CMS templates.
+
+django CMS uses `django-sekizai <https://github.com/django-cms/django-sekizai>`_
+to manage CSS and JavaScript. To use the sekizai tags, you need to load the
+``sekizai_tags`` template tags in your template: ``{% load sekizai_tags %}``.
+
+Example
+-------
+
+Here is an example of a simple template that uses placeholders:
+
+.. code-block:: html+django
+
+    {% extends "base.html" %}
+    {% load cms_tags djangocms_alias_tags %}
+    {% block title %}{% page_attribute "page_title" %}{% endblock title %}
+    {% block content %}
+        <header>
+            {% placeholder "header" %}
+        </header>
+        <main>
+            {% placeholder "main" %}
+        </main>
+        <footer>
+            {% static_alias "footer" %}
+        </footer>
+    {% endblock content %}
+
+In this example, the template extends the base template, sets the title of the
+page, and defines three placeholders: "header", "main", and "footer". The
+placeholders are then rendered in the template.
+
+The underlying base template could look like this:
+
+.. code-block:: html+django
+
+    {% load cms_tags sekizai_tags %}
+    <!DOCTYPE html>
+    <html>
+        <head>
+            <title>{% block title %}{% endblock title %}</title>
+            {% render_block "css" %}
+        </head>
+        <body>
+            {% cms_toolbar %}
+            {% block content %}{% endblock content %}
+            {% render_block "js" %}
+        </body>
+    </html>
+
+
+
+Static aliases
+==============
 
 .. versionadded:: 4.0
 
 .. note::
 
-  Using ``static_alias`` requires the installation of `djangocms-alias <https://github.com/django-cms/djangocms-alias>`_ to work.
+  Using ``static_alias`` requires the installation of
+  `djangocms-alias <https://github.com/django-cms/djangocms-alias>`_ to work.
+
+The package `djangocms-alias <https://github.com/django-cms/djangocms-alias>`_
+provides an admin page in Django admin where special types of placeholders
+called "static aliases" can be managed and its contents edited.
+
+Frequent Use Cases:
+
+1. Editors wish to manage repeated content centrally (DRY - don't repeat
+   yourself)
 
+2. Developers wish to add CMS functionality to their custom application's
+   templates
+
+**Repeated content**: Often, content areas such as a footer, a header or a
+sidebar have identical content across all pages of a website.
+`djangocms-alias <https://github.com/django-cms/djangocms-alias>`_ provides
+a Django admin page for editors to manage such general site-wide content in
+one place.
+
+**Custom applications**: Templates in custom applications usually follow some
+well-defined business logic which is normally hard-coded in the template.
+However the same templates might include areas of "static" content, i.e.
+content that editors wish to manage. As the django CMS :ttag:`placeholder` tag
+only work in templates attached to the django CMS
+:class:`~cms.models.pagemodel.Page` model,
+`djangocms-alias <https://github.com/django-cms/djangocms-alias>`_
+closes the gap by providing editors central access to such custom content areas.
 
-Plain :ttag:`placeholder` cannot be used in templates used by external applications,
-use :ttag:`static_alias` instead.
 
 .. _page_template:
 
 CMS_TEMPLATE
-------------
+============
 
 ``CMS_TEMPLATE`` is a context variable available in the context; it contains
 the template path for CMS pages and application using apphooks, and the default
@@ -36,13 +198,17 @@ Example: cms template
 
 .. code-block:: html+django
 
-    {% load cms_tags %}
+    {% load cms_tags sekizai_tags %}
     <html>
+        <head>
+            {% render_block "css" %}
+        </head>
         <body>
         {% cms_toolbar %}
         {% block main %}
         {% placeholder "main" %}
         {% endblock main %}
+        {% render_block "js" %}
         </body>
     </html>
 
@@ -62,10 +228,3 @@ Example: application template
 
 ``CMS_TEMPLATE`` memorises the path of the cms template so the application
 template can dynamically import it.
-
-
-render_model
-------------
-
-:ttag:`render_model` allows to edit the django models from the frontend by
-reusing the django CMS frontend editor.
diff --git a/docs/how_to/11-apphooks.rst b/docs/how_to/11-apphooks.rst
index 6aebd862e5a..2239304846b 100644
--- a/docs/how_to/11-apphooks.rst
+++ b/docs/how_to/11-apphooks.rst
@@ -34,7 +34,7 @@ example:
         name = "My Apphook"
 
         def get_urls(self, page=None, language=None, **kwargs):
-            return ["myapp.urls"] # replace this with the path to your application's URLs module
+            return ["myapp.urls"]  # replace this with the path to your application's URLs module
 
 Apphooks for namespaced applications
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
@@ -136,8 +136,8 @@ So, given an application with the ``urls.py`` for the views ``index_view`` and
 .. code-block::
 
     urlpatterns = [
-        path('archive/', archive_view),
-        path('', index_view),
+        path("archive/", archive_view),
+        path("", index_view),
     ]
 
 attached to a page whose URL path is ``/hello/world/``, the views will be exposed as
@@ -149,13 +149,42 @@ follows:
 Sub-pages of an apphooked page
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
-.. important::
+Usually you should not add child pages to a page with an apphook. This is because the
+apphook "swallows" all URLs below that page, handing them over to the attached application.
 
-    Don't add child pages to a page with an apphook.
+In the rare occasion that you nevertheless want to add child pages below an apphooked
+page, then you must add a special URL pattern to route requests back into the CMS.
+
+For example, if you have an apphooked page at ``/hello/`` and you want to add a CMS page,
+and optionally its children below that page using the slug ``world``, then rewrite the
+URL patterns from above as:
+
+.. code-block:: python
+
+    from django.urls import path, re_path
+    from cms.views import details
+
+    def reroute_cms_page(request, path, page=None):
+        language = get_language_from_request(request, check_path=True)
+        return details(request, f'{page.get_path(language)}/{path}')
+
+    class MyApphook(CMSApp):
+        # ...
+        def get_urls(self, page=None, language=None, **kwargs):
+            return [
+                path("archive/", archive_view),
+                re_path(r"^(?P<path>world/.*)$", reroute_cms_page, {"page": page}),
+                path("", index_view),
+            ]
+
+Here we created a short function-based view named ``reroute_cms_page``. It handles
+the requests which otherwise would be swallowed by the apphook.
+
+A requests starting with the URL ``/hello/`` then is handled by ``index_view``,
+``/hello/archive/`` is handled by ``archive_view``, and ``/hello/world/``,
+``/hello/world/foo``, etc. are handled by our special view ``reroute_cms_page``,
+routing the request back to the ``detail()`` view of Django-CMS.
 
-    The apphook "swallows" all URLs below that of the page, handing them over to the
-    attached application. If you have any child pages of the apphooked page, django CMS
-    will not be able to serve them reliably.
 
 Managing apphooks
 -----------------
diff --git a/docs/index.rst b/docs/index.rst
index 21935bcdedf..b9ec186ede4 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -126,7 +126,7 @@ organisation that exists to support the development of django CMS and its commun
 Discord
 ~~~~~
 
-Join `our friendly Discord server <https://www.django-cms.org/discord>`_ for **support** and
+Join `our friendly Discord server <https://discord-support-channel.django-cms.org>`_ for **support** and
 to **share ideas** and **discuss technical questions** with other members of the
 community.
 
diff --git a/docs/introduction/01-install.rst b/docs/introduction/01-install.rst
index bff1917f444..27ab9a65dda 100644
--- a/docs/introduction/01-install.rst
+++ b/docs/introduction/01-install.rst
@@ -223,14 +223,14 @@ template. It performs the following five steps in one simple go:
    - `djangocms_admin_style <https://github.com/django-cms/djangocms-admin-style>`_ for
      a consistent user experience with django CMS and Django admin.
 
-3. It changes into the project directory and runs the ``migrate`` command to create the 
+3. It changes into the project directory and runs the ``migrate`` command to create the
    database:
 
    .. code-block::
 
        python -m manage migrate
 
-4. It prompts for crating a superuser by invoking:
+4. It prompts for creating a superuser by invoking:
 
    .. code-block::
 
@@ -273,7 +273,7 @@ We suggest to use pip-compile to freeze your requirements as, for example, discu
 Spin up your Django development server (Step 3)
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
-Now you are ready to spin up Django's development server by first changing directory into 
+Now you are ready to spin up Django's development server by first changing directory into
 the project folder and then spinning up the development server:
 
 .. code-block::
@@ -295,7 +295,7 @@ CMS to any Django project. It will require some settings to be modified, however
 Minimally-required applications and settings
 ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
-To run djanog CMS you will only need to modify the ``settings.py`` and ``urls.py``
+To run django CMS you will only need to modify the ``settings.py`` and ``urls.py``
 files.
 
 Open the new project's ``settings.py`` file in your text editor.
@@ -365,11 +365,13 @@ configured by default in a new Django project's :setting:`django:DATABASES`.
 .. note::
 
     For deployment, you'll need to use a :doc:`production-ready database with Django
-    <django:ref/databases>`. We recommend using PostgreSQL_ or MySQL_.
+    <django:ref/databases>`. We recommend using PostgreSQL_, MySQL_ or MariaDB_.
 
     Installing and maintaining database systems is far beyond the scope of this
     documentation, but is very well documented on the systems' respective websites.
 
+    .. _mariadb: http://www.mariadb.com
+
     .. _mysql: http://www.mysql.com
 
     .. _postgresql: http://www.postgresql.org/
@@ -380,7 +382,7 @@ configured by default in a new Django project's :setting:`django:DATABASES`.
     .. code-block::
 
         pip install psycopg2     # for Postgres
-        pip install mysqlclient  # for MySQL
+        pip install mysqlclient  # for MySQL or MariaDB
 
     Refer to :setting:`Django's DATABASES setting documentation <django:DATABASES>` for the
     appropriate configuration for your chosen database backend.
diff --git a/docs/introduction/02-templates_placeholders.rst b/docs/introduction/02-templates_placeholders.rst
index 53ddb1d8417..d5cfad78b6e 100644
--- a/docs/introduction/02-templates_placeholders.rst
+++ b/docs/introduction/02-templates_placeholders.rst
@@ -89,16 +89,18 @@ Do not forget to add ``djangocms_alias`` to your ``INSTALLED_APPS`` in ``setting
 
 Static aliases are an easy way to display the same content on multiple locations on your website. Static placeholders act almost like normal placeholders, except for the fact that once a static placeholder is created and you added content to it, it will be saved globally. Even when you remove the static placeholders from a template, you can reuse them later.
 
-So let's add a footer to all our pages. Since we want our footer on every single page, we should add it to our **base template** (``mysite/templates/base.html``). Place it near the end of the HTML ``<body>`` element:
+So let's add a footer to all our pages. Since we want our footer on every single page, we should add it to our **base template** (``mysite/templates/base.html``). Place it near the end of the HTML ``<body>`` element, and inside a content block:
 
 .. code-block:: html+django
    :emphasize-lines: 1,3-5
 
         {% load djangocms_alias_tags %}
 
-        <footer>
-          {% static_alias 'footer' %}
-        </footer>
+        {% block content %}
+            <footer>
+              {% static_alias 'footer' %}
+            </footer>
+        {% endblock content %}
 
 
         {% render_block "js" %}
diff --git a/docs/introduction/index.rst b/docs/introduction/index.rst
index fb4839f530b..e0c8cbdf335 100644
--- a/docs/introduction/index.rst
+++ b/docs/introduction/index.rst
@@ -37,4 +37,4 @@ django CMS by hand, rather than using the django CMS quistart project, see
 :doc:`/introduction/01-install` and then follow the rest of the tutorials.
 
 Either way, you'll be able to find support and help from the numerous friendly members
-of the django CMS community on `our Discord server <https://www.django-cms.org/discord>`_.
+of the django CMS community on `our Discord server <https://discord-support-channel.django-cms.org>`_.
diff --git a/docs/requirements.txt b/docs/requirements.txt
index d581937d92e..92008873a4d 100644
--- a/docs/requirements.txt
+++ b/docs/requirements.txt
@@ -67,7 +67,7 @@ idna==3.7
     # via requests
 imagesize==1.4.1
     # via sphinx
-jinja2==3.1.3
+jinja2==3.1.4
     # via sphinx
 libcst==1.1.0
     # via docstrfmt
@@ -106,8 +106,6 @@ pygments==2.13.0
     #   -r requirements.in
     #   furo
     #   sphinx
-pyparsing==3.0.9
-    # via packaging
 pytz==2022.6
     # via
     #   babel
@@ -115,7 +113,7 @@ pytz==2022.6
     #   django
 pyyaml==6.0.1
     # via libcst
-requests==2.28.1
+requests==2.32.0
     # via sphinx
 six==1.16.0
     # via livereload
@@ -161,7 +159,7 @@ tabulate==0.9.0
     # via docstrfmt
 toml==0.10.2
     # via docstrfmt
-tornado==6.2
+tornado==6.4.1
     # via livereload
 typing-extensions==4.9.0
     # via
@@ -169,7 +167,7 @@ typing-extensions==4.9.0
     #   typing-inspect
 typing-inspect==0.9.0
     # via libcst
-urllib3==1.26.18
+urllib3==1.26.19
     # via requests
 wheel==0.38.3
     # via pip-tools
diff --git a/pyproject.toml b/pyproject.toml
index 513e8d25b98..346a1f17aba 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -37,6 +37,7 @@ extend-ignore = [
   "B028",
   "B018",
   "B020",
+  "B024",  # abstract base class, but has no abstract method
   "B026",
   "B904",
   "C400",
diff --git a/setup.cfg b/setup.cfg
index f35c8073027..ae5fd17fefd 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -37,7 +37,7 @@ classifiers =
 project_urls =
     Documentation = https://docs.django-cms.org/
     Release notes = https://docs.django-cms.org/en/latest/upgrade/index.html
-    Community = http://www.django-cms.org/slack
+    Community = https://discord-support-channel.django-cms.org
     Source = https://github.com/django-cms/django-cms
 
 [options]
@@ -67,6 +67,5 @@ bcrypt = bcrypt
 universal=1
 
 [codespell]
-ignore-words-list = cant,statics,groupe,manuel,uptodate
+ignore-words-list = cant,statics,groupe,manuel,uptodate,assertIn
 skip = package-lock.json,*.js,*.js.html,*.po,./node_modules/*,./.idea/*,./docs/env/*,./docs/build/*,./.env/*,./.venv/*,./build/*,./django_cms.egg-info/*,./.git,./cms/test_utils/project/sampleapp/models.py,./venv/*,./docs/spelling_wordlist
-


Test patch:
diff --git a/cms/test_utils/testcases.py b/cms/test_utils/testcases.py
index ddcef4407ef..41d02c1383c 100644
--- a/cms/test_utils/testcases.py
+++ b/cms/test_utils/testcases.py
@@ -123,6 +123,7 @@ def add_global_permission(self, user, **kwargs):
             'can_add': False,
             'can_change': False,
             'can_delete': False,
+            'can_publish': False,
             'can_change_advanced_settings': False,
             'can_change_permissions': False,
             'can_move_page': False,
@@ -140,6 +141,7 @@ def add_page_permission(self, user, page, **kwargs):
             'can_add': False,
             'can_change': False,
             'can_delete': False,
+            'can_publish': False,
             'can_change_advanced_settings': False,
             'can_change_permissions': False,
             'can_move_page': False,
@@ -209,6 +211,7 @@ def _add_default_permissions(self, user):
         user.user_permissions.add(Permission.objects.get(codename='delete_link'))
         user.user_permissions.add(Permission.objects.get(codename='change_link'))
         # Page permissions
+        user.user_permissions.add(Permission.objects.get(codename='publish_page'))
         user.user_permissions.add(Permission.objects.get(codename='add_page'))
         user.user_permissions.add(Permission.objects.get(codename='change_page'))
         user.user_permissions.add(Permission.objects.get(codename='delete_page'))
@@ -569,7 +572,7 @@ def get_page_change_template_uri(self, language, page):
 
     def get_add_plugin_uri(self, placeholder, plugin_type, language='en', parent=None, position=None):
         if placeholder.page:
-            path = placeholder.page.get_absolute_url(language)
+            path = placeholder.page.get_absolute_url(language) or f'/{language}/'
         else:
             path = f'/{language}/'
 
@@ -593,7 +596,7 @@ def get_change_plugin_uri(self, plugin, language=None):
         language = language or 'en'
 
         if plugin.page:
-            path = plugin.page.get_absolute_url(language)
+            path = plugin.page.get_absolute_url(language) or f'/{language}/'
         else:
             path = f'/{language}/'
 
@@ -605,7 +608,7 @@ def get_move_plugin_uri(self, plugin, language=None):
         language = language or 'en'
 
         if plugin.page:
-            path = plugin.page.get_absolute_url(language)
+            path = plugin.page.get_absolute_url(language) or f'/{language}/'
         else:
             path = f'/{language}/'
 
@@ -617,7 +620,7 @@ def get_copy_plugin_uri(self, plugin, language=None):
         language = language or 'en'
 
         if plugin.page:
-            path = plugin.page.get_absolute_url(language)
+            path = plugin.page.get_absolute_url(language) or f'/{language}/'
         else:
             path = f'/{language}/'
 
@@ -629,7 +632,7 @@ def get_copy_placeholder_uri(self, placeholder, language=None):
         language = language or 'en'
 
         if placeholder.page:
-            path = placeholder.page.get_absolute_url(language)
+            path = placeholder.page.get_absolute_url(language) or f'/{language}/'
         else:
             path = f'/{language}/'
 
@@ -641,7 +644,7 @@ def get_delete_plugin_uri(self, plugin, language=None):
         language = language or 'en'
 
         if plugin.page:
-            path = plugin.page.get_absolute_url(language)
+            path = plugin.page.get_absolute_url(language) or f'/{language}/'
         else:
             path = f'/{language}/'
 
@@ -653,7 +656,7 @@ def get_clear_placeholder_url(self, placeholder, language=None):
         language = language or 'en'
 
         if placeholder.page:
-            path = placeholder.page.get_absolute_url(language)
+            path = placeholder.page.get_absolute_url(language) or f'/{language}/'
         else:
             path = f'/{language}/'
 
diff --git a/cms/tests/test_admin.py b/cms/tests/test_admin.py
index d92c8fad4f8..b2799ad182c 100644
--- a/cms/tests/test_admin.py
+++ b/cms/tests/test_admin.py
@@ -68,6 +68,7 @@ def _get_staff_user(self, use_global_permissions=True):
                 user=normal_guy,
                 can_change=True,
                 can_delete=True,
+                can_publish=True,
                 can_change_advanced_settings=False,
                 can_change_permissions=False,
                 can_move_page=True,
@@ -516,6 +517,7 @@ def _give_cms_permissions(self, user, save=True):
             user=user,
             can_change=True,
             can_delete=True,
+            can_publish=True,
             can_change_advanced_settings=False,
             can_change_permissions=False,
             can_move_page=True,
diff --git a/cms/tests/test_permissions.py b/cms/tests/test_permissions.py
index c051aaa81fb..fe32cf6deeb 100644
--- a/cms/tests/test_permissions.py
+++ b/cms/tests/test_permissions.py
@@ -11,7 +11,7 @@
 from cms.test_utils.testcases import CMSTestCase
 from cms.utils.page_permissions import (
     get_change_id_list,
-    user_can_change_page,
+    user_can_publish_page,
 )
 
 
@@ -76,14 +76,15 @@ def test_cached_permission_precedence(self):
         )
         page_permission = GlobalPagePermission.objects.create(
             can_change=True,
+            can_publish=True,
             user=self.user_normal,
         )
         page_permission.sites.add(Site.objects.get_current())
-        set_permission_cache(self.user_normal, "change_page", [])
+        set_permission_cache(self.user_normal, "publish_page", [])
 
-        can_change = user_can_change_page(
+        can_publish = user_can_publish_page(
             self.user_normal,
             page,
             Site.objects.get_current(),
         )
-        self.assertTrue(can_change)
+        self.assertTrue(can_publish)
diff --git a/cms/tests/test_permmod.py b/cms/tests/test_permmod.py
index a0337a18812..f1ad9525a04 100644
--- a/cms/tests/test_permmod.py
+++ b/cms/tests/test_permmod.py
@@ -1,3 +1,5 @@
+from unittest.mock import patch
+
 from django.contrib.admin.sites import site
 from django.contrib.auth import get_user_model
 from django.contrib.auth.models import AnonymousUser, Group, Permission
@@ -9,7 +11,7 @@
 from cms.admin.forms import save_permissions
 from cms.api import assign_user_to_page, create_page, create_page_user
 from cms.cms_menus import get_visible_nodes
-from cms.models import ACCESS_PAGE, Page, PageContent
+from cms.models import ACCESS_PAGE, CMSPlugin, Page, PageContent
 from cms.models.permissionmodels import (
     ACCESS_PAGE_AND_DESCENDANTS,
     GlobalPagePermission,
@@ -58,15 +60,18 @@ class PermissionModeratorTests(CMSTestCase):
 
     def setUp(self):
         # create super user
-        self.user_super = self._create_user("super", is_staff=True,
-                                            is_superuser=True)
-        self.user_staff = self._create_user("staff", is_staff=True,
-                                            add_default_permissions=True)
-        self.user_master = self._create_user("master", is_staff=True,
-                                             add_default_permissions=True)
-        self.user_slave = self._create_user("slave", is_staff=True,
-                                            add_default_permissions=True)
+        self.user_super = self._create_user("super", is_staff=True, is_superuser=True)
+
+        self.user_staff = self._create_user("staff", is_staff=True, add_default_permissions=True)
+        self.add_permission(self.user_staff, 'publish_page')
+
+        self.user_master = self._create_user("master", is_staff=True, add_default_permissions=True)
+        self.add_permission(self.user_master, 'publish_page')
+
+        self.user_slave = self._create_user("slave", is_staff=True, add_default_permissions=True)
+
         self.user_normal = self._create_user("normal", is_staff=False)
+        self.user_normal.user_permissions.add(Permission.objects.get(codename='publish_page'))
 
         with self.login_user_context(self.user_super):
             self.home_page = create_page("home", "nav_playground.html", "en",
@@ -105,7 +110,7 @@ def setUp(self):
             page_a = create_page("pageA", "nav_playground.html", "en",
                                  created_by=self.user_super)
             assign_user_to_page(page_a, self.user_master,
-                                can_add=True, can_change=True, can_delete=True,
+                                can_add=True, can_change=True, can_delete=True, can_publish=True,
                                 can_move_page=True)
 
     def _add_plugin(self, user, page):
@@ -139,6 +144,28 @@ def test_slave_cannot_add_page_to_root(self):
             response = self.client.get(self.get_page_add_uri('en'))
             self.assertEqual(response.status_code, 403)
 
+    @override_settings(
+        CMS_PLACEHOLDER_CONF={
+            'col_left': {
+                'default_plugins': [
+                    {
+                        'plugin_type': 'TextPlugin',
+                        'values': {
+                            'body': 'Lorem ipsum dolor sit amet, consectetur adipisicing elit. Culpa, repellendus, delectus, quo quasi ullam inventore quod quam aut voluptatum aliquam voluptatibus harum officiis officia nihil minus unde accusamus dolorem repudiandae.'
+                        },
+                    },
+                ]
+            },
+        },
+    )
+    def test_default_plugins(self):
+        with self.login_user_context(self.user_slave):
+            self.assertEqual(CMSPlugin.objects.count(), 0)
+            response = self.client.get(self.slave_page.get_absolute_url(), {'edit': 1})
+            self.assertEqual(response.status_code, 200)
+            self.assertEqual(CMSPlugin.objects.count(), 1)
+
+
     def test_super_can_add_plugin(self):
         self._add_plugin(self.user_super, page=self.slave_page)
 
@@ -214,6 +241,9 @@ def test_user_globalpermission(self):
             user_global.is_staff = False
             user_global.save()  # Prevent is_staff permission
             global_page = create_page("global", "nav_playground.html", "en")
+            # Removed call since global page user doesn't have publish permission
+            # global_page = publish_page(global_page, user_global)
+            # it's allowed for the normal user to view the page
             assign_user_to_page(global_page, user_global, global_permission=True, can_view=True)
 
         url = global_page.get_absolute_url('en')
@@ -261,6 +291,20 @@ def test_anonymous_user_public_for_none(self):
             response = self.client.get(url)
             self.assertEqual(response.status_code, 404)
 
+    def test_page_content_reflects_page_publish_permission(self):
+        """
+        Test that the page content object gets its publish permission from the
+        page object.
+        """
+        page = create_page('test', 'nav_playground.html', 'en')
+        page_content = page.get_content_obj("en")
+
+        user = self.get_standard_user()
+        assign_user_to_page(page, user, can_publish=True)
+        with patch.object(page, "has_publish_permission") as page_has_publish_permission:
+            self.assertTrue(page_content.has_publish_permission(user))
+            page_has_publish_permission.assert_called_once_with(user)
+
 
 class ViewPermissionBaseTests(CMSTestCase):
 
@@ -591,7 +635,7 @@ def test_emulate_admin_index(self):
                 with self.assertNumQueries(FuzzyInt(3, max_queries)):
                     # internally this calls PageAdmin.has_[add|change|delete|view]_permission()
                     expected_perms = {'add': True, 'change': True, 'delete': False}
-                    expected_perms.update({'view': False})  # Why
+                    expected_perms.update({'view': expected_perms['change']})
                     self.assertEqual(expected_perms, site._registry[PageContent].get_model_perms(request))
 
             # can't use the above loop for this test, as we're testing that
@@ -619,7 +663,7 @@ def test_emulate_admin_index(self):
                 request.current_page = None
                 request.session = {}
                 expected_perms = {'add': True, 'change': True, 'delete': False}
-                expected_perms.update({'view': False})
+                expected_perms.update({'view': expected_perms['change']})
                 self.assertEqual(expected_perms, site._registry[PageContent].get_model_perms(request))
 
     def test_has_page_add_permission_with_target(self):
diff --git a/cms/tests/test_placeholder.py b/cms/tests/test_placeholder.py
index dbcb68887e0..6f6bfe857d6 100644
--- a/cms/tests/test_placeholder.py
+++ b/cms/tests/test_placeholder.py
@@ -607,7 +607,14 @@ def test_get_placeholder_conf(self):
             'main': {
                 'name': 'main content',
                 'plugins': ['TextPlugin', 'LinkPlugin'],
-                'require_parent': False,
+                'default_plugins': [
+                    {
+                        'plugin_type': 'TextPlugin',
+                        'values': {
+                            'body': '<p>Some default text</p>'
+                        },
+                    },
+                ],
             },
             'layout/home.html main': {
                 'name': 'main content with FilerImagePlugin and limit',
@@ -642,8 +649,8 @@ def test_get_placeholder_conf(self):
             returned = get_placeholder_conf('excluded_plugins', 'main', 'layout/other.html')
             self.assertEqual(returned, TEST_CONF['layout/other.html main']['excluded_plugins'])
             # test grandparent inherited value
-            returned = get_placeholder_conf('require_parent', 'main', 'layout/other.html')
-            self.assertEqual(returned, TEST_CONF['main']['require_parent'])
+            returned = get_placeholder_conf('default_plugins', 'main', 'layout/other.html')
+            self.assertEqual(returned, TEST_CONF['main']['default_plugins'])
             # test generic configuration
             returned = get_placeholder_conf('plugins', 'something')
             self.assertEqual(returned, TEST_CONF[None]['plugins'])
@@ -738,6 +745,79 @@ def test_placeholder_field_dynamic_slot_update(self):
         self.assertEqual(old_placeholder_1_plugin_count, current_placeholder_1_plugin_count)
         self.assertEqual(old_placeholder_2_plugin_count, current_placeholder_2_plugin_count)
 
+    def test_plugins_prepopulate(self):
+        """ Tests prepopulate placeholder configuration """
+
+        conf = {
+            'col_left': {
+                'default_plugins' : [
+                    {
+                        'plugin_type':'TextPlugin',
+                        'values':{'body':'<p>en default body 1</p>'},
+                    },
+                    {
+                        'plugin_type':'TextPlugin',
+                        'values':{'body':'<p>en default body 2</p>'},
+                    },
+                ]
+            },
+        }
+        with self.settings(CMS_PLACEHOLDER_CONF=conf):
+            page = create_page('page_en', 'col_two.html', 'en')
+            placeholder = page.get_placeholders("en").get(slot='col_left')
+            context = SekizaiContext()
+            context['request'] = self.get_request(language="en", page=page)
+            # Our page should have "en default body 1" AND "en default body 2"
+            content = _render_placeholder(placeholder, context)
+            self.assertRegex(content, r"^<p>en default body 1</p>\s*<p>en default body 2</p>$")
+
+    def test_plugins_children_prepopulate(self):
+        """
+        Validate a default textplugin with a nested default link plugin
+        """
+
+        conf = {
+            'col_left': {
+                'default_plugins': [
+                    {
+                        'plugin_type': 'TextPlugin',
+                        'values': {
+                            'body': '<p>body %(_tag_child_1)s and %(_tag_child_2)s</p>'
+                        },
+                        'children': [
+                            {
+                                'plugin_type': 'LinkPlugin',
+                                'values': {
+                                    'name': 'django',
+                                    'external_link': 'https://www.djangoproject.com/'
+                                },
+                            },
+                            {
+                                'plugin_type': 'LinkPlugin',
+                                'values': {
+                                    'name': 'django-cms',
+                                    'external_link': 'https://www.django-cms.org'
+                                },
+                            },
+                        ]
+                    },
+                ]
+            },
+        }
+
+        with self.settings(CMS_PLACEHOLDER_CONF=conf):
+            page = create_page('page_en', 'col_two.html', 'en')
+            placeholder = page.get_placeholders("en").get(slot='col_left')
+            context = SekizaiContext()
+            context['request'] = self.get_request(language="en", page=page)
+            _render_placeholder(placeholder, context)
+            plugins = placeholder.get_plugins_list()
+            self.assertEqual(len(plugins), 3)
+            self.assertEqual(plugins[0].plugin_type, 'TextPlugin')
+            self.assertEqual(plugins[1].plugin_type, 'LinkPlugin')
+            self.assertEqual(plugins[2].plugin_type, 'LinkPlugin')
+            self.assertTrue(plugins[1].parent == plugins[2].parent and plugins[1].parent == plugins[0])
+
     def test_placeholder_pk_thousands_format(self):
         page = create_page("page", "nav_playground.html", "en")
         title = page.get_content_obj("en")
diff --git a/cms/tests/test_rendering.py b/cms/tests/test_rendering.py
index b542db75bbe..62ac442ca31 100644
--- a/cms/tests/test_rendering.py
+++ b/cms/tests/test_rendering.py
@@ -723,6 +723,7 @@ def test_inherit_placeholder_override(self):
         r = self.render(self.test_page5)
         self.assertEqual(r, '|' + self.test_data5['text_main'] + '|' + self.test_data5['text_sub'])
 
+    @override_settings(CMS_PLACEHOLDER_CONF={None: {'language_fallback': False}})
     def test_inherit_placeholder_queries(self):
         with self.assertNumQueries(FuzzyInt(6, 10)):
             r = self.render(self.test_page2)
diff --git a/cms/tests/test_templatetags.py b/cms/tests/test_templatetags.py
index cd259fa0646..ab0a028dffb 100644
--- a/cms/tests/test_templatetags.py
+++ b/cms/tests/test_templatetags.py
@@ -11,7 +11,7 @@
 from django.test import RequestFactory
 from django.test.utils import override_settings
 from django.utils.encoding import force_str
-from django.utils.html import escape
+from django.utils.html import strip_tags
 from django.utils.timezone import now
 from django.utils.translation import override as force_language
 from djangocms_text_ckeditor.cms_plugins import TextPlugin
@@ -115,20 +115,32 @@ def test_unicode_placeholder_name_fails_fast(self):
 
     def test_page_attribute_tag_escapes_content(self):
         script = '<script>alert("XSS");</script>'
+        ampersand = 'Q&A page'
 
         class FakePage:
+            def __init__(self, title):
+                self.title = title
+                super().__init__()
+
             def get_page_title(self, *args, **kwargs):
-                return script
+                return self.title
 
         class FakeRequest:
-            current_page = FakePage()
             GET = {'language': 'en'}
 
-        request = FakeRequest()
+            def __init__(self, page):
+                self.current_page = page
+
+        request_script = FakeRequest(FakePage(script))
+        request_ampersand = FakeRequest(FakePage(ampersand))
         template = '{% load cms_tags %}{% page_attribute page_title %}'
-        output = self.render_template_obj(template, {}, request)
-        self.assertNotEqual(script, output)
-        self.assertEqual(escape(script), output)
+        output_script = self.render_template_obj(template, {}, request_script)
+        output_ampersand = self.render_template_obj(template, {}, request_ampersand)
+
+        self.assertNotEqual(script, output_script)
+        self.assertEqual(ampersand, output_ampersand)
+        self.assertEqual(strip_tags(script), output_script)
+        self.assertEqual(strip_tags(ampersand), output_ampersand)
 
     def test_json_encoder(self):
         self.assertEqual(json_filter(True), 'true')