forked from ungleich-public/dynamicweb
clone cms with menus, make menus standalone app
This commit is contained in:
parent
1ece5ce7c7
commit
afb096b7a3
1153 changed files with 318897 additions and 37 deletions
1
cms/__init__.py
Normal file
1
cms/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
__version__ = '5.2.0dev1'
|
||||
9
cms/__main__.py
Normal file
9
cms/__main__.py
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
"""
|
||||
Invokes djangocms when the cms module is run as a script.
|
||||
|
||||
Example: python -m cms mysite
|
||||
"""
|
||||
from cms.management import djangocms
|
||||
|
||||
if __name__ == "__main__":
|
||||
djangocms.execute_from_command_line()
|
||||
10
cms/admin/__init__.py
Normal file
10
cms/admin/__init__.py
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import cms.admin.pageadmin # noqa: F401
|
||||
import cms.admin.permissionadmin # noqa: F401
|
||||
import cms.admin.placeholderadmin # noqa: F401
|
||||
import cms.admin.settingsadmin # noqa: F401
|
||||
import cms.admin.useradmin # noqa: F401
|
||||
|
||||
# Piggyback off admin.autodiscover() to discover cms plugins
|
||||
from cms import plugin_pool
|
||||
|
||||
plugin_pool.plugin_pool.discover_plugins()
|
||||
1714
cms/admin/forms.py
Normal file
1714
cms/admin/forms.py
Normal file
File diff suppressed because it is too large
Load diff
1513
cms/admin/pageadmin.py
Normal file
1513
cms/admin/pageadmin.py
Normal file
File diff suppressed because it is too large
Load diff
232
cms/admin/permissionadmin.py
Normal file
232
cms/admin/permissionadmin.py
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
from copy import deepcopy
|
||||
|
||||
from django.contrib import admin
|
||||
from django.contrib.admin import site
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.auth.admin import UserAdmin
|
||||
from django.contrib.sites.models import Site
|
||||
from django.db import OperationalError, ProgrammingError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from cms.admin.forms import (
|
||||
GlobalPagePermissionAdminForm,
|
||||
PagePermissionInlineAdminForm,
|
||||
ViewRestrictionInlineAdminForm,
|
||||
)
|
||||
from cms.exceptions import NoPermissionsException
|
||||
from cms.models import GlobalPagePermission, PagePermission
|
||||
from cms.utils import page_permissions, permissions
|
||||
from cms.utils.conf import get_cms_setting
|
||||
from cms.utils.helpers import classproperty
|
||||
|
||||
PERMISSION_ADMIN_INLINES = []
|
||||
|
||||
user_model = get_user_model()
|
||||
admin_class = UserAdmin
|
||||
for model, admin_instance in site._registry.items():
|
||||
if model == user_model:
|
||||
admin_class = admin_instance.__class__
|
||||
|
||||
|
||||
class TabularInline(admin.TabularInline):
|
||||
pass
|
||||
|
||||
|
||||
class PagePermissionInlineAdmin(TabularInline):
|
||||
model = PagePermission
|
||||
# use special form, so we can override of user and group field
|
||||
form = PagePermissionInlineAdminForm
|
||||
extra = 0 # edit page load time boost
|
||||
show_with_view_permissions = False
|
||||
|
||||
def has_change_permission(self, request, obj=None):
|
||||
if not obj:
|
||||
return False
|
||||
return page_permissions.user_can_change_page_permissions(
|
||||
request.user,
|
||||
page=obj,
|
||||
site=obj.site,
|
||||
)
|
||||
|
||||
def has_add_permission(self, request, obj=None):
|
||||
return self.has_change_permission(request, obj)
|
||||
|
||||
@classproperty
|
||||
def raw_id_fields(cls):
|
||||
# Dynamically set raw_id_fields based on settings
|
||||
threshold = get_cms_setting('RAW_ID_USERS')
|
||||
|
||||
# Given a fresh django-cms install and a django settings with the
|
||||
# CMS_RAW_ID_USERS = CMS_PERMISSION = True
|
||||
# django throws an OperationalError (sqlite) or ProgrammingError
|
||||
# (postgres) when running ./manage migrate because the user table
|
||||
# doesn't exist yet.
|
||||
try:
|
||||
threshold = threshold and get_user_model().objects.count() > threshold
|
||||
except (OperationalError, ProgrammingError):
|
||||
threshold = False
|
||||
|
||||
return ['user'] if threshold else []
|
||||
|
||||
def get_queryset(self, request):
|
||||
"""
|
||||
Queryset change, so user with global change permissions can see
|
||||
all permissions. Otherwise user can see only permissions for
|
||||
peoples which are under him (he can't see his permissions, because
|
||||
this will lead to violation, when he can add more power to himself)
|
||||
"""
|
||||
site = Site.objects.get_current(request)
|
||||
|
||||
try:
|
||||
# can see only permissions for users which are under him in tree
|
||||
qs = self.model.objects.subordinate_to_user(request.user, site)
|
||||
except NoPermissionsException:
|
||||
return self.model.objects.none()
|
||||
return qs.filter(can_view=self.show_with_view_permissions)
|
||||
|
||||
def get_formset(self, request, obj=None, **kwargs):
|
||||
"""
|
||||
Some fields may be excluded here. User can change only
|
||||
permissions which are available for him. E.g. if user does not haves
|
||||
can_change flag, he can't change assign can_change permissions.
|
||||
"""
|
||||
exclude = self.exclude or []
|
||||
if obj:
|
||||
user = request.user
|
||||
if not obj.has_add_permission(user):
|
||||
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):
|
||||
exclude.append('can_move_page')
|
||||
|
||||
kwargs['exclude'] = exclude
|
||||
self.form._page = obj
|
||||
formset_cls = super().get_formset(request, obj=obj, **kwargs)
|
||||
qs = self.get_queryset(request)
|
||||
if obj is not None:
|
||||
qs = qs.filter(page=obj)
|
||||
formset_cls._queryset = qs
|
||||
return formset_cls
|
||||
|
||||
|
||||
class ViewRestrictionInlineAdmin(PagePermissionInlineAdmin):
|
||||
extra = 0 # edit page load time boost
|
||||
form = ViewRestrictionInlineAdminForm
|
||||
verbose_name = _("View restriction")
|
||||
verbose_name_plural = _("View restrictions")
|
||||
show_with_view_permissions = True
|
||||
|
||||
|
||||
class GlobalPagePermissionAdmin(admin.ModelAdmin):
|
||||
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 = []
|
||||
for field in admin_class.search_fields:
|
||||
search_fields.append("user__%s" % field)
|
||||
search_fields.append('group__name')
|
||||
|
||||
list_display.append('can_change_advanced_settings')
|
||||
list_filter.append('can_change_advanced_settings')
|
||||
|
||||
def get_list_filter(self, request):
|
||||
threshold = get_cms_setting('RAW_ID_USERS')
|
||||
try:
|
||||
threshold = threshold and get_user_model().objects.count() > threshold
|
||||
except (OperationalError, ProgrammingError):
|
||||
threshold = False
|
||||
filter_copy = deepcopy(self.list_filter)
|
||||
if threshold:
|
||||
filter_copy.remove('user')
|
||||
return filter_copy
|
||||
|
||||
def get_exclude(self, request, obj=None):
|
||||
"""Offer only the ``can_*`` flags the acting user may hand out.
|
||||
|
||||
Mirrors ``PagePermissionInlineAdmin.get_formset``: a manager must not be
|
||||
able to grant -- to themselves or anyone else -- a right they do not hold
|
||||
(CWE-269). Excluding the field keeps it out of the rendered form *and*
|
||||
out of a crafted POST. Whether a flag the manager holds on some site may
|
||||
be granted for the sites actually picked is settled in the form's
|
||||
``clean()``, once ``sites`` is known.
|
||||
"""
|
||||
exclude = list(super().get_exclude(request, obj) or [])
|
||||
grantable = permissions.get_grantable_global_permissions(request.user)
|
||||
exclude.extend(
|
||||
field for field in GlobalPagePermission.get_all_permissions()
|
||||
if field not in grantable and field not in exclude
|
||||
)
|
||||
return exclude
|
||||
|
||||
def get_form(self, request, obj=None, **kwargs):
|
||||
form_class = super().get_form(request, obj, **kwargs)
|
||||
# The form caps each granted flag to the sites the manager holds it on,
|
||||
# so it needs to know who is acting. ``modelform_factory`` returns a
|
||||
# fresh subclass per call, so this is per-request state rather than
|
||||
# shared mutation of the declared form class.
|
||||
form_class._current_user = request.user
|
||||
return form_class
|
||||
|
||||
def has_add_permission(self, request):
|
||||
site = Site.objects.get_current(request)
|
||||
return permissions.user_can_add_global_permissions(request.user, site)
|
||||
|
||||
def has_change_permission(self, request, obj=None):
|
||||
site = Site.objects.get_current(request)
|
||||
return (
|
||||
permissions.user_can_change_global_permissions(request.user, site)
|
||||
and self._can_manage_grant(request, obj)
|
||||
)
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
site = Site.objects.get_current(request)
|
||||
return (
|
||||
permissions.user_can_delete_global_permissions(request.user, site)
|
||||
and self._can_manage_grant(request, obj)
|
||||
)
|
||||
|
||||
def _can_manage_grant(self, request, obj):
|
||||
"""Only let a manager edit or delete a grant they could have created.
|
||||
|
||||
A grant for a site the manager may not manage permissions on, or holding
|
||||
flags they may not hand out on its sites, is out of reach: its ``user``,
|
||||
``group`` and ``sites`` would otherwise let them move those flags to
|
||||
themselves or to other sites (CWE-269).
|
||||
"""
|
||||
if obj is None:
|
||||
return True
|
||||
held = {flag for flag in GlobalPagePermission.get_all_permissions() if getattr(obj, flag)}
|
||||
site_ids = list(obj.sites.values_list("pk", flat=True))
|
||||
grantable = permissions.get_grantable_global_permissions(request.user, site_ids)
|
||||
return "can_change_permissions" in grantable and held <= grantable
|
||||
|
||||
@classproperty
|
||||
def raw_id_fields(cls):
|
||||
# Dynamically set raw_id_fields based on settings
|
||||
threshold = get_cms_setting('RAW_ID_USERS')
|
||||
|
||||
# Given a fresh django-cms install and a django settings with the
|
||||
# CMS_RAW_ID_USERS = CMS_PERMISSION = True
|
||||
# django throws an OperationalError (sqlite) or ProgrammingError
|
||||
# (postgres) when running ./manage migrate because the user table
|
||||
# doesn't exist yet.
|
||||
try:
|
||||
threshold = threshold and get_user_model().objects.count() > threshold
|
||||
except (OperationalError, ProgrammingError):
|
||||
threshold = False
|
||||
|
||||
return ['user'] if threshold else []
|
||||
|
||||
|
||||
if get_cms_setting('PERMISSION'):
|
||||
admin.site.register(GlobalPagePermission, GlobalPagePermissionAdmin)
|
||||
PERMISSION_ADMIN_INLINES.extend([
|
||||
ViewRestrictionInlineAdmin,
|
||||
PagePermissionInlineAdmin,
|
||||
])
|
||||
1355
cms/admin/placeholderadmin.py
Normal file
1355
cms/admin/placeholderadmin.py
Normal file
File diff suppressed because it is too large
Load diff
156
cms/admin/settingsadmin.py
Normal file
156
cms/admin/settingsadmin.py
Normal file
|
|
@ -0,0 +1,156 @@
|
|||
import copy
|
||||
import json
|
||||
from functools import update_wrapper
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from django.contrib import admin
|
||||
from django.db import transaction
|
||||
from django.http import (
|
||||
HttpResponse,
|
||||
HttpResponseBadRequest,
|
||||
HttpResponseRedirect,
|
||||
)
|
||||
from django.http.request import QueryDict
|
||||
from django.urls import Resolver404, path, re_path, resolve
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.utils.html import conditional_escape
|
||||
from django.utils.translation import override
|
||||
from django.views.decorators.csrf import csrf_protect
|
||||
|
||||
from cms.admin.forms import RequestToolbarForm
|
||||
from cms.models import UserSettings
|
||||
from cms.models.contentmodels import PageContent
|
||||
from cms.utils.page import get_page_from_request
|
||||
from cms.utils.page_permissions import user_can_view_page
|
||||
from cms.utils.permissions import user_can_view_placeholder_source
|
||||
from cms.utils.urlutils import admin_reverse
|
||||
|
||||
|
||||
@admin.register(UserSettings)
|
||||
class SettingsAdmin(admin.ModelAdmin):
|
||||
|
||||
def get_urls(self):
|
||||
def wrap(view):
|
||||
def wrapper(*args, **kwargs):
|
||||
return self.admin_site.admin_view(view)(*args, **kwargs)
|
||||
|
||||
return update_wrapper(wrapper, view)
|
||||
|
||||
info = self.model._meta.app_label, self.model._meta.model_name
|
||||
|
||||
return [
|
||||
path('session_store/', self.session_store, name='%s_%s_session_store' % info),
|
||||
path('cms-toolbar/', wrap(self.get_toolbar), name='%s_%s_get_toolbar' % info),
|
||||
path('', wrap(self.change_view), name='%s_%s_change' % info),
|
||||
re_path(r'^(.+)/$', wrap(self.change_view), name='%s_%s_change' % info),
|
||||
]
|
||||
|
||||
@method_decorator(csrf_protect)
|
||||
@transaction.atomic
|
||||
def change_view(self, request, id=None):
|
||||
model = self.model
|
||||
try:
|
||||
obj = model.objects.get(user=request.user)
|
||||
except model.DoesNotExist:
|
||||
return self.add_view(request)
|
||||
return super().change_view(request, str(obj.pk))
|
||||
|
||||
def session_store(self, request):
|
||||
"""
|
||||
either POST or GET
|
||||
POST should have a settings parameter
|
||||
"""
|
||||
if not request.user.is_staff:
|
||||
return HttpResponse(json.dumps(""),
|
||||
content_type="application/json")
|
||||
if request.method == "POST":
|
||||
request.session['cms_settings'] = conditional_escape(request.POST['settings'])
|
||||
request.session.save()
|
||||
return HttpResponse(
|
||||
json.dumps(request.session.get('cms_settings', '')),
|
||||
content_type="application/json"
|
||||
)
|
||||
|
||||
def get_toolbar(self, request):
|
||||
from cms.toolbar.toolbar import CMSToolbar
|
||||
|
||||
form = RequestToolbarForm(request.GET or None)
|
||||
|
||||
if not form.is_valid():
|
||||
return HttpResponseBadRequest('Invalid parameters')
|
||||
|
||||
form_data = form.cleaned_data
|
||||
cms_path = form_data.get('cms_path') or request.path_info
|
||||
origin_url = urlparse(cms_path)
|
||||
attached_obj = form_data.get('attached_obj')
|
||||
if isinstance(attached_obj, PageContent):
|
||||
current_page = attached_obj.page
|
||||
else:
|
||||
current_page = get_page_from_request(request, use_path=origin_url.path, clean_path=True)
|
||||
|
||||
# The object id/type come straight from the client, so enforce an
|
||||
# object-level view check before rendering a toolbar for it. Without
|
||||
# this, any staff user could read the title/breadcrumb/edit URLs of
|
||||
# pages on other sites or of view-restricted pages they cannot access.
|
||||
# Deny with the same generic response as an unknown object so this does
|
||||
# not become an existence oracle.
|
||||
if isinstance(attached_obj, PageContent):
|
||||
if not user_can_view_page(request.user, current_page):
|
||||
return HttpResponseBadRequest('Invalid parameters')
|
||||
elif attached_obj is not None:
|
||||
if not user_can_view_placeholder_source(request.user, attached_obj):
|
||||
return HttpResponseBadRequest('Invalid parameters')
|
||||
elif current_page is not None and not user_can_view_page(request.user, current_page):
|
||||
return HttpResponseBadRequest('Invalid parameters')
|
||||
|
||||
data = QueryDict(query_string=origin_url.query, mutable=True)
|
||||
placeholders = request.GET.getlist("placeholders[]")
|
||||
|
||||
if placeholders:
|
||||
data.setlist('placeholders[]', placeholders)
|
||||
|
||||
request = copy.copy(request)
|
||||
request.GET = data
|
||||
request.current_page = current_page
|
||||
try:
|
||||
request.resolver_match = resolve(origin_url.path)
|
||||
except Resolver404:
|
||||
pass
|
||||
request.toolbar = CMSToolbar(request, request_path=origin_url.path, _async=True)
|
||||
request.toolbar.set_object(attached_obj or current_page)
|
||||
return HttpResponse(request.toolbar.render())
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
obj.user = request.user
|
||||
obj.save()
|
||||
|
||||
def response_post_save_change(self, request, obj):
|
||||
#
|
||||
# When the user changes his language setting, we need to do two things:
|
||||
# 1. Change the language-prefix for the sideframed admin view
|
||||
# 2. Reload the whole window so that the new language affects the
|
||||
# toolbar, etc.
|
||||
#
|
||||
# To do this, we first redirect the sideframe to the correct new, URL,
|
||||
# but we pass a GET param 'reload_window', which instructs JS on that
|
||||
# page to strip (to avoid infinite redirection loops) that param then
|
||||
# reload the whole window again.
|
||||
#
|
||||
with override(obj.language):
|
||||
post_url = admin_reverse(
|
||||
'cms_usersettings_change',
|
||||
args=[obj.id, ],
|
||||
current_app=self.admin_site.name
|
||||
)
|
||||
return HttpResponseRedirect(f"{post_url}?reload_window")
|
||||
|
||||
def has_change_permission(self, request, obj=None):
|
||||
if obj and obj.user == request.user:
|
||||
return True
|
||||
return False
|
||||
|
||||
def get_model_perms(self, request):
|
||||
"""
|
||||
Return empty perms dict thus hiding the model from admin index.
|
||||
"""
|
||||
return {}
|
||||
171
cms/admin/useradmin.py
Normal file
171
cms/admin/useradmin.py
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
from copy import deepcopy
|
||||
|
||||
from django.contrib import admin
|
||||
from django.contrib.admin import ModelAdmin, site
|
||||
from django.contrib.admin.utils import unquote
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.sites.models import Site
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.utils.translation import gettext
|
||||
|
||||
from cms.admin.forms import PageUserChangeForm, PageUserGroupForm
|
||||
from cms.exceptions import NoPermissionsException
|
||||
from cms.models import Page, PagePermission, PageUser, PageUserGroup
|
||||
from cms.utils.compat.forms import UserAdmin
|
||||
from cms.utils.conf import get_cms_setting
|
||||
from cms.utils.permissions import (
|
||||
get_model_permission_codename,
|
||||
get_subordinate_groups,
|
||||
get_subordinate_users,
|
||||
get_user_permission_level,
|
||||
)
|
||||
|
||||
user_model = get_user_model()
|
||||
admin_class: type[ModelAdmin] = UserAdmin
|
||||
for model, admin_instance in site._registry.items():
|
||||
if model == user_model:
|
||||
admin_class = admin_instance.__class__
|
||||
|
||||
|
||||
class GenericCmsPermissionAdmin:
|
||||
|
||||
def get_subordinates(self, user, site):
|
||||
raise NotImplementedError
|
||||
|
||||
def _has_change_permissions_permission(self, request):
|
||||
"""
|
||||
User is able to add/change objects only if he haves can change
|
||||
permission on some page.
|
||||
"""
|
||||
site = Site.objects.get_current(request)
|
||||
|
||||
try:
|
||||
get_user_permission_level(request.user, site)
|
||||
except NoPermissionsException:
|
||||
return False
|
||||
return True
|
||||
|
||||
def get_form(self, request, obj=None, **kwargs):
|
||||
form_class = super().get_form(request, obj, **kwargs)
|
||||
form_class._current_user = request.user
|
||||
return form_class
|
||||
|
||||
def get_queryset(self, request):
|
||||
queryset = super().get_queryset(request)
|
||||
site = Site.objects.get_current(request)
|
||||
user_ids = self.get_subordinates(request.user, site).values_list('pk', flat=True)
|
||||
return queryset.filter(pk__in=user_ids)
|
||||
|
||||
def has_add_permission(self, request):
|
||||
has_model_perm = super().has_add_permission(request)
|
||||
|
||||
if not has_model_perm:
|
||||
return False
|
||||
return self._has_change_permissions_permission(request)
|
||||
|
||||
def has_change_permission(self, request, obj=None):
|
||||
has_model_perm = super().has_change_permission(request, obj)
|
||||
|
||||
if not has_model_perm:
|
||||
return False
|
||||
return self._has_change_permissions_permission(request)
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
has_model_perm = super().has_delete_permission(request, obj)
|
||||
|
||||
if not has_model_perm:
|
||||
return False
|
||||
return self._has_change_permissions_permission(request)
|
||||
|
||||
def has_view_permission(self, request, obj=None):
|
||||
# For django 2.1
|
||||
# Default is to return True if user got `change` perm, but we have to
|
||||
# get in consideration also cms permission system
|
||||
return self.has_change_permission(request, obj)
|
||||
|
||||
|
||||
class PageUserAdmin(GenericCmsPermissionAdmin, admin_class):
|
||||
form = PageUserChangeForm
|
||||
model = PageUser
|
||||
|
||||
def get_subordinates(self, user, site):
|
||||
return get_subordinate_users(user, site).values_list('pk', flat=True)
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
fields = super().get_readonly_fields(request, obj)
|
||||
|
||||
if not request.user.is_superuser:
|
||||
# Non superusers can't set superuser status on
|
||||
# their subordinates.
|
||||
fields = list(fields) + ['is_superuser']
|
||||
return fields
|
||||
|
||||
def has_change_permission(self, request, obj=None):
|
||||
if obj is not None and obj.is_superuser and not request.user.is_superuser:
|
||||
# Superusers are nobody's subordinate. get_queryset() already hides
|
||||
# them, this guards the views that resolve an object by other means.
|
||||
return False
|
||||
return super().has_change_permission(request, obj)
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
if obj is not None and obj.is_superuser and not request.user.is_superuser:
|
||||
return False
|
||||
return super().has_delete_permission(request, obj)
|
||||
|
||||
def user_change_password(self, request, id, form_url=''):
|
||||
# Django's password view only checks has_change_permission(), so a
|
||||
# subordinate check has to happen before anything else.
|
||||
user = self.get_object(request, unquote(id))
|
||||
|
||||
if user is not None and user.is_superuser and not request.user.is_superuser:
|
||||
raise PermissionDenied
|
||||
return super().user_change_password(request, id, form_url)
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
if not change:
|
||||
# By default set the staff flag to True
|
||||
# when a PageUser is first created
|
||||
obj.is_staff = True
|
||||
# Set the created_by field to the current user
|
||||
obj.created_by = request.user
|
||||
super().save_model(request, obj, form, change)
|
||||
|
||||
|
||||
class PageUserGroupAdmin(GenericCmsPermissionAdmin, admin.ModelAdmin):
|
||||
form = PageUserGroupForm
|
||||
list_display = ('name', 'created_by')
|
||||
|
||||
fieldsets = [
|
||||
(None, {'fields': ('name',)}),
|
||||
]
|
||||
|
||||
def get_fieldsets(self, request, obj=None):
|
||||
"""
|
||||
Nobody can grant more than he haves, so check for user permissions
|
||||
to Page and User model and render fieldset depending on them.
|
||||
"""
|
||||
fieldsets = deepcopy(self.fieldsets)
|
||||
perm_models = (
|
||||
(Page, gettext('Page permissions')),
|
||||
(PageUser, gettext('User & Group permissions')),
|
||||
(PagePermission, gettext('Page permissions management')),
|
||||
)
|
||||
for i, perm_model in enumerate(perm_models):
|
||||
fields = []
|
||||
model, title = perm_model
|
||||
name = model.__name__.lower()
|
||||
for key in ('add', 'change', 'delete'):
|
||||
perm_code = get_model_permission_codename(model, action=key)
|
||||
if request.user.has_perm(perm_code):
|
||||
fields.append(f'can_{key}_{name}')
|
||||
if fields:
|
||||
fieldsets.insert(2 + i, (title, {'fields': (fields,)}))
|
||||
return fieldsets
|
||||
|
||||
def get_subordinates(self, user, site):
|
||||
return get_subordinate_groups(user, site).values_list('pk', flat=True)
|
||||
|
||||
|
||||
if get_cms_setting('PERMISSION'):
|
||||
admin.site.register(PageUser, PageUserAdmin)
|
||||
admin.site.register(PageUserGroup, PageUserGroupAdmin)
|
||||
1192
cms/admin/utils.py
Normal file
1192
cms/admin/utils.py
Normal file
File diff suppressed because it is too large
Load diff
728
cms/api.py
Normal file
728
cms/api.py
Normal file
|
|
@ -0,0 +1,728 @@
|
|||
"""
|
||||
Python APIs for creating CMS content. This is done in :mod:`cms.api` and not
|
||||
on the models and managers, because the direct API via models and managers is
|
||||
slightly counterintuitive for developers.
|
||||
|
||||
The api for both Pages and Plugins has changed significantly since django CMS
|
||||
Version 4.
|
||||
|
||||
Also, the functions defined in this module do sanity checks on arguments.
|
||||
|
||||
.. warning:: None of the functions in this module does any security or permission
|
||||
checks. They verify their input values to be sane wherever
|
||||
possible, however permission checks should be implemented manually
|
||||
before calling any of these functions.
|
||||
|
||||
.. info:: Due to potential circular dependency issues, it's recommended
|
||||
to import the api in the functions that uses its function.
|
||||
|
||||
e.g. use:
|
||||
|
||||
::
|
||||
|
||||
def my_function():
|
||||
from cms.api import api_function
|
||||
|
||||
api_function(...)
|
||||
|
||||
instead of:
|
||||
|
||||
::
|
||||
|
||||
from cms.api import api_function
|
||||
|
||||
|
||||
def my_function():
|
||||
api_function(...)
|
||||
|
||||
"""
|
||||
|
||||
import warnings
|
||||
from collections import defaultdict
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.contrib.sites.models import Site
|
||||
from django.core.exceptions import FieldError, ValidationError
|
||||
from django.db import IntegrityError, models, transaction
|
||||
from django.template.defaultfilters import slugify
|
||||
from django.template.loader import get_template
|
||||
|
||||
from cms import constants
|
||||
from cms.app_base import CMSApp
|
||||
from cms.apphook_pool import apphook_pool
|
||||
from cms.constants import TEMPLATE_INHERITANCE_MAGIC
|
||||
from cms.models import PageContent
|
||||
from cms.models.pagemodel import Page
|
||||
from cms.models.permissionmodels import (
|
||||
ACCESS_PAGE_AND_DESCENDANTS,
|
||||
GlobalPagePermission,
|
||||
PagePermission,
|
||||
PageUser,
|
||||
)
|
||||
from cms.models.placeholdermodel import Placeholder
|
||||
from cms.models.pluginmodel import CMSPlugin
|
||||
from cms.plugin_base import CMSPluginBase
|
||||
from cms.plugin_pool import plugin_pool
|
||||
from cms.utils.conf import get_cms_setting
|
||||
from cms.utils.i18n import get_language_list
|
||||
from cms.utils.page import get_available_slug, get_clean_username
|
||||
from cms.utils.permissions import get_current_user, reset_current_user, set_current_user
|
||||
from cms.utils.plugins import copy_plugins_to_placeholder, downcast_plugins
|
||||
from .menus.menu_pool import menu_pool
|
||||
|
||||
# ===============================================================================
|
||||
# Helpers/Internals
|
||||
# ===============================================================================
|
||||
|
||||
|
||||
def _verify_apphook(apphook, namespace):
|
||||
"""
|
||||
Verifies the apphook given is valid and returns the normalized form (name)
|
||||
"""
|
||||
apphook_pool.discover_apps()
|
||||
if isinstance(apphook, CMSApp):
|
||||
try:
|
||||
assert apphook.__class__ in [app.__class__ for app in apphook_pool.apps.values()]
|
||||
except AssertionError:
|
||||
raise
|
||||
apphook_name = apphook.__class__.__name__
|
||||
elif hasattr(apphook, "__module__") and issubclass(apphook, CMSApp):
|
||||
return apphook.__name__
|
||||
elif isinstance(apphook, str):
|
||||
try:
|
||||
assert apphook in apphook_pool.apps
|
||||
except AssertionError:
|
||||
raise
|
||||
apphook_name = apphook
|
||||
else:
|
||||
raise TypeError("apphook must be string or CMSApp instance")
|
||||
if apphook_pool.apps[apphook_name].app_name and not namespace:
|
||||
raise ValidationError("apphook with app_name must define a namespace")
|
||||
return apphook_name
|
||||
|
||||
|
||||
def _get_default_slug(page, title, language):
|
||||
candidate = slugify(title)
|
||||
base = page.get_path_for_slug(candidate, language)
|
||||
# without a reachable path (e.g. unpublished parent) check the bare slug
|
||||
# for availability instead
|
||||
return get_available_slug(page.site, base if base is not None else candidate, language)
|
||||
|
||||
|
||||
def _validate_path_uniqueness(page, path, language):
|
||||
if path is None:
|
||||
# pages without a reachable path (e.g. unpublished parent) cannot
|
||||
# conflict with any URL
|
||||
return
|
||||
from cms.forms.validators import validate_url_uniqueness
|
||||
|
||||
validate_url_uniqueness(page.site, path, language, exclude_page=page)
|
||||
|
||||
|
||||
def _verify_plugin_type(plugin_type):
|
||||
"""
|
||||
Verifies the given plugin_type is valid and returns a tuple of
|
||||
(plugin_model, plugin_type)
|
||||
"""
|
||||
if hasattr(plugin_type, "__module__") and issubclass(plugin_type, CMSPluginBase):
|
||||
plugin_model = plugin_type.model
|
||||
assert plugin_type in plugin_pool.plugins.values()
|
||||
plugin_type = plugin_type.__name__
|
||||
elif isinstance(plugin_type, str):
|
||||
try:
|
||||
plugin_model = plugin_pool.get_plugin(plugin_type).model
|
||||
except KeyError:
|
||||
raise TypeError("plugin_type must be CMSPluginBase subclass or string")
|
||||
else:
|
||||
raise TypeError("plugin_type must be CMSPluginBase subclass or string")
|
||||
return plugin_model, plugin_type
|
||||
|
||||
|
||||
# ===============================================================================
|
||||
# Public API
|
||||
# ===============================================================================
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def create_page(
|
||||
title,
|
||||
template,
|
||||
language,
|
||||
menu_title=None,
|
||||
slug=None,
|
||||
apphook=None,
|
||||
apphook_namespace=None,
|
||||
redirect=None,
|
||||
meta_description=None,
|
||||
created_by="python-api",
|
||||
parent=None,
|
||||
publication_date=None,
|
||||
publication_end_date=None,
|
||||
in_navigation=False,
|
||||
soft_root=False,
|
||||
reverse_id=None,
|
||||
navigation_extenders=None,
|
||||
published=None,
|
||||
site=None,
|
||||
login_required=False,
|
||||
limit_visibility_in_menu=constants.VISIBILITY_ALL,
|
||||
position="last-child",
|
||||
overwrite_url=None,
|
||||
xframe_options=constants.X_FRAME_OPTIONS_INHERIT,
|
||||
page_title=None,
|
||||
):
|
||||
"""
|
||||
Creates a :class:`cms.models.Page` instance and returns it. Also
|
||||
creates a :class:`cms.models.PageContent` instance for the specified
|
||||
language.
|
||||
|
||||
.. warning::
|
||||
Since version 4 the parameters published, publication_date, and publication_end_date
|
||||
do not change the behaviour of this function. If they are supplied a warning is raised.
|
||||
|
||||
:param str title: Title of the page
|
||||
:param str template: Template to use for this page. Must be in :setting:`CMS_TEMPLATES`
|
||||
:param str language: Language code for this page. Must be in :setting:`django:LANGUAGES`
|
||||
:param str menu_title: Menu title for this page
|
||||
:param str slug: Slug for the page, by default uses a slugified version of *title*
|
||||
:param apphook: Application to hook on this page, must be a valid apphook
|
||||
:type apphook: str or :class:`cms.app_base.CMSApp` sub-class
|
||||
:param str apphook_namespace: Name of the apphook namespace
|
||||
:param str redirect: URL redirect
|
||||
:param str meta_description: Description of this page for SEO
|
||||
:param created_by: User that is creating this page
|
||||
:type created_by: str of :class:`django.contrib.auth.models.User` instance
|
||||
:param parent: Parent page of this page
|
||||
:type parent: :class:`cms.models.Page` instance
|
||||
:param bool in_navigation: Whether this page should be in the navigation or not
|
||||
:param bool soft_root: Whether this page is a soft root or not
|
||||
:param str reverse_id: Reverse ID of this page (for template tags)
|
||||
:param str navigation_extenders: Menu to attach to this page. Must be a valid menu
|
||||
:param site: Site to put this page on
|
||||
:type site: :class:`django.contrib.sites.models.Site` instance
|
||||
:param bool login_required: Whether users must be logged in or not to view this page
|
||||
:param limit_visibility_in_menu: Limits visibility of this page in the menu
|
||||
:type limit_visibility_in_menu: :data:`VISIBILITY_ALL` or :data:`VISIBILITY_USERS` or :data:`VISIBILITY_ANONYMOUS`
|
||||
:param str position: Where to insert this node if *parent* is given, must be ``'first-child'`` or ``'last-child'``
|
||||
:param str overwrite_url: Overwritten path for this page
|
||||
:param int xframe_options: X Frame Option value for Clickjacking protection
|
||||
:param str page_title: Overridden page title for HTML title tag
|
||||
"""
|
||||
if published is not None or publication_date is not None or publication_end_date is not None:
|
||||
warnings.warn(
|
||||
'This API function no longer accepts a "published", "publication_date", or '
|
||||
'"publication_end_date" argument',
|
||||
UserWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
# validate template
|
||||
if not template == TEMPLATE_INHERITANCE_MAGIC:
|
||||
assert template in [tpl[0] for tpl in get_cms_setting("TEMPLATES")]
|
||||
get_template(template)
|
||||
|
||||
# validate parent
|
||||
if parent:
|
||||
assert isinstance(parent, Page)
|
||||
|
||||
# validate site
|
||||
if not site:
|
||||
if parent:
|
||||
site = parent.site
|
||||
else:
|
||||
site = Site.objects.get_current() # Get the current site from settings
|
||||
else:
|
||||
assert isinstance(site, Site)
|
||||
if parent:
|
||||
assert parent.site == site, "Parent page must be on the same site as the new page"
|
||||
|
||||
# validate language:
|
||||
assert language in get_language_list(site), get_cms_setting("LANGUAGES").get(site.pk)
|
||||
|
||||
if navigation_extenders:
|
||||
raw_menus = menu_pool.get_menus_by_attribute("cms_enabled", True)
|
||||
menus = [menu[0] for menu in raw_menus]
|
||||
assert navigation_extenders in menus
|
||||
|
||||
# validate menu visibility
|
||||
accepted_limitations = (constants.VISIBILITY_ALL, constants.VISIBILITY_USERS, constants.VISIBILITY_ANONYMOUS)
|
||||
assert limit_visibility_in_menu in accepted_limitations
|
||||
|
||||
# validate position
|
||||
assert position in ("last-child", "first-child", "left", "right")
|
||||
|
||||
# validate and normalize apphook
|
||||
if apphook:
|
||||
application_urls = _verify_apphook(apphook, apphook_namespace)
|
||||
else:
|
||||
application_urls = None
|
||||
|
||||
# ugly permissions hack: expose the creator through the current-user
|
||||
# context variable for the duration of the creation, then restore the
|
||||
# previous value. Capturing the token and resetting (instead of blindly
|
||||
# setting None at the end) avoids clobbering a user set by
|
||||
# CurrentUserMiddleware and prevents leaking the creator into the next
|
||||
# operation on the same thread. See cms.utils.permissions.reset_current_user.
|
||||
if created_by and isinstance(created_by, get_user_model()):
|
||||
user_token = set_current_user(created_by)
|
||||
created_by = get_clean_username(created_by)
|
||||
else:
|
||||
user_token = set_current_user(None)
|
||||
|
||||
try:
|
||||
if reverse_id:
|
||||
if Page.objects.filter(reverse_id=reverse_id, site=site).exists():
|
||||
raise FieldError('A page with the reverse_id="%s" already exist.' % reverse_id)
|
||||
|
||||
page = Page(
|
||||
parent=parent,
|
||||
created_by=created_by,
|
||||
changed_by=created_by,
|
||||
reverse_id=reverse_id,
|
||||
navigation_extenders=navigation_extenders,
|
||||
application_urls=application_urls,
|
||||
application_namespace=apphook_namespace,
|
||||
login_required=login_required,
|
||||
site=site,
|
||||
)
|
||||
page.add_to_tree(position=position)
|
||||
page.save()
|
||||
|
||||
create_page_content(
|
||||
language=language,
|
||||
title=title,
|
||||
menu_title=menu_title,
|
||||
page_title=page_title,
|
||||
slug=slug,
|
||||
created_by=created_by,
|
||||
redirect=redirect,
|
||||
meta_description=meta_description,
|
||||
page=page,
|
||||
overwrite_url=overwrite_url,
|
||||
soft_root=soft_root,
|
||||
in_navigation=in_navigation,
|
||||
template=template,
|
||||
limit_visibility_in_menu=limit_visibility_in_menu,
|
||||
xframe_options=xframe_options,
|
||||
)
|
||||
return page
|
||||
finally:
|
||||
reset_current_user(user_token)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def create_page_content(
|
||||
language,
|
||||
title,
|
||||
page,
|
||||
menu_title=None,
|
||||
slug=None,
|
||||
redirect=None,
|
||||
meta_description=None,
|
||||
parent=None,
|
||||
overwrite_url=None,
|
||||
page_title=None,
|
||||
path=None,
|
||||
created_by="python-api",
|
||||
soft_root=False,
|
||||
in_navigation=False,
|
||||
template=TEMPLATE_INHERITANCE_MAGIC,
|
||||
limit_visibility_in_menu=constants.VISIBILITY_ALL,
|
||||
xframe_options=constants.X_FRAME_OPTIONS_INHERIT,
|
||||
):
|
||||
"""
|
||||
Creates a :class:`cms.models.PageContent` instance and returns it.
|
||||
|
||||
``parent`` is only used if slug=None.
|
||||
|
||||
:param str language: Language code for this page. Must be in :setting:`django:LANGUAGES`
|
||||
:param str title: Title of the page
|
||||
:param page: The page for which to create this title
|
||||
:type page: :class:`cms.models.Page` instance
|
||||
:param str menu_title: Menu title for this page
|
||||
:param str slug: Slug for the page, by default uses a slugified version of *title*
|
||||
:param str redirect: URL redirect
|
||||
:param str meta_description: Description of this page for SEO
|
||||
:param parent: Used for automated slug generation
|
||||
:type parent: :class:`cms.models.Page` instance
|
||||
:param str overwrite_url: Overwritten path for this page
|
||||
:param str page_title: Overridden page title for HTML title tag
|
||||
|
||||
"""
|
||||
# validate template
|
||||
if not template == TEMPLATE_INHERITANCE_MAGIC:
|
||||
assert template in [tpl[0] for tpl in get_cms_setting("TEMPLATES")]
|
||||
get_template(template)
|
||||
|
||||
# validate page
|
||||
assert isinstance(page, Page)
|
||||
|
||||
# validate language:
|
||||
assert language in get_language_list(page.site_id)
|
||||
|
||||
# validate menu visibility
|
||||
accepted_limitations = (constants.VISIBILITY_ALL, constants.VISIBILITY_USERS, constants.VISIBILITY_ANONYMOUS)
|
||||
assert limit_visibility_in_menu in accepted_limitations
|
||||
|
||||
# set default slug:
|
||||
if not slug:
|
||||
slug = _get_default_slug(page, title, language)
|
||||
|
||||
if overwrite_url or path is None:
|
||||
path = page.get_url_data(slug, overwrite_url, language)["path"]
|
||||
|
||||
# When called directly (not via create_page) with a user instance, expose
|
||||
# it through the current-user context variable so signal handlers and
|
||||
# PageContent.objects.with_user() attribute the creation correctly. Capture
|
||||
# the token so we can restore the previous value at the end -- otherwise the
|
||||
# user leaks into the next operation on the same thread when
|
||||
# CurrentUserMiddleware is not installed (it only resets at the request
|
||||
# boundary). See cms.utils.permissions.reset_current_user.
|
||||
user_token = None
|
||||
if created_by and isinstance(created_by, get_user_model()):
|
||||
user_token = set_current_user(created_by)
|
||||
created_by = get_clean_username(created_by)
|
||||
|
||||
try:
|
||||
try:
|
||||
_validate_path_uniqueness(page, path, language)
|
||||
except ValidationError as e:
|
||||
raise IntegrityError(e)
|
||||
|
||||
# E.g., djangocms-versioning needs an User object to be passed when creating a versioned Object
|
||||
user = get_current_user()
|
||||
page_content = PageContent.objects.with_user(user).create(
|
||||
language=language,
|
||||
title=title,
|
||||
menu_title=menu_title,
|
||||
page_title=page_title,
|
||||
redirect=redirect,
|
||||
meta_description=meta_description,
|
||||
page=page,
|
||||
created_by=created_by,
|
||||
changed_by=created_by,
|
||||
soft_root=soft_root,
|
||||
in_navigation=in_navigation,
|
||||
template=template,
|
||||
limit_visibility_in_menu=limit_visibility_in_menu,
|
||||
xframe_options=xframe_options,
|
||||
slug=slug,
|
||||
overwrite_url=overwrite_url.strip("/") if overwrite_url else None,
|
||||
)
|
||||
page_content.rescan_placeholders()
|
||||
|
||||
if page_content.is_public():
|
||||
# The page URL is derived from public content only. With a versioning
|
||||
# package installed the new content is created as a draft and gets
|
||||
# its URL on first publish.
|
||||
page.urls.update_or_create(
|
||||
page=page,
|
||||
language=language,
|
||||
defaults=dict(
|
||||
slug=slug,
|
||||
path=path,
|
||||
managed=not bool(overwrite_url),
|
||||
site_id=page.site_id,
|
||||
),
|
||||
)
|
||||
page._clear_internal_cache()
|
||||
|
||||
return page_content
|
||||
finally:
|
||||
if user_token is not None:
|
||||
reset_current_user(user_token)
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def add_plugin(placeholder, plugin_type, language, position="last-child", target=None, **data):
|
||||
"""
|
||||
Adds a plugin to a placeholder and returns it.
|
||||
|
||||
:param placeholder: Placeholder to add the plugin to
|
||||
:type placeholder: :class:`cms.models.placeholdermodel.Placeholder` instance
|
||||
:param plugin_type: What type of plugin to add
|
||||
:type plugin_type: str or :class:`cms.plugin_base.CMSPluginBase` sub-class, must be a valid plugin
|
||||
:param str language: Language code for this plugin, must be in :setting:`django:LANGUAGES`
|
||||
:param str position: Position to add this plugin to the placeholder. Allowed positions are ``"last-child"``
|
||||
(default), ``"first-child"``, ``"left"``, ``"right"``.
|
||||
:param target: Parent plugin. Must be plugin instance
|
||||
:param data: Data for the plugin type instance
|
||||
"""
|
||||
# validate placeholder
|
||||
assert isinstance(placeholder, Placeholder)
|
||||
|
||||
# validate and normalize plugin type
|
||||
plugin_model, plugin_type = _verify_plugin_type(plugin_type)
|
||||
|
||||
if target:
|
||||
if position == "last-child":
|
||||
new_pos = placeholder.get_next_plugin_position(language, parent=target, insert_order="last")
|
||||
parent_id = target.pk
|
||||
elif position == "first-child":
|
||||
new_pos = placeholder.get_next_plugin_position(language, parent=target, insert_order="first")
|
||||
parent_id = target.pk
|
||||
elif position == "left":
|
||||
new_pos = target.position
|
||||
parent_id = target.parent_id
|
||||
elif position == "right":
|
||||
new_pos = target.position + 1 + target._get_descendants_count()
|
||||
parent_id = target.parent_id
|
||||
else:
|
||||
raise Exception("position not supported: %s" % position)
|
||||
else:
|
||||
assert position in ("first-child", "last-child")
|
||||
if position == "last-child":
|
||||
new_pos = placeholder.get_next_plugin_position(language, insert_order="last")
|
||||
else:
|
||||
new_pos = 1
|
||||
parent_id = None
|
||||
|
||||
plugin_base = CMSPlugin(
|
||||
plugin_type=plugin_type,
|
||||
placeholder=placeholder,
|
||||
position=new_pos,
|
||||
language=language,
|
||||
parent_id=parent_id,
|
||||
)
|
||||
plugin_base = placeholder.add_plugin(plugin_base)
|
||||
plugin = plugin_model(**data)
|
||||
plugin_base.set_base_attr(plugin)
|
||||
plugin.save()
|
||||
return plugin
|
||||
|
||||
|
||||
def create_page_user(
|
||||
created_by,
|
||||
user,
|
||||
can_add_page=True,
|
||||
can_view_page=True,
|
||||
can_change_page=True,
|
||||
can_delete_page=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,
|
||||
can_delete_pagepermission=True,
|
||||
grant_all=False,
|
||||
):
|
||||
"""
|
||||
Creates a page user for the user provided and returns that page user.
|
||||
|
||||
:param created_by: The user that creates the page user
|
||||
:type created_by: :class:`django.contrib.auth.models.User` instance
|
||||
:param user: The user to create the page user from
|
||||
:type user: :class:`django.contrib.auth.models.User` instance
|
||||
:param bool can_*: Permissions to give the user
|
||||
:param bool grant_all: Grant all permissions to the user
|
||||
"""
|
||||
from cms.admin.forms import save_permissions
|
||||
|
||||
if grant_all:
|
||||
# just be lazy
|
||||
return create_page_user(created_by, user, True, True, True, True, True, True, True, True, True, True, True)
|
||||
|
||||
# validate created_by
|
||||
assert isinstance(created_by, get_user_model())
|
||||
|
||||
data = {
|
||||
"can_add_page": can_add_page,
|
||||
"can_view_page": can_view_page,
|
||||
"can_change_page": can_change_page,
|
||||
"can_delete_page": can_delete_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,
|
||||
"can_add_pagepermission": can_add_pagepermission,
|
||||
"can_change_pagepermission": can_change_pagepermission,
|
||||
"can_delete_pagepermission": can_delete_pagepermission,
|
||||
}
|
||||
user.is_staff = True
|
||||
user.is_active = True
|
||||
page_user = PageUser(created_by=created_by)
|
||||
for field in [f.name for f in get_user_model()._meta.local_fields]:
|
||||
setattr(page_user, field, getattr(user, field))
|
||||
user.save()
|
||||
page_user.save()
|
||||
save_permissions(data, page_user)
|
||||
return user
|
||||
|
||||
|
||||
def assign_user_to_page(
|
||||
page,
|
||||
user,
|
||||
grant_on=ACCESS_PAGE_AND_DESCENDANTS,
|
||||
can_add=False,
|
||||
can_change=False,
|
||||
can_delete=False,
|
||||
can_change_advanced_settings=False,
|
||||
can_publish=None,
|
||||
can_change_permissions=False,
|
||||
can_move_page=False,
|
||||
can_recover_page=True,
|
||||
can_view=False,
|
||||
grant_all=False,
|
||||
global_permission=False,
|
||||
):
|
||||
"""
|
||||
Assigns a user to a page and gives them some permissions. Returns the
|
||||
:class:`cms.models.PagePermission` object that gets
|
||||
created.
|
||||
|
||||
:param page: The page to assign the user to
|
||||
:type page: :class:`cms.models.Page` instance
|
||||
:param user: The user to assign to the page
|
||||
:type user: :class:`django.contrib.auth.models.User` instance
|
||||
:param grant_on: Controls which pages are affected
|
||||
:type grant_on: :data:`cms.models.ACCESS_PAGE`, :data:`cms.models.ACCESS_CHILDREN`,
|
||||
:data:`cms.models.ACCESS_DESCENDANTS` or :data:`cms.models.ACCESS_PAGE_AND_DESCENDANTS`
|
||||
:param can_*: Permissions to grant
|
||||
:param bool grant_all: Grant all permissions to the user
|
||||
"""
|
||||
|
||||
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,
|
||||
"can_view": can_view or grant_all,
|
||||
}
|
||||
|
||||
page_permission = PagePermission(page=page, user=user, grant_on=grant_on, **data)
|
||||
page_permission.save()
|
||||
if global_permission:
|
||||
page_permission = GlobalPagePermission(user=user, can_recover_page=can_recover_page, **data)
|
||||
page_permission.save()
|
||||
page_permission.sites.add(page.site)
|
||||
return page_permission
|
||||
|
||||
|
||||
def publish_page(page, user, language):
|
||||
"""
|
||||
.. warning::
|
||||
|
||||
Publishing pages has been removed from django CMS core in version 4 onward.
|
||||
|
||||
For publishing functionality see `djangocms-versioning: <https://github.com/django-cms/djangocms-versioning>`_
|
||||
"""
|
||||
warnings.warn(
|
||||
"This API function has been removed. For publishing functionality use a package that adds "
|
||||
"publishing, such as: djangocms-versioning.",
|
||||
UserWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
|
||||
def publish_pages(include_unpublished=False, language=None, site=None):
|
||||
"""
|
||||
.. warning::
|
||||
|
||||
Publishing pages has been removed from django CMS core in version 4 onward.
|
||||
|
||||
For publishing functionality see `djangocms-versioning: <https://github.com/django-cms/djangocms-versioning>`_
|
||||
"""
|
||||
warnings.warn(
|
||||
"This API function has been removed. For publishing functionality use a package that adds "
|
||||
"publishing, such as: djangocms-versioning.",
|
||||
UserWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
|
||||
def get_page_draft(page):
|
||||
"""
|
||||
.. warning::
|
||||
|
||||
The concept of draft pages has been removed from django CMS core in version 4 onward.
|
||||
|
||||
For draft functionality see `djangocms-versioning: <https://github.com/django-cms/djangocms-versioning>`_
|
||||
"""
|
||||
warnings.warn(
|
||||
"This API function has been removed. For publishing functionality use a package that adds "
|
||||
"publishing, such as: djangocms-versioning.",
|
||||
UserWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
|
||||
def copy_plugins_to_language(page, source_language, target_language, only_empty=True):
|
||||
"""
|
||||
Copy the plugins to another language in the same page for all the page
|
||||
placeholders.
|
||||
|
||||
By default, plugins are copied only if placeholder has no plugin for the
|
||||
target language; use ``only_empty=False`` to change this.
|
||||
|
||||
.. warning: This function skips permissions checks
|
||||
|
||||
:param page: the page to copy
|
||||
:type page: :class:`cms.models.pagemodel.Page` instance
|
||||
:param string source_language: The source language code,
|
||||
must be in :setting:`django:LANGUAGES`
|
||||
:param string target_language: The source language code,
|
||||
must be in :setting:`django:LANGUAGES`
|
||||
:param bool only_empty: if False, plugin are copied even if
|
||||
plugins exists in the target language (on a placeholder basis).
|
||||
:return int: number of copied plugins
|
||||
"""
|
||||
source_placeholders = list(page.get_placeholders(source_language))
|
||||
placeholder_ids = [placeholder.pk for placeholder in source_placeholders]
|
||||
source_plugins = downcast_plugins(
|
||||
CMSPlugin.objects.filter(
|
||||
placeholder_id__in=placeholder_ids,
|
||||
language=source_language,
|
||||
).order_by("position")
|
||||
)
|
||||
target_plugin_counts = dict(
|
||||
CMSPlugin.objects.filter(
|
||||
placeholder_id__in=placeholder_ids,
|
||||
language=target_language,
|
||||
)
|
||||
.values("placeholder_id")
|
||||
.annotate(count=models.Count("pk"))
|
||||
.values_list("placeholder_id", "count")
|
||||
)
|
||||
plugins_by_placeholder = defaultdict(list)
|
||||
for plugin in source_plugins:
|
||||
plugins_by_placeholder[plugin.placeholder_id].append(plugin)
|
||||
|
||||
copied = 0
|
||||
for placeholder in source_placeholders:
|
||||
if not only_empty or not target_plugin_counts.get(placeholder.pk):
|
||||
copied_plugins = copy_plugins_to_placeholder(
|
||||
plugins_by_placeholder[placeholder.pk],
|
||||
placeholder,
|
||||
language=target_language,
|
||||
plugins_are_downcast=True,
|
||||
)
|
||||
copied += len(copied_plugins)
|
||||
return copied
|
||||
|
||||
|
||||
def can_change_page(request):
|
||||
"""
|
||||
Check whether a user has the permission to change the page.
|
||||
|
||||
This will work across all permission-related setting, with a unified interface
|
||||
to permission checking.
|
||||
|
||||
:param request: The request object from which the user will be taken.
|
||||
:type request: :class:`HttpRequest` instance
|
||||
|
||||
"""
|
||||
from cms.utils import page_permissions
|
||||
|
||||
user = request.user
|
||||
current_page = request.current_page
|
||||
|
||||
if current_page:
|
||||
return page_permissions.user_can_change_page(user, current_page)
|
||||
|
||||
site = Site.objects.get_current(request)
|
||||
return page_permissions.user_can_change_all_pages(user, site)
|
||||
259
cms/app_base.py
Normal file
259
cms/app_base.py
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
from abc import ABCMeta, abstractmethod
|
||||
|
||||
|
||||
class CMSApp:
|
||||
"""Base class for creating apphooks. Apphooks live in a file called ``cms_apps.py``.
|
||||
To create an AppHook subclass ``CMSApp`` in ``cms_apps.py``
|
||||
::
|
||||
class MyAppHook(CMSApp):
|
||||
name = "Problem solver"
|
||||
"""
|
||||
#: list of urlconfs: example: ``_urls = ["myapp.urls"]``
|
||||
_urls = []
|
||||
#: list of menu classes: example: ``_menus = [MyAppMenu]``
|
||||
_menus = []
|
||||
#: Human-readable name of the apphook (required). This name will be displayed
|
||||
#: on the admin site.
|
||||
name = None
|
||||
#: Gives the system a unique way to refer to the apphook. This enables Django
|
||||
#: namespaces support (optional)
|
||||
app_name = None
|
||||
#: configuration model (optional)
|
||||
app_config = None
|
||||
#: if set to true, apphook inherits permissions from the current page
|
||||
permissions = True
|
||||
#: list of application names to exclude from inheriting CMS permissions
|
||||
exclude_permissions = []
|
||||
|
||||
def __new__(cls):
|
||||
"""
|
||||
We want to bind the CMSapp class to a specific AppHookConfig, but only one at a time
|
||||
Checking for the runtime attribute should be a sane fix
|
||||
"""
|
||||
if cls.app_config:
|
||||
if getattr(cls.app_config, 'cmsapp', None) and cls.app_config.cmsapp != cls:
|
||||
raise RuntimeError(
|
||||
'Only one AppHook per AppHookConfiguration must exists.\n'
|
||||
'AppHook %s already defined for %s AppHookConfig' % (
|
||||
cls.app_config.cmsapp.__name__, cls.app_config.__name__
|
||||
)
|
||||
)
|
||||
cls.app_config.cmsapp = cls
|
||||
return super().__new__(cls)
|
||||
|
||||
def get_configs(self):
|
||||
"""
|
||||
Returns all the apphook configuration instances.
|
||||
|
||||
To be implemented by apphook subclass.
|
||||
"""
|
||||
raise NotImplementedError('Configurable AppHooks must implement this method')
|
||||
|
||||
def get_config(self, namespace):
|
||||
"""
|
||||
Returns the apphook configuration instance linked to the given namespace
|
||||
|
||||
To be implemented by apphook subclass.
|
||||
"""
|
||||
raise NotImplementedError('Configurable AppHooks must implement this method')
|
||||
|
||||
def get_config_add_url(self):
|
||||
"""
|
||||
Returns the url to add a new apphook configuration instance
|
||||
(usually the model admin add view)
|
||||
|
||||
To be implemented by apphook subclass.
|
||||
"""
|
||||
raise NotImplementedError('Configurable AppHooks must implement this method')
|
||||
|
||||
def get_menus(self, page=None, language=None, **kwargs):
|
||||
"""
|
||||
Returns the menus for the apphook instance, eventually selected according
|
||||
to the given arguments.
|
||||
|
||||
By default, it returns the menus assigned to :py:attr:`CMSApp._menus`.
|
||||
|
||||
The method accepts page, language and generic keyword arguments:
|
||||
you can customize this function to return different list of menu classes
|
||||
according to the given arguments.
|
||||
|
||||
If no menus are returned, then the user will need to attach menus to pages
|
||||
manually in the admin.
|
||||
|
||||
If no page and language are provided, this method **must** return **all the
|
||||
menus used by this apphook**. Example::
|
||||
|
||||
if page and page.reverse_id == 'page1':
|
||||
return [Menu1]
|
||||
elif page and page.reverse_id == 'page2':
|
||||
return [Menu2]
|
||||
else:
|
||||
return [Menu1, Menu2]
|
||||
|
||||
:param page: page the apphook is attached to
|
||||
:param language: current site language
|
||||
:return: list of menu classes
|
||||
"""
|
||||
return self._menus
|
||||
|
||||
def get_urls(self, page=None, language=None, **kwargs):
|
||||
"""
|
||||
Returns the urlconfs for the apphook instance, eventually selected
|
||||
according to the given arguments.
|
||||
|
||||
By default, it returns the urls assigned to :py:attr:`CMSApp._urls`
|
||||
|
||||
The method accepts page, language and generic keyword arguments:
|
||||
you can customize this function to return different urlconfs
|
||||
according to the given arguments.
|
||||
|
||||
This method **must** return a non-empty list of urlconfs,
|
||||
even if no argument is passed.
|
||||
|
||||
:param page: page the apphook is attached to
|
||||
:param language: current site language
|
||||
:return: list of urlconfs strings
|
||||
"""
|
||||
return self._urls
|
||||
|
||||
def get_root_template(self, page=None, language=None, **kwargs):
|
||||
"""
|
||||
.. versionadded:: 5.1
|
||||
|
||||
Returns the template name used by the apphook's root view.
|
||||
|
||||
Best-effort: walks the urlconfs from :meth:`get_urls` to locate the
|
||||
pattern matching the empty path and returns ``template_name`` from its
|
||||
view class (CBVs) or callback (FBVs that expose it). Returns ``None``
|
||||
for views that compute the template dynamically — override this method
|
||||
to return the template name in that case.
|
||||
|
||||
:return: template name or None
|
||||
"""
|
||||
from django.urls import URLPattern, URLResolver, get_resolver
|
||||
|
||||
def find_root(patterns):
|
||||
for p in patterns:
|
||||
if p.pattern.match('') is None:
|
||||
continue
|
||||
if isinstance(p, URLResolver):
|
||||
inner = find_root(p.url_patterns)
|
||||
if inner is not None:
|
||||
return inner
|
||||
elif isinstance(p, URLPattern):
|
||||
return p
|
||||
return None
|
||||
|
||||
for urlconf in self.get_urls(page=page, language=language, **kwargs):
|
||||
try:
|
||||
resolver = get_resolver(urlconf)
|
||||
pattern = find_root(resolver.url_patterns)
|
||||
except Exception:
|
||||
continue
|
||||
if pattern is None:
|
||||
continue
|
||||
callback = pattern.callback
|
||||
view_class = getattr(callback, 'view_class', None)
|
||||
view_initkwargs = getattr(callback, 'view_initkwargs', None) or {}
|
||||
return (
|
||||
view_initkwargs.get('template_name')
|
||||
or getattr(view_class, 'template_name', None)
|
||||
or getattr(callback, 'template_name', None)
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
class CMSAppConfig:
|
||||
"""
|
||||
.. versionadded:: 4.0
|
||||
|
||||
Base class that all cms app configurations should inherit from.
|
||||
|
||||
CMSAppConfig live in a file called ``cms_config.py``.
|
||||
|
||||
Apps subclassing ``CMSAppConfig`` can set ``cms_enabled = True`` for their app config to
|
||||
use django CMS' wizard functionality. Additional wizzwards are listed in the app config's
|
||||
``cms_wizzards`` property.
|
||||
|
||||
The second functionality that django CMS offers is attaching Model objects to the toolbar. To use
|
||||
this functionality, set list the Model classes in ``cms_toolbar_enabled_models`` and have
|
||||
``cms_enabled = True``
|
||||
"""
|
||||
|
||||
def __init__(self, django_app_config):
|
||||
self.app_config = django_app_config
|
||||
|
||||
@staticmethod
|
||||
def get_contract(contract_name: str) -> type:
|
||||
"""
|
||||
Retrieve a contract implementation from registered CMS extension apps.
|
||||
|
||||
Searches through all registered CMS extension applications for a contract
|
||||
that matches the given contract name. A contract is a named interface that
|
||||
allows apps to provide functionality that other apps can use.
|
||||
|
||||
This method enables an architecture where apps can query for
|
||||
specific contracts and use the implementation provided by any registered
|
||||
extension app that exports that contract.
|
||||
|
||||
:param contract_name: The name identifier of the contract to retrieve
|
||||
:type contract_name: str
|
||||
:return: The contract class if found, or None if no matching contract exists
|
||||
:rtype: type or None
|
||||
:raises: None
|
||||
|
||||
Example::
|
||||
|
||||
# Get a versioning contract implementation
|
||||
versioning_contract = CMSAppConfig.get_contract('versioning')
|
||||
if versioning_contract:
|
||||
# Use the versioning functionality
|
||||
version = versioning_contract.create_version(obj)
|
||||
"""
|
||||
from cms.app_registration import get_cms_extension_apps
|
||||
|
||||
for app in get_cms_extension_apps():
|
||||
name, cls = getattr(app.cms_extension, "contract", (None, None))
|
||||
if name == contract_name:
|
||||
return cls
|
||||
return None
|
||||
|
||||
|
||||
class CMSAppExtension(metaclass=ABCMeta):
|
||||
"""
|
||||
.. versionadded:: 4.0
|
||||
|
||||
Base class that all cms app extensions should inherit from. App extensions allow
|
||||
apps to offer their functionality to other apps, e.g., as done by djangocms-versioning.
|
||||
|
||||
CMSAppExtensions live in a file called ``cms_config.py``.
|
||||
"""
|
||||
|
||||
@abstractmethod
|
||||
def configure_app(self, cms_config):
|
||||
"""
|
||||
Implement this method if the app provides functionality that
|
||||
other apps can use and configure.
|
||||
|
||||
This method will be run once for every app that defines an
|
||||
attribute like ``<app_label>_enabled`` as ``True`` on its cms app
|
||||
config class.
|
||||
|
||||
So for example, if app A with label "app_a" implements this
|
||||
method and app B and app C define ``app_a_enabled = True`` on their
|
||||
cms config classes, the method app A has defined will run twice,
|
||||
once for app B and once for app C.
|
||||
|
||||
:param cms_config: the cms config class of the app registering for additional functionality
|
||||
:type cms_config: :class:`CMSAppConfig` subclass
|
||||
"""
|
||||
pass
|
||||
|
||||
def ready(self):
|
||||
"""Override this method to run code after all CMS extensions
|
||||
have been configured.
|
||||
|
||||
This method will be run once, even if no cms app config sets
|
||||
its ``<app_label>_enabled`` attribute to ``True``"""
|
||||
pass
|
||||
203
cms/app_registration.py
Normal file
203
cms/app_registration.py
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
import inspect
|
||||
from functools import cache
|
||||
from importlib import import_module
|
||||
|
||||
from django.apps import apps
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.utils.module_loading import module_has_submodule
|
||||
|
||||
from cms.app_base import CMSAppConfig, CMSAppExtension
|
||||
from cms.constants import CMS_CONFIG_NAME
|
||||
|
||||
|
||||
def _find_subclasses(module, klass):
|
||||
"""
|
||||
Helper function.
|
||||
|
||||
Returns a list of classes in module which inherit from klass.
|
||||
"""
|
||||
classes = []
|
||||
# Find all classes that inherit from klass
|
||||
for name, obj in inspect.getmembers(module):
|
||||
is_subclass = (
|
||||
# Ignore the import of klass itself
|
||||
inspect.isclass(obj) and issubclass(obj, klass) and obj != klass
|
||||
)
|
||||
if is_subclass:
|
||||
classes.append(obj)
|
||||
return classes
|
||||
|
||||
|
||||
def _find_config(cms_module):
|
||||
"""
|
||||
Helper function.
|
||||
|
||||
Returns the class inheriting from CMSAppConfig in the given module.
|
||||
If no such class exists in the module, returns None.
|
||||
If multiple classes inherit from CMSAppConfig, raises
|
||||
ImproperlyConfigured exception.
|
||||
"""
|
||||
cms_config_classes = _find_subclasses(cms_module, CMSAppConfig)
|
||||
if len(cms_config_classes) == 1:
|
||||
return cms_config_classes[0]
|
||||
elif len(cms_config_classes) > 1:
|
||||
raise ImproperlyConfigured(
|
||||
"cms_config.py files can't define more than one "
|
||||
"class which inherits from CMSAppConfig")
|
||||
|
||||
|
||||
def _find_extension(cms_module):
|
||||
"""
|
||||
Helper function.
|
||||
|
||||
Returns the class inheriting from CMSAppExtension in the given module.
|
||||
If no such class exists in the module, returns None.
|
||||
If multiple classes inherit from CMSAppExtension, raises
|
||||
ImproperlyConfigured exception.
|
||||
"""
|
||||
cms_extension_classes = _find_subclasses(cms_module, CMSAppExtension)
|
||||
if len(cms_extension_classes) == 1:
|
||||
return cms_extension_classes[0]
|
||||
elif len(cms_extension_classes) > 1:
|
||||
raise ImproperlyConfigured(
|
||||
"cms_config.py files can't define more than one "
|
||||
"class which inherits from CMSAppExtension")
|
||||
|
||||
|
||||
def autodiscover_cms_configs():
|
||||
"""
|
||||
Find and import all cms_config.py files. Add a cms_app attribute
|
||||
to django's app config with an instance of the cms config.
|
||||
|
||||
This runs in two passes: all CMSAppExtension instances are attached
|
||||
first, then the CMSAppConfig objects are instantiated. A config's
|
||||
``__init__`` may query registered extensions via
|
||||
``CMSAppConfig.get_contract()`` (which caches the result of
|
||||
``get_cms_extension_apps()``), so every extension must be registered
|
||||
before any config is instantiated. Otherwise the order of apps in
|
||||
INSTALLED_APPS could cause a config to cache an incomplete extension
|
||||
list (e.g. djangocms_alias not finding djangocms_versioning).
|
||||
"""
|
||||
discovered = []
|
||||
# First pass: import every cms_config module and attach the extensions.
|
||||
for app_config in apps.get_app_configs():
|
||||
try:
|
||||
cms_module = import_module(
|
||||
f'{app_config.name}.{CMS_CONFIG_NAME}')
|
||||
except: # NOQA
|
||||
# If something in cms_config.py raises an exception let that
|
||||
# exception bubble up. Only catch the exception if
|
||||
# cms_config.py doesn't exist
|
||||
if module_has_submodule(app_config.module, CMS_CONFIG_NAME):
|
||||
raise
|
||||
else:
|
||||
config = _find_config(cms_module)
|
||||
extension = _find_extension(cms_module)
|
||||
if not config and not extension:
|
||||
raise ImproperlyConfigured(
|
||||
f"The cms_config.py file of the '{app_config.label}' app "
|
||||
"must define at least one class which inherits from "
|
||||
"CMSAppConfig or CMSAppExtension")
|
||||
# We are adding these attributes here rather than in
|
||||
# django's app config definition because there are
|
||||
# all kinds of limitations as to what can be imported
|
||||
# in django's apps.py and leaving it to devs to define this
|
||||
# there could cause issues
|
||||
if extension:
|
||||
app_config.cms_extension = extension()
|
||||
discovered.append((app_config, config))
|
||||
|
||||
# All extensions are now attached; drop any extension lookup that was
|
||||
# cached before every extension was registered, so the configs below
|
||||
# see the complete list.
|
||||
get_cms_extension_apps.cache_clear()
|
||||
|
||||
# Second pass: instantiate the configs, which may now safely look up
|
||||
# any registered extension via get_contract().
|
||||
for app_config, config in discovered:
|
||||
if config:
|
||||
app_config.cms_config = config(app_config)
|
||||
|
||||
|
||||
@cache
|
||||
def get_cms_extension_apps():
|
||||
"""
|
||||
Returns django app configs of apps with a cms extension
|
||||
"""
|
||||
# NOTE: The cms_extension attr is added by the autodiscover_cms_configs
|
||||
# function if a cms_config.py file with a suitable class is found.
|
||||
cms_apps = [app_config for app_config in apps.get_app_configs()
|
||||
if hasattr(app_config, 'cms_extension')]
|
||||
return cms_apps
|
||||
|
||||
|
||||
@cache
|
||||
def get_cms_config_apps():
|
||||
"""
|
||||
Returns django app configs of apps with a cms config
|
||||
"""
|
||||
# NOTE: The cms_config attr is added by the autodiscover_cms_configs
|
||||
# function if a cms_config.py file with a suitable class is found.
|
||||
cms_apps = [app_config for app_config in apps.get_app_configs()
|
||||
if hasattr(app_config, 'cms_config')]
|
||||
return cms_apps
|
||||
|
||||
|
||||
def configure_cms_apps(apps_with_features):
|
||||
"""
|
||||
Check installed apps for apps that are configured to use cms addons
|
||||
and run code to register them with their config
|
||||
"""
|
||||
for app_with_feature in apps_with_features:
|
||||
contract = getattr(app_with_feature.cms_extension, "contract", None)
|
||||
if contract is None:
|
||||
contract_label = app_with_feature.label # default contract name
|
||||
elif isinstance(contract, (tuple, list)) and len(contract) == 2:
|
||||
contract_label = contract[0]
|
||||
else:
|
||||
raise ImproperlyConfigured("CMSExtension contract property needs to be a 2-tuple consisting of the form (contract_name, contract_object)")
|
||||
enabled_property = f"{contract_label}_enabled"
|
||||
configure_app = app_with_feature.cms_extension.configure_app
|
||||
|
||||
for app_config in get_cms_config_apps():
|
||||
if getattr(app_config.cms_config, enabled_property, False):
|
||||
# Feature enabled for this app so configure
|
||||
configure_app(app_config.cms_config)
|
||||
|
||||
|
||||
def ready_cms_apps(apps_with_features):
|
||||
"""Run ready() methods on every registered cms extension,
|
||||
so that final checks can happen after all apps have been configured
|
||||
"""
|
||||
for app_with_feature in apps_with_features:
|
||||
app_with_feature.cms_extension.ready()
|
||||
|
||||
|
||||
# TODO: Remove this function once backwards compatibility is deprecated
|
||||
def backwards_compatibility_config():
|
||||
"""
|
||||
Ensure that old ways of configuring and setting up things (plugin
|
||||
pools etc.) still work.
|
||||
|
||||
NOTE: The autodiscover code has been copied over from
|
||||
django.utils.module_loading.autodiscover_modules
|
||||
This is because django's autodiscover function imports
|
||||
django.apps.apps from within the body of the function, which
|
||||
interferes with backwards compatibility testing. The testing is
|
||||
non-trivial and requires the patching of django.apps.apps (which
|
||||
is impossible when imports are done inside of functions).
|
||||
"""
|
||||
# The old pools defined various discover methods that looked for
|
||||
# specific files and ran the register code in them. Listing the
|
||||
# names of the files here.
|
||||
modules_to_autodiscover = ['cms_wizards']
|
||||
for module in modules_to_autodiscover:
|
||||
for app_config in apps.get_app_configs():
|
||||
try:
|
||||
import_module(f'{app_config.name}.{module}')
|
||||
except Exception: # NOQA
|
||||
# Decide whether to bubble up this error. If the app just
|
||||
# doesn't have the module in question, we can ignore the error
|
||||
# attempting to import it, otherwise we want it to bubble up.
|
||||
if module_has_submodule(app_config.module, module):
|
||||
raise
|
||||
102
cms/apphook_pool.py
Normal file
102
cms/apphook_pool.py
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import warnings
|
||||
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.utils.module_loading import autodiscover_modules, import_string
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from cms.app_base import CMSApp
|
||||
from cms.exceptions import AppAlreadyRegistered
|
||||
from cms.utils.conf import get_cms_setting
|
||||
|
||||
|
||||
class ApphookPool:
|
||||
|
||||
def __init__(self):
|
||||
self.apphooks = []
|
||||
self.apps = {}
|
||||
self.discovered = False
|
||||
|
||||
def clear(self):
|
||||
# TODO: remove this method, it's Python, we don't need it.
|
||||
self.apphooks = []
|
||||
self.apps = {}
|
||||
self.discovered = False
|
||||
|
||||
def register(self, app=None, discovering_apps=False):
|
||||
# allow use as a decorator
|
||||
if app is None:
|
||||
return lambda app: self.register(app, discovering_apps)
|
||||
|
||||
if self.apphooks and not discovering_apps:
|
||||
return app
|
||||
|
||||
if app.__name__ in self.apps:
|
||||
raise AppAlreadyRegistered(
|
||||
'A CMS application %r is already registered' % app.__name__
|
||||
)
|
||||
|
||||
if not issubclass(app, CMSApp):
|
||||
raise ImproperlyConfigured(
|
||||
'CMS application must inherit from cms.app_base.CMSApp, but %r does not' % app.__name__
|
||||
)
|
||||
|
||||
if not hasattr(app, 'menus') and hasattr(app, 'menu'):
|
||||
warnings.warn(
|
||||
"You define a 'menu' attribute on CMS application %r, but the 'menus' attribute is empty, "
|
||||
"did you make a typo?" % app.__name__
|
||||
)
|
||||
|
||||
self.apps[app.__name__] = app()
|
||||
return app
|
||||
|
||||
def discover_apps(self):
|
||||
self.apphooks = get_cms_setting('APPHOOKS')
|
||||
|
||||
if self.apphooks:
|
||||
for path in self.apphooks:
|
||||
cls = import_string(path)
|
||||
try:
|
||||
self.register(cls, discovering_apps=True)
|
||||
except AppAlreadyRegistered:
|
||||
pass
|
||||
|
||||
else:
|
||||
autodiscover_modules('cms_apps')
|
||||
|
||||
self.discovered = True
|
||||
|
||||
def get_apphooks(self):
|
||||
hooks = []
|
||||
|
||||
if not self.discovered:
|
||||
self.discover_apps()
|
||||
|
||||
for app_name in self.apps:
|
||||
app = self.apps[app_name]
|
||||
if app.get_urls():
|
||||
hooks.append((app_name, app.name or app_name))
|
||||
|
||||
# Unfortunately, we lose the ordering since we now have a list of
|
||||
# tuples. Let's reorder by app_name:
|
||||
hooks = sorted(hooks, key=lambda hook: hook[1])
|
||||
|
||||
return hooks
|
||||
|
||||
def get_apphook(self, app_name):
|
||||
if not self.discovered:
|
||||
self.discover_apps()
|
||||
|
||||
try:
|
||||
return self.apps[app_name]
|
||||
except KeyError:
|
||||
# deprecated: return apphooks registered in db with urlconf name
|
||||
# instead of apphook class name
|
||||
for app in self.apps.values():
|
||||
if app_name in app.get_urls():
|
||||
return app
|
||||
|
||||
warnings.warn(_('No registered apphook "%r" found') % app_name)
|
||||
return None
|
||||
|
||||
|
||||
apphook_pool = ApphookPool()
|
||||
295
cms/appresolver.py
Normal file
295
cms/appresolver.py
Normal file
|
|
@ -0,0 +1,295 @@
|
|||
from collections import OrderedDict
|
||||
from importlib import import_module
|
||||
|
||||
from django.contrib.sites.models import Site
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
from django.db import OperationalError, ProgrammingError
|
||||
from django.urls import NoReverseMatch, Resolver404, URLResolver, reverse
|
||||
from django.urls.resolvers import RegexPattern, URLPattern
|
||||
from django.utils.translation import get_language, override
|
||||
|
||||
from cms.apphook_pool import apphook_pool
|
||||
from cms.models.pagemodel import Page
|
||||
from cms.utils import get_current_site
|
||||
from cms.utils.i18n import get_language_list
|
||||
|
||||
APP_RESOLVERS = []
|
||||
|
||||
|
||||
def clear_app_resolvers():
|
||||
global APP_RESOLVERS
|
||||
APP_RESOLVERS = []
|
||||
|
||||
|
||||
def applications_page_check(request):
|
||||
"""Tries to find if given path was resolved over application.
|
||||
Applications have higher priority than other cms pages.
|
||||
"""
|
||||
# We should get in this branch only if an apphook is active on /
|
||||
# This removes the non-CMS part of the URL.
|
||||
try:
|
||||
path = request.path_info.replace(reverse('pages-root'), '', 1)
|
||||
except NoReverseMatch:
|
||||
path = request.path_info
|
||||
|
||||
# check if application resolver can resolve this
|
||||
site = get_current_site(request)
|
||||
for lang in get_language_list(site_id=site.pk):
|
||||
if path.startswith(lang + "/"):
|
||||
path = path[len(lang + "/"):]
|
||||
|
||||
for resolver in APP_RESOLVERS:
|
||||
try:
|
||||
page_id = resolver.resolve_page_id(path)
|
||||
return Page.objects.get(id=page_id)
|
||||
except Resolver404:
|
||||
# Raised if the page is not managed by an apphook
|
||||
pass
|
||||
except Page.DoesNotExist:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
class AppRegexURLResolver(URLResolver):
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.page_id = None
|
||||
self.url_patterns_dict = {}
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def urlconf_module(self):
|
||||
# It is valid for urlconf_module to be a list of patterns. So we just
|
||||
# return the list here.
|
||||
#
|
||||
# See https://github.com/django/django/blob/2.2.4/django/urls/resolvers.py#L578
|
||||
#
|
||||
return self.url_patterns_dict.get(get_language(), [])
|
||||
|
||||
# On URLResolver the url_patterns property is cached and thus calls made after
|
||||
# language changes (different return values for get_language()) would not return the
|
||||
# right value. Overriding here prevents caching.
|
||||
@property
|
||||
def url_patterns(self):
|
||||
return self.urlconf_module
|
||||
|
||||
def resolve_page_id(self, path):
|
||||
"""Resolves requested path similar way how resolve does, but instead
|
||||
of return callback,.. returns page_id to which was application
|
||||
assigned.
|
||||
"""
|
||||
tried = []
|
||||
pattern = getattr(self, 'pattern', self)
|
||||
match = pattern.regex.search(path)
|
||||
if match:
|
||||
new_path = path[match.end():]
|
||||
for pattern in self.url_patterns:
|
||||
if isinstance(pattern, AppRegexURLResolver):
|
||||
try:
|
||||
return pattern.resolve_page_id(new_path)
|
||||
except Resolver404:
|
||||
pass
|
||||
else:
|
||||
try:
|
||||
sub_match = pattern.resolve(new_path)
|
||||
except Resolver404 as e:
|
||||
tried_match = e.args[0].get('tried')
|
||||
if tried_match is not None:
|
||||
tried.extend([[pattern] + t for t in tried_match])
|
||||
else:
|
||||
tried.extend([pattern])
|
||||
else:
|
||||
if sub_match:
|
||||
return getattr(pattern, 'page_id', None)
|
||||
pattern = getattr(pattern, 'pattern', pattern)
|
||||
tried.append(pattern.regex.pattern)
|
||||
raise Resolver404({'tried': tried, 'path': new_path})
|
||||
|
||||
|
||||
def recurse_patterns(path, pattern_list, page_id, default_args=None,
|
||||
nested=False):
|
||||
"""
|
||||
Recurse over a list of to-be-hooked patterns for a given path prefix
|
||||
"""
|
||||
newpatterns = []
|
||||
for pattern in pattern_list:
|
||||
app_pat = getattr(pattern, 'pattern', pattern).regex.pattern
|
||||
# make sure we don't get patterns that start with more than one '^'!
|
||||
app_pat = app_pat.lstrip('^')
|
||||
path = path.lstrip('^')
|
||||
regex = rf'^{path}{app_pat}' if not nested else r'^%s' % (app_pat)
|
||||
if isinstance(pattern, URLResolver):
|
||||
# include default_args
|
||||
args = pattern.default_kwargs
|
||||
if default_args:
|
||||
args.update(default_args)
|
||||
# see lines 243 and 236 of urlresolvers.py to understand the next line
|
||||
urlconf_module = recurse_patterns(regex, pattern.url_patterns, page_id, args, nested=True)
|
||||
# this is an 'include', recurse!
|
||||
regex_pattern = regex
|
||||
regex_pattern = RegexPattern(regex)
|
||||
resolver = URLResolver(regex_pattern, urlconf_module,
|
||||
pattern.default_kwargs, pattern.app_name,
|
||||
pattern.namespace)
|
||||
else:
|
||||
# Re-do the URLPattern with the new regular expression
|
||||
args = pattern.default_args
|
||||
if default_args:
|
||||
args.update(default_args)
|
||||
|
||||
regex_pattern = RegexPattern(regex, name=pattern.name, is_endpoint=True)
|
||||
resolver = URLPattern(regex_pattern, pattern.callback, args,
|
||||
pattern.name)
|
||||
resolver.page_id = page_id
|
||||
newpatterns.append(resolver)
|
||||
return newpatterns
|
||||
|
||||
|
||||
def _set_permissions(patterns, exclude_permissions):
|
||||
for pattern in patterns:
|
||||
if isinstance(pattern, URLResolver):
|
||||
if pattern.namespace in exclude_permissions:
|
||||
continue
|
||||
_set_permissions(pattern.url_patterns, exclude_permissions)
|
||||
else:
|
||||
from cms.utils.decorators import cms_perms
|
||||
pattern.callback = cms_perms(pattern.callback)
|
||||
|
||||
|
||||
def _set_site_filter(patterns, site_id):
|
||||
for pattern in patterns:
|
||||
if isinstance(pattern, URLResolver):
|
||||
_set_site_filter(pattern.url_patterns, site_id)
|
||||
else:
|
||||
from cms.utils.decorators import cms_site_filter
|
||||
pattern.callback = cms_site_filter(site_id)(pattern.callback)
|
||||
|
||||
|
||||
def get_app_urls(urls):
|
||||
for urlconf in urls:
|
||||
if isinstance(urlconf, str):
|
||||
mod = import_module(urlconf)
|
||||
if not hasattr(mod, 'urlpatterns'):
|
||||
raise ImproperlyConfigured(
|
||||
"URLConf `%s` has no urlpatterns attribute" % urlconf)
|
||||
yield mod.urlpatterns
|
||||
elif isinstance(urlconf, (list, tuple)):
|
||||
yield urlconf
|
||||
else:
|
||||
yield [urlconf]
|
||||
|
||||
|
||||
def get_patterns_for_page_url(page_url):
|
||||
"""
|
||||
Resolve the urlconf module for a path+title combination
|
||||
Returns a list of url objects.
|
||||
"""
|
||||
path = page_url.path
|
||||
|
||||
if path and not path.endswith('/'):
|
||||
path += '/'
|
||||
|
||||
app = apphook_pool.get_apphook(page_url.page.application_urls)
|
||||
|
||||
url_patterns = []
|
||||
for pattern_list in get_app_urls(app.get_urls(page_url.page, page_url.language)):
|
||||
url_patterns += recurse_patterns(path, pattern_list, page_url.page_id)
|
||||
return url_patterns
|
||||
|
||||
|
||||
def get_app_patterns():
|
||||
try:
|
||||
try:
|
||||
site = Site.objects.get_current()
|
||||
return _get_app_patterns(site)
|
||||
except ImproperlyConfigured:
|
||||
# Site framework not configured with SITE_ID, fallback to
|
||||
# request-based site resolution (not possible here), so just
|
||||
# add the apphook patterns with a site filter.
|
||||
return _get_app_patterns(None) # site-independent mode
|
||||
except (OperationalError, ProgrammingError,):
|
||||
# ignore if DB is not ready
|
||||
# Starting with Django 1.9 this code gets called even when creating
|
||||
# or running migrations. So in many cases the DB will not be ready yet.
|
||||
return []
|
||||
|
||||
|
||||
def _get_app_patterns(site: Site | None) -> list[URLPattern]:
|
||||
"""
|
||||
Get a list of patterns for all hooked apps.
|
||||
|
||||
How this works:
|
||||
|
||||
By looking through all titles with an app hook (application_urls) we find
|
||||
all urlconf modules we have to hook into titles.
|
||||
|
||||
If we use the ML URL Middleware, we namespace those patterns with the title
|
||||
language.
|
||||
|
||||
All 'normal' patterns from the urlconf get re-written by prefixing them with
|
||||
the title path and then included into the cms url patterns.
|
||||
|
||||
If the app is still configured, but is no longer installed/available, then
|
||||
this method returns a degenerate patterns object: patterns('')
|
||||
"""
|
||||
from cms.models.pagemodel import PageUrl
|
||||
|
||||
included = []
|
||||
hooked_applications = OrderedDict()
|
||||
|
||||
# we don't have a request here so get_page_queryset() can't be used,
|
||||
# so use public() queryset.
|
||||
# This can be done because url patterns are used just in frontend
|
||||
page_urls = PageUrl.objects.get_for_site(site) if site else PageUrl.objects.all()
|
||||
|
||||
# Loop over all titles with an application hooked to them
|
||||
page_urls = (
|
||||
page_urls
|
||||
.exclude(path__isnull=True)
|
||||
.exclude(page__application_urls=None)
|
||||
.exclude(page__application_urls='')
|
||||
# Page types are blueprints for new pages, not content. Their apphook
|
||||
# would otherwise be dispatched before the catch-all ``cms.views.details``
|
||||
# route, serving the page type on the public site.
|
||||
.exclude(page__is_page_type=True)
|
||||
.order_by('-page__path')
|
||||
.select_related('page')
|
||||
)
|
||||
|
||||
for page_url in page_urls:
|
||||
mix_id = "{}:{}:{}".format(
|
||||
page_url.path + "/",
|
||||
page_url.page.application_urls,
|
||||
page_url.language,
|
||||
)
|
||||
|
||||
if mix_id in included:
|
||||
# don't add the same thing twice
|
||||
continue
|
||||
app = apphook_pool.get_apphook(page_url.page.application_urls)
|
||||
if not app:
|
||||
continue
|
||||
if page_url.page not in hooked_applications:
|
||||
hooked_applications[page_url.page] = {}
|
||||
app_ns = app.app_name, page_url.page.application_namespace
|
||||
with override(page_url.language):
|
||||
hooked_applications[page_url.page][page_url.language] = (
|
||||
app_ns, get_patterns_for_page_url(page_url), app)
|
||||
included.append(mix_id)
|
||||
# Build the app patterns to be included in the cms urlconfs
|
||||
app_patterns = []
|
||||
for page, languages in hooked_applications.items():
|
||||
resolver = None
|
||||
for lang, ((app_ns, inst_ns), current_patterns, app) in languages.items():
|
||||
if not resolver:
|
||||
regex_pattern = RegexPattern(r'')
|
||||
resolver = AppRegexURLResolver(
|
||||
regex_pattern, 'app_resolver', app_name=app_ns, namespace=inst_ns)
|
||||
resolver.page_id = page.pk
|
||||
if app.permissions:
|
||||
_set_permissions(current_patterns, app.exclude_permissions)
|
||||
if site is None:
|
||||
_set_site_filter(current_patterns, page.site_id)
|
||||
resolver.url_patterns_dict[lang] = current_patterns
|
||||
app_patterns.append(resolver)
|
||||
APP_RESOLVERS.append(resolver)
|
||||
return app_patterns
|
||||
14
cms/apps.py
Normal file
14
cms/apps.py
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
from django.apps import AppConfig
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
|
||||
class CMSConfig(AppConfig):
|
||||
name = 'cms'
|
||||
verbose_name = _("django CMS")
|
||||
default_auto_field = 'django.db.models.AutoField'
|
||||
|
||||
def ready(self):
|
||||
from cms.utils.setup import setup, setup_cms_apps
|
||||
|
||||
setup()
|
||||
setup_cms_apps()
|
||||
87
cms/cache/__init__.py
vendored
Normal file
87
cms/cache/__init__.py
vendored
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import re
|
||||
|
||||
from cms.utils.conf import get_cms_setting
|
||||
|
||||
CMS_PAGE_CACHE_VERSION_KEY = get_cms_setting("CACHE_PREFIX") + '_PAGE_CACHE_VERSION'
|
||||
|
||||
|
||||
def _get_cache_version():
|
||||
"""
|
||||
Returns the current page cache version, explicitly setting one if not
|
||||
defined.
|
||||
"""
|
||||
from django.core.cache import cache
|
||||
|
||||
version = cache.get(CMS_PAGE_CACHE_VERSION_KEY)
|
||||
|
||||
if version:
|
||||
return version
|
||||
else:
|
||||
_set_cache_version(1)
|
||||
return 1
|
||||
|
||||
|
||||
def _set_cache_version(version):
|
||||
"""
|
||||
Set the cache version to the specified value.
|
||||
"""
|
||||
from django.core.cache import cache
|
||||
|
||||
cache.set(
|
||||
CMS_PAGE_CACHE_VERSION_KEY,
|
||||
version,
|
||||
get_cms_setting('CACHE_DURATIONS')['content']
|
||||
)
|
||||
|
||||
|
||||
def invalidate_cms_page_cache():
|
||||
"""
|
||||
Invalidates the CMS PAGE CACHE.
|
||||
"""
|
||||
|
||||
#
|
||||
# NOTE: We're using a cache versioning strategy for invalidating the page
|
||||
# cache when necessary. Instead of wiping all the old entries, we simply
|
||||
# increment the version number rendering all previous entries
|
||||
# inaccessible and left to expire naturally.
|
||||
#
|
||||
# ALSO NOTE: According to the Django documentation, a timeout value of
|
||||
# `None' (in version 1.6+) is supposed to mean "cache forever", however,
|
||||
# this is actually only implemented as only slightly less than 30 days in
|
||||
# some backends (memcached, in particular). In older Djangos, `None' means
|
||||
# "use default value". To avoid issues arising from different Django
|
||||
# versions and cache backend implementations, we will explicitly set the
|
||||
# lifespan of the CMS_PAGE_CACHE_VERSION entry to whatever is set in
|
||||
# settings.CACHE_DURATIONS['content']. This allows users to adjust as
|
||||
# necessary for their backend.
|
||||
#
|
||||
# To prevent writing cache entries that will live longer than our version
|
||||
# key, we will always re-write the current version number into the cache
|
||||
# just after we write any new cache entries, thus ensuring that the
|
||||
# version number will always outlive any entries written against that
|
||||
# version. This is a cheap operation.
|
||||
#
|
||||
# If there are no new cache writes before the version key expires, its
|
||||
# perfectly OK, since any previous entries cached against that version
|
||||
# will have also expired, so, it'd be pointless to try to access them
|
||||
# anyway.
|
||||
#
|
||||
version = _get_cache_version()
|
||||
_set_cache_version(version + 1)
|
||||
|
||||
|
||||
CLEAN_KEY_PATTERN = re.compile(r'[^a-zA-Z0-9_-]')
|
||||
|
||||
|
||||
def _clean_key(key):
|
||||
return CLEAN_KEY_PATTERN.sub('-', key)
|
||||
|
||||
|
||||
def _get_cache_key(name, page_lookup, lang, site_id):
|
||||
from cms.models import Page
|
||||
if isinstance(page_lookup, Page):
|
||||
page_key = str(page_lookup.pk)
|
||||
else:
|
||||
page_key = str(page_lookup)
|
||||
page_key = _clean_key(page_key)
|
||||
return f'{get_cms_setting("CACHE_PREFIX")}{name}__page_lookup:{page_key}_site:{str(site_id)}_lang:{str(lang)}'
|
||||
30
cms/cache/choices.py
vendored
Normal file
30
cms/cache/choices.py
vendored
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
from django.conf import settings
|
||||
|
||||
from cms.utils.conf import get_cms_setting
|
||||
|
||||
|
||||
def _site_cache_key(lang):
|
||||
return "{}-{}".format(get_cms_setting('SITE_CHOICES_CACHE_KEY'), lang)
|
||||
|
||||
|
||||
def _page_cache_key(lang):
|
||||
return "{}-{}".format(get_cms_setting('PAGE_CHOICES_CACHE_KEY'), lang)
|
||||
|
||||
|
||||
def _clean_many(prefix):
|
||||
from django.core.cache import cache
|
||||
keys = []
|
||||
if settings.USE_I18N:
|
||||
for lang in [language[0] for language in settings.LANGUAGES]:
|
||||
keys.append(f"{prefix}-{lang}")
|
||||
else:
|
||||
keys = [f"{prefix}-{settings.LANGUAGE_CODE}"]
|
||||
cache.delete_many(keys)
|
||||
|
||||
|
||||
def clean_site_choices_cache(sender, **kwargs):
|
||||
_clean_many(get_cms_setting('SITE_CHOICES_CACHE_KEY'))
|
||||
|
||||
|
||||
def clean_page_choices_cache(sender, **kwargs):
|
||||
_clean_many(get_cms_setting('PAGE_CHOICES_CACHE_KEY'))
|
||||
272
cms/cache/page.py
vendored
Normal file
272
cms/cache/page.py
vendored
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import hashlib
|
||||
from collections.abc import Mapping
|
||||
from datetime import timedelta
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from django.conf import settings
|
||||
from django.utils.cache import (
|
||||
add_never_cache_headers,
|
||||
patch_response_headers,
|
||||
patch_vary_headers,
|
||||
)
|
||||
from django.utils.encoding import iri_to_uri
|
||||
from django.utils.timezone import now
|
||||
|
||||
from cms.cache import _get_cache_key, _get_cache_version, _set_cache_version
|
||||
from cms.constants import EXPIRE_NOW, MAX_EXPIRATION_TTL
|
||||
from cms.toolbar.utils import get_toolbar_from_request
|
||||
from cms.utils import get_current_site
|
||||
from cms.utils.compat.response import get_response_headers
|
||||
from cms.utils.conf import get_cms_setting
|
||||
from cms.utils.helpers import get_timezone_name
|
||||
from cms.utils.i18n import get_default_language_for_site
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterable
|
||||
|
||||
from django.http import HttpRequest, HttpResponse
|
||||
|
||||
from cms.models import Page
|
||||
|
||||
|
||||
def _page_cache_key(request: HttpRequest, vary_on: Iterable[str] | None = None) -> str:
|
||||
"""
|
||||
Generate a cache key based on the request path and language.
|
||||
The language is determined following django-cms's language resolution order.
|
||||
|
||||
``vary_on`` is an optional iterable of header names declared by plugins via
|
||||
``get_vary_cache_on()``. The request's values for those headers are always
|
||||
folded into the key so that responses varying on those headers are cached
|
||||
separately per value (instead of the first variant being served to
|
||||
everyone). A missing (``None``) or empty ``vary_on`` hashes to a constant.
|
||||
"""
|
||||
site = get_current_site(request)
|
||||
if hasattr(request, "LANGUAGE_CODE"):
|
||||
language = request.LANGUAGE_CODE
|
||||
else:
|
||||
language = get_default_language_for_site(site.pk)
|
||||
|
||||
cache_key = "%s:%d:%s:%s" % (
|
||||
get_cms_setting("CACHE_PREFIX"),
|
||||
site.pk,
|
||||
language,
|
||||
hashlib.sha1(iri_to_uri(request.get_full_path()).encode("utf-8")).hexdigest(),
|
||||
)
|
||||
if settings.USE_TZ:
|
||||
cache_key += ".%s" % get_timezone_name()
|
||||
cache_key += ".%s" % _vary_on_hash(request, vary_on or [])
|
||||
return cache_key
|
||||
|
||||
|
||||
def _vary_on_hash(request: HttpRequest, vary_on: Iterable[str]) -> str:
|
||||
"""Hash of the request's values for the given (plugin-declared) headers."""
|
||||
ctx = hashlib.sha1()
|
||||
for header in sorted(header.lower() for header in vary_on):
|
||||
# Mirror Django's translation of header names to ``request.META`` keys.
|
||||
meta_key = "HTTP_" + header.upper().replace("-", "_")
|
||||
value = request.META.get(meta_key, "")
|
||||
ctx.update(("%s=%s&" % (header, iri_to_uri(value))).encode("utf-8"))
|
||||
return ctx.hexdigest()
|
||||
|
||||
|
||||
def _page_vary_headers_cache_key(request: HttpRequest) -> str:
|
||||
"""Key under which the list of plugin-declared vary headers is stored.
|
||||
|
||||
The list cannot be known on a cache read until the page is rendered, so it
|
||||
is persisted on write and looked up first on read (mirroring Django's
|
||||
``learn_cache_key`` / ``get_cache_key`` two-step approach).
|
||||
"""
|
||||
return _page_cache_key(request) + ".vary-on"
|
||||
|
||||
|
||||
def set_page_cache(response: HttpResponse) -> HttpResponse:
|
||||
"""Store a rendered page response in the CMS page cache.
|
||||
|
||||
The response is only cached for anonymous requests when the page cache is
|
||||
enabled and the toolbar has not disabled caching. The cache timeout is the
|
||||
smallest of the configured ``content`` duration and every cache-able
|
||||
placeholder's TTL (as returned by ``get_cache_expiration()``); if any
|
||||
placeholder requests ``EXPIRE_NOW`` the response is not cached at all.
|
||||
|
||||
When the response is cacheable, expiration and ``Vary`` headers are patched
|
||||
onto it and two entries are written: the list of plugin-declared vary
|
||||
headers (see :func:`_page_vary_headers_cache_key`) and the response payload
|
||||
(content, headers and absolute expiry timestamp) under the header-aware
|
||||
content key. Otherwise ``never cache`` headers are added.
|
||||
|
||||
Returns the (possibly header-patched) ``response``.
|
||||
"""
|
||||
from django.core.cache import cache
|
||||
|
||||
request = response._request
|
||||
toolbar = get_toolbar_from_request(request)
|
||||
is_authenticated = request.user.is_authenticated
|
||||
|
||||
if is_authenticated or toolbar._cache_disabled or not get_cms_setting("PAGE_CACHE"):
|
||||
add_never_cache_headers(response)
|
||||
return response
|
||||
|
||||
# This *must* be TZ-aware
|
||||
timestamp = now()
|
||||
|
||||
placeholders = toolbar.content_renderer.get_rendered_placeholders()
|
||||
# Checks if there's a plugin using the legacy "cache = False"
|
||||
placeholder_ttl_list = []
|
||||
vary_cache_on_set = set()
|
||||
for ph in placeholders:
|
||||
# get_cache_expiration() always returns:
|
||||
# EXPIRE_NOW <= int <= MAX_EXPIRATION_IN_SECONDS
|
||||
ttl = ph.get_cache_expiration(request, timestamp)
|
||||
vary_cache_on = ph.get_vary_cache_on(request)
|
||||
|
||||
placeholder_ttl_list.append(ttl)
|
||||
if ttl and vary_cache_on:
|
||||
# We're only interested in vary headers if they come from
|
||||
# a cache-able placeholder.
|
||||
vary_cache_on_set |= set(vary_cache_on)
|
||||
|
||||
if EXPIRE_NOW not in placeholder_ttl_list:
|
||||
if placeholder_ttl_list:
|
||||
min_placeholder_ttl = min(x for x in placeholder_ttl_list)
|
||||
else:
|
||||
# Should only happen when there are no placeholders at all
|
||||
min_placeholder_ttl = MAX_EXPIRATION_TTL
|
||||
ttl = min(get_cms_setting("CACHE_DURATIONS")["content"], min_placeholder_ttl)
|
||||
|
||||
if ttl > 0:
|
||||
# Adds expiration, etc. to headers
|
||||
vary_on = sorted(vary_cache_on_set)
|
||||
patch_response_headers(response, cache_timeout=ttl)
|
||||
patch_vary_headers(response, vary_on)
|
||||
|
||||
version = _get_cache_version()
|
||||
# We also store the absolute expiration timestamp to avoid
|
||||
# recomputing it on cache-reads.
|
||||
expires_datetime = timestamp + timedelta(seconds=ttl)
|
||||
response_headers = get_response_headers(response)
|
||||
# Persist whether this page opted out of Django's clickjacking
|
||||
# middleware. Only "Allow" pages (no X-Frame-Options header) set
|
||||
# xframe_options_exempt; "Inherit" pages leave it unset so the
|
||||
# middleware adds the site default. The read path must replay this
|
||||
# decision -- unconditionally exempting a cached response would drop
|
||||
# X-Frame-Options from every inherit-default page (clickjacking).
|
||||
xframe_options_exempt = getattr(response, "xframe_options_exempt", False)
|
||||
# Persist the list of plugin-declared vary headers so the read path
|
||||
# can rebuild the same (header-value aware) content key.
|
||||
cache.set(
|
||||
_page_vary_headers_cache_key(request),
|
||||
vary_on,
|
||||
ttl,
|
||||
version=version,
|
||||
)
|
||||
cache.set(
|
||||
_page_cache_key(request, vary_on),
|
||||
(
|
||||
response.content,
|
||||
response_headers,
|
||||
expires_datetime,
|
||||
xframe_options_exempt,
|
||||
),
|
||||
ttl,
|
||||
version=version,
|
||||
)
|
||||
# See note in invalidate_cms_page_cache()
|
||||
_set_cache_version(version)
|
||||
return response
|
||||
|
||||
|
||||
def get_page_cache(request: HttpRequest) -> tuple[bytes, Mapping[str, str], datetime] | None:
|
||||
"""Return the cached page response for ``request`` or ``None``.
|
||||
|
||||
Mirrors Django's two-step ``get_cache_key`` lookup: first the list of
|
||||
plugin-declared vary headers is read, then the request's values for those
|
||||
headers are folded into the content key so the matching variant is
|
||||
returned. The cached value is the ``(content, headers, expires_datetime)``
|
||||
tuple stored by :func:`set_page_cache`, or ``None`` on a cache miss.
|
||||
"""
|
||||
from django.core.cache import cache
|
||||
|
||||
version = _get_cache_version()
|
||||
# First resolve which headers (if any) the cached page varies on, then
|
||||
# build the content key from this request's values for those headers.
|
||||
vary_on = cache.get(_page_vary_headers_cache_key(request), version=version)
|
||||
return cache.get(_page_cache_key(request, vary_on), version=version)
|
||||
|
||||
|
||||
def get_xframe_cache(page: Page) -> int | None:
|
||||
"""Return the cached ``X-Frame-Options`` value for ``page`` or ``None``."""
|
||||
from django.core.cache import cache
|
||||
|
||||
return cache.get("cms:xframe_options:%s" % page.pk)
|
||||
|
||||
|
||||
def set_xframe_cache(page: Page, xframe_options: int) -> None:
|
||||
"""Cache the resolved ``X-Frame-Options`` value for ``page``.
|
||||
|
||||
The cache version is re-written afterwards so the version key always
|
||||
outlives the entries written against it (see
|
||||
:func:`cms.cache.invalidate_cms_page_cache`).
|
||||
"""
|
||||
from django.core.cache import cache
|
||||
|
||||
cache.set("cms:xframe_options:%s" % page.pk, xframe_options, version=_get_cache_version())
|
||||
_set_cache_version(_get_cache_version())
|
||||
|
||||
|
||||
def _page_url_key(
|
||||
page_lookup: Any,
|
||||
lang: str,
|
||||
site_id: int,
|
||||
extra_key: str | None,
|
||||
) -> str:
|
||||
"""Build the cache key for a page's absolute URL.
|
||||
|
||||
``page_lookup`` is the untyped page reference accepted by the ``page_url``
|
||||
template tag (a :class:`~cms.models.Page`, pk, reverse id, ...). When
|
||||
``extra_key`` is a string it is folded into the key so callers can cache
|
||||
distinct URLs (e.g. per query string) for the same page.
|
||||
"""
|
||||
return "".join([
|
||||
_get_cache_key("page_url", page_lookup, lang, site_id),
|
||||
f"_key:{extra_key}" if isinstance(extra_key, str) else "",
|
||||
"_type:absolute_url",
|
||||
])
|
||||
|
||||
|
||||
def set_page_url_cache(
|
||||
page_lookup: Any,
|
||||
lang: str,
|
||||
site_id: int,
|
||||
url: str,
|
||||
extra_key: str | None = None,
|
||||
) -> None:
|
||||
"""Cache the absolute ``url`` of a page for the ``content`` duration.
|
||||
|
||||
The cache version is re-written afterwards so the version key always
|
||||
outlives the entries written against it (see
|
||||
:func:`cms.cache.invalidate_cms_page_cache`).
|
||||
"""
|
||||
from django.core.cache import cache
|
||||
|
||||
cache.set(
|
||||
_page_url_key(page_lookup, lang, site_id, extra_key),
|
||||
url,
|
||||
get_cms_setting("CACHE_DURATIONS")["content"],
|
||||
version=_get_cache_version(),
|
||||
)
|
||||
_set_cache_version(_get_cache_version())
|
||||
|
||||
|
||||
def get_page_url_cache(
|
||||
page_lookup: Any,
|
||||
lang: str,
|
||||
site_id: int,
|
||||
extra_key: str | None = None,
|
||||
) -> str | None:
|
||||
"""Return the cached absolute URL for a page lookup, or ``None`` on a miss."""
|
||||
from django.core.cache import cache
|
||||
|
||||
return cache.get(_page_url_key(page_lookup, lang, site_id, extra_key), version=_get_cache_version())
|
||||
76
cms/cache/permissions.py
vendored
Normal file
76
cms/cache/permissions.py
vendored
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
from cms.utils.conf import get_cms_setting
|
||||
|
||||
PERMISSION_KEYS = [
|
||||
'add_page', 'change_page', 'change_page_advanced_settings',
|
||||
'change_page_permissions', 'delete_page', 'move_page',
|
||||
'publish_page', 'view_page',
|
||||
]
|
||||
|
||||
|
||||
def get_cache_key(user, site, key):
|
||||
"""Cache key for ``user``'s page permissions of ``key`` on ``site``.
|
||||
|
||||
The page permissions behind a key are computed per site, so the site has to
|
||||
be part of the key: without it the value warmed for one site is served for
|
||||
every other one, granting access to sites the user has no rights on.
|
||||
"""
|
||||
return "%s:permission:%d:%d:%s" % (
|
||||
get_cms_setting('CACHE_PREFIX'), user.pk or 0, site.pk, key)
|
||||
|
||||
|
||||
def get_cache_permission_version_key():
|
||||
return "{}:permission:version".format(get_cms_setting('CACHE_PREFIX'))
|
||||
|
||||
|
||||
def get_cache_permission_version():
|
||||
from django.core.cache import cache
|
||||
try:
|
||||
version = int(cache.get(get_cache_permission_version_key()))
|
||||
except Exception:
|
||||
version = 1
|
||||
return int(version)
|
||||
|
||||
|
||||
def get_permission_cache(user, site, key):
|
||||
"""
|
||||
Helper for reading values from cache
|
||||
"""
|
||||
from django.core.cache import cache
|
||||
return cache.get(get_cache_key(user, site, key), version=get_cache_permission_version())
|
||||
|
||||
|
||||
def set_permission_cache(user, site, key, value):
|
||||
"""
|
||||
Helper method for storing values in cache. Stores used keys so
|
||||
all of them can be cleaned when clean_permission_cache gets called.
|
||||
"""
|
||||
from django.core.cache import cache
|
||||
|
||||
# store this key, so we can clean it when required
|
||||
cache_key = get_cache_key(user, site, key)
|
||||
cache.set(cache_key, value,
|
||||
get_cms_setting('CACHE_DURATIONS')['permissions'],
|
||||
version=get_cache_permission_version())
|
||||
|
||||
|
||||
def clear_user_permission_cache(user):
|
||||
"""
|
||||
Cleans permission cache for given user, on every site.
|
||||
"""
|
||||
from django.contrib.sites.models import Site
|
||||
from django.core.cache import cache
|
||||
|
||||
cache.delete_many(
|
||||
[get_cache_key(user, site, key) for site in Site.objects.all() for key in PERMISSION_KEYS],
|
||||
version=get_cache_permission_version(),
|
||||
)
|
||||
|
||||
|
||||
def clear_permission_cache():
|
||||
from django.core.cache import cache
|
||||
version = get_cache_permission_version()
|
||||
if version > 1:
|
||||
cache.incr(get_cache_permission_version_key())
|
||||
else:
|
||||
cache.set(get_cache_permission_version_key(), 2,
|
||||
get_cms_setting('CACHE_DURATIONS')['permissions'])
|
||||
171
cms/cache/placeholder.py
vendored
Normal file
171
cms/cache/placeholder.py
vendored
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
"""
|
||||
This module manages placeholder caching. We use a cache-versioning strategy
|
||||
in which each (placeholder x lang x site_id) manages its own version. The
|
||||
actual cache includes additional keys appropriate for the placeholders
|
||||
get_vary_cache_on().
|
||||
|
||||
Invalidation of a placeholder's cache simply increments the version number for
|
||||
the (placeholder x lang) pair, which renders any cache entries for that
|
||||
placeholder under that version inaccessible. Those cache entries will simply
|
||||
expire and will be purged according to the policy of the cache backend in-use.
|
||||
|
||||
The cache entries themselves may include additional sub-keys, according to the
|
||||
list of VARY header-names as returned by placeholder.get_vary_cache_on() and
|
||||
the current HTTPRequest object.
|
||||
|
||||
The vary-on header-names are also stored with the version. This enables us to
|
||||
check for cache hits without re-computing placeholder.get_vary_cache_on().
|
||||
"""
|
||||
import hashlib
|
||||
import time
|
||||
|
||||
from django.utils.timezone import now
|
||||
|
||||
from cms.utils.conf import get_cms_setting
|
||||
from cms.utils.helpers import get_header_name, get_timezone_name
|
||||
|
||||
|
||||
def _get_placeholder_cache_version_key(placeholder, lang, site_id):
|
||||
"""
|
||||
Returns the version key for the given «placeholder», «lang» and «site_id».
|
||||
|
||||
Invalidating this (via clear_placeholder_cache by replacing the stored
|
||||
value with a new value) will effectively make all "sub-caches" relating to
|
||||
this (placeholder x lang) inaccessible. Sub-caches include caches per TZ
|
||||
and per VARY header.
|
||||
"""
|
||||
prefix = get_cms_setting("CACHE_PREFIX")
|
||||
key = f"{prefix}|placeholder_cache_version|id:{placeholder.pk}|lang:{str(lang)}|site:{site_id}"
|
||||
# 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(key) > 200:
|
||||
key = "{prefix}|{hash}".format(
|
||||
prefix=prefix,
|
||||
hash=hashlib.sha1(key.encode("utf-8")).hexdigest(),
|
||||
)
|
||||
return key
|
||||
|
||||
|
||||
def _get_placeholder_cache_version(placeholder, lang, site_id):
|
||||
"""
|
||||
Gets the (placeholder x lang)'s current version and vary-on header-names
|
||||
list, if present, otherwise resets to («timestamp», []).
|
||||
"""
|
||||
from django.core.cache import cache
|
||||
|
||||
key = _get_placeholder_cache_version_key(placeholder, lang, site_id)
|
||||
cached = cache.get(key)
|
||||
if cached:
|
||||
version, vary_on_list = cached
|
||||
else:
|
||||
version = int(time.time() * 1000000)
|
||||
vary_on_list = []
|
||||
_set_placeholder_cache_version(placeholder, lang, site_id, version, vary_on_list)
|
||||
return version, vary_on_list
|
||||
|
||||
|
||||
def _set_placeholder_cache_version(placeholder, lang, site_id, version, vary_on_list=None, duration=None):
|
||||
"""
|
||||
Sets the (placeholder x lang)'s version and vary-on header-names list.
|
||||
"""
|
||||
from django.core.cache import cache
|
||||
|
||||
key = _get_placeholder_cache_version_key(placeholder, lang, site_id)
|
||||
|
||||
if not version or version < 1:
|
||||
version = int(time.time() * 1000000)
|
||||
|
||||
if vary_on_list is None:
|
||||
vary_on_list = []
|
||||
|
||||
cache.set(key, (version, vary_on_list), duration)
|
||||
|
||||
|
||||
def _get_placeholder_cache_key(placeholder, lang, site_id, request, soft=False):
|
||||
"""
|
||||
Returns the fully-addressed cache key for the given placeholder and
|
||||
the request.
|
||||
|
||||
The kwarg «soft» should be set to True if getting the cache key to then
|
||||
read from the cache. If instead the key retrieval is to support a cache
|
||||
write, let «soft» be False.
|
||||
"""
|
||||
prefix = get_cms_setting("CACHE_PREFIX")
|
||||
version, vary_on_list = _get_placeholder_cache_version(placeholder, lang, site_id)
|
||||
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
|
||||
# vary_cache_on headers and the correct cache expiration, ignoring any
|
||||
# we already have. If the placeholder has already been rendered, this
|
||||
# will be very efficient (zero-additional queries) due to the caching
|
||||
# of all its plugins during the rendering process anyway.
|
||||
vary_on_list = placeholder.get_vary_cache_on(request)
|
||||
duration = placeholder.get_cache_expiration(request, now())
|
||||
# Update the main placeholder cache version
|
||||
_set_placeholder_cache_version(placeholder, lang, site_id, version, vary_on_list, duration)
|
||||
|
||||
sub_key_list = []
|
||||
for key in vary_on_list:
|
||||
value = request.META.get(get_header_name(key)) or "_"
|
||||
sub_key_list.append(key + ":" + value)
|
||||
|
||||
cache_key = main_key
|
||||
if sub_key_list:
|
||||
cache_key += "|" + "|".join(sub_key_list)
|
||||
|
||||
# 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(),
|
||||
)
|
||||
|
||||
return cache_key
|
||||
|
||||
|
||||
def set_placeholder_cache(placeholder, lang, site_id, content, request):
|
||||
"""
|
||||
Sets the (correct) placeholder cache with the rendered placeholder.
|
||||
"""
|
||||
from django.core.cache import cache
|
||||
|
||||
key = _get_placeholder_cache_key(placeholder, lang, site_id, request)
|
||||
|
||||
duration = min(get_cms_setting("CACHE_DURATIONS")["content"], placeholder.get_cache_expiration(request, now()))
|
||||
cache.set(key, content, duration)
|
||||
# "touch" the cache-version, so that it stays as fresh as this content.
|
||||
version, vary_on_list = _get_placeholder_cache_version(placeholder, lang, site_id)
|
||||
_set_placeholder_cache_version(placeholder, lang, site_id, version, vary_on_list, duration=duration)
|
||||
|
||||
|
||||
def get_placeholder_cache(placeholder, lang, site_id, request):
|
||||
"""
|
||||
Returns the placeholder from cache respecting the placeholder's
|
||||
VARY headers.
|
||||
"""
|
||||
from django.core.cache import cache
|
||||
|
||||
key = _get_placeholder_cache_key(placeholder, lang, site_id, request, soft=True)
|
||||
content = cache.get(key)
|
||||
return content
|
||||
|
||||
|
||||
def clear_placeholder_cache(placeholder, lang, site_id):
|
||||
"""
|
||||
Invalidates all existing cache entries for (placeholder x lang x site_id).
|
||||
We don't need to re-store the vary_on_list, because the cache is now
|
||||
effectively empty.
|
||||
"""
|
||||
version = int(time.time() * 1000000)
|
||||
_set_placeholder_cache_version(placeholder, lang, site_id, version, [])
|
||||
87
cms/cms_config.py
Normal file
87
cms/cms_config.py
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
from collections.abc import Iterable
|
||||
from functools import cached_property
|
||||
from logging import getLogger
|
||||
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
from cms.app_base import CMSAppConfig, CMSAppExtension
|
||||
from cms.cms_wizards import cms_page_wizard, cms_subpage_wizard
|
||||
from cms.models import PageContent
|
||||
from cms.page_rendering import render_pagecontent
|
||||
from cms.wizards.wizard_base import Wizard
|
||||
|
||||
logger = getLogger(__name__)
|
||||
|
||||
|
||||
class CMSCoreConfig(CMSAppConfig):
|
||||
cms_enabled = True
|
||||
cms_wizards = [cms_page_wizard, cms_subpage_wizard]
|
||||
cms_toolbar_enabled_models = [(PageContent, render_pagecontent, "page")]
|
||||
|
||||
|
||||
class CMSCoreExtensions(CMSAppExtension):
|
||||
def __init__(self):
|
||||
self.lazy_wizards = []
|
||||
self.toolbar_enabled_models = {}
|
||||
self.model_groupers = {}
|
||||
self.toolbar_mixins = []
|
||||
|
||||
def configure_wizards(self, cms_config):
|
||||
"""
|
||||
Adds all registered wizards from apps that define them to the
|
||||
wizards dictionary on this class
|
||||
"""
|
||||
if not isinstance(cms_config.cms_wizards, Iterable):
|
||||
raise ImproperlyConfigured("cms_wizards must be iterable")
|
||||
|
||||
self.lazy_wizards.append(cms_config.cms_wizards)
|
||||
|
||||
@cached_property
|
||||
def wizards(self) -> dict[str, Wizard]:
|
||||
"""
|
||||
Returns a dictionary of wizard instances keyed by their unique IDs.
|
||||
Iterates over all iterables in `self.lazy_wizards`, filters out objects that are instances
|
||||
of the `Wizard` class, and constructs a dictionary where each key is the wizard's `id`
|
||||
and the value is the corresponding `Wizard` instance.
|
||||
Returns:
|
||||
dict: A dictionary mapping wizard IDs to `Wizard` instances.
|
||||
"""
|
||||
|
||||
wizards = {}
|
||||
for iterable in self.lazy_wizards:
|
||||
new_wizards = {wizard.id: wizard for wizard in iterable}
|
||||
if wizard := next((wizard for wizard in new_wizards.values() if not isinstance(wizard, Wizard)), None):
|
||||
# If any wizard in the iterable is not an instance of Wizard, raise an exception
|
||||
raise ImproperlyConfigured(
|
||||
f"cms_wizards must be iterable of Wizard instances, got {type(wizard)}"
|
||||
)
|
||||
wizards |= new_wizards
|
||||
return wizards
|
||||
|
||||
def configure_toolbar_enabled_models(self, cms_config):
|
||||
if not isinstance(cms_config.cms_toolbar_enabled_models, Iterable):
|
||||
raise ImproperlyConfigured("cms_toolbar_enabled_models must be iterable")
|
||||
for model, render_func, *grouper in cms_config.cms_toolbar_enabled_models:
|
||||
if model in self.toolbar_enabled_models:
|
||||
logger.warning(
|
||||
f"Model {model} already registered for frontend rendering",
|
||||
)
|
||||
else:
|
||||
self.toolbar_enabled_models[model] = render_func
|
||||
if grouper:
|
||||
self.model_groupers[model] = grouper[0]
|
||||
|
||||
def configure_toolbar_mixin(self, cms_config):
|
||||
if not issubclass(cms_config.cms_toolbar_mixin, object):
|
||||
raise ImproperlyConfigured("cms_toolbar_mixin must be class")
|
||||
self.toolbar_mixins.append(cms_config.cms_toolbar_mixin)
|
||||
|
||||
def configure_app(self, cms_config):
|
||||
# The cms_wizards settings is optional. If it's not here
|
||||
# just move on.
|
||||
if hasattr(cms_config, 'cms_wizards'):
|
||||
self.configure_wizards(cms_config)
|
||||
if hasattr(cms_config, 'cms_toolbar_enabled_models'):
|
||||
self.configure_toolbar_enabled_models(cms_config)
|
||||
if hasattr(cms_config, 'cms_toolbar_mixin'):
|
||||
self.configure_toolbar_mixin(cms_config)
|
||||
526
cms/cms_menus.py
Normal file
526
cms/cms_menus.py
Normal file
|
|
@ -0,0 +1,526 @@
|
|||
import re
|
||||
from collections import defaultdict
|
||||
from collections.abc import Generator, Iterable
|
||||
|
||||
from django.utils.functional import SimpleLazyObject
|
||||
|
||||
from cms import constants
|
||||
from cms.apphook_pool import apphook_pool
|
||||
from cms.models import Page, PageContent, PagePermission, PageUrl
|
||||
from cms.toolbar.utils import get_object_edit_url, get_object_preview_url, get_toolbar_from_request
|
||||
from cms.utils.conf import get_cms_setting
|
||||
from cms.utils.i18n import (
|
||||
get_fallback_languages,
|
||||
get_public_languages,
|
||||
hide_untranslated,
|
||||
is_valid_site_language,
|
||||
)
|
||||
from cms.utils.page_permissions import user_can_view_all_pages
|
||||
from menus.base import Menu, Modifier, NavigationNode
|
||||
from menus.menu_pool import menu_pool
|
||||
|
||||
# Shortcut for visibility markers
|
||||
VISIBLE_FOR_AUTHENTICATED = constants.VISIBILITY_ALL, constants.VISIBILITY_USERS
|
||||
VISIBLE_FOR_ANONYMOUS = constants.VISIBILITY_ALL, constants.VISIBILITY_ANONYMOUS
|
||||
|
||||
|
||||
def get_visible_page_contents(request, page_contents: Iterable[PageContent], site) -> Iterable[PageContent]:
|
||||
"""
|
||||
This code is a many-pages-at-once version of cms.utils.page_permissions.user_can_view_page.
|
||||
`pages` contains all published pages.
|
||||
"""
|
||||
public_for = get_cms_setting("PUBLIC_FOR")
|
||||
can_see_unrestricted = public_for == "all" or (public_for == "staff" and request.user.is_staff)
|
||||
|
||||
if not request.user.is_authenticated and not can_see_unrestricted:
|
||||
# User is not authenticated and can't see unrestricted pages,
|
||||
# no need to check for page restrictions because if there's some,
|
||||
# user is anon and if there is not any, user can't see unrestricted.
|
||||
return []
|
||||
|
||||
if user_can_view_all_pages(request.user, site):
|
||||
return page_contents
|
||||
|
||||
if not get_cms_setting("PERMISSION"):
|
||||
# If there's no restrictions, let the user see all pages
|
||||
# only if he can see unrestricted, otherwise return no pages.
|
||||
return page_contents if can_see_unrestricted else []
|
||||
|
||||
restrictions = PagePermission.objects.filter(
|
||||
page_id__in={page_content.page.pk for page_content in page_contents},
|
||||
can_view=True,
|
||||
)
|
||||
restriction_map = {perm.page_id: perm for perm in restrictions}
|
||||
|
||||
user_id = request.user.pk
|
||||
user_groups = SimpleLazyObject(lambda: frozenset(request.user.groups.values_list("pk", flat=True)))
|
||||
is_auth_user = request.user.is_authenticated
|
||||
|
||||
def user_can_see_page(page: Page) -> bool:
|
||||
if page.pk in restriction_map:
|
||||
# set internal fk cache to our page with loaded ancestors and descendants
|
||||
PagePermission.page.field.set_cached_value(restriction_map[page.pk], page)
|
||||
|
||||
restricted = False
|
||||
for perm in restrictions:
|
||||
if perm.get_page_permission_tuple().contains(page.path):
|
||||
if not is_auth_user:
|
||||
return False
|
||||
if perm.user_id == user_id or perm.group_id in user_groups:
|
||||
return True
|
||||
restricted = True
|
||||
|
||||
# Page has no view restrictions, fallback to the project's
|
||||
# CMS_PUBLIC_FOR setting.
|
||||
return can_see_unrestricted and not restricted
|
||||
|
||||
return list(page_content for page_content in page_contents if user_can_see_page(page_content.page))
|
||||
|
||||
|
||||
class CMSNavigationNode(NavigationNode):
|
||||
"""
|
||||
Represents a CMS Navigation Node for a Page object in the page tree.
|
||||
|
||||
Attributes:
|
||||
path: The path of the node.
|
||||
language: The language used for the node (optional).
|
||||
"""
|
||||
|
||||
def __init__(self, *args, language: str | None = None, **kwargs):
|
||||
"""
|
||||
Initializes a CMSNavigationNode instance.
|
||||
|
||||
:param *args: Positional arguments.
|
||||
:param language: The language used for the node. Optional.
|
||||
:param **kwargs: Keyword arguments.
|
||||
"""
|
||||
# language is only used when we're dealing with a fallback
|
||||
self.language = language
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def is_selected(self, request) -> bool:
|
||||
try:
|
||||
return request.current_page.pk == self.id
|
||||
except AttributeError:
|
||||
return False
|
||||
|
||||
|
||||
class CMSMenu(Menu):
|
||||
"""Subclass of :class:`menus.base.Menu`. Its :meth:`~menus.base.Menu.get_nodes()` creates a list of NavigationNodes
|
||||
based on a site's :class:`cms.models.pagemodel.Page` objects.
|
||||
"""
|
||||
|
||||
def __init__(self, renderer):
|
||||
"""
|
||||
Initializes a CMSMenu instance.
|
||||
|
||||
:param renderer: The renderer object.
|
||||
"""
|
||||
super().__init__(renderer)
|
||||
|
||||
lang = renderer.request_language
|
||||
site_pk = renderer.site.pk
|
||||
if is_valid_site_language(lang, site_id=site_pk):
|
||||
_valid_language = True
|
||||
_hide_untranslated = hide_untranslated(lang, site_pk)
|
||||
else:
|
||||
_valid_language = False
|
||||
_hide_untranslated = False
|
||||
|
||||
if _valid_language:
|
||||
# The request language has been explicitly configured
|
||||
# for the current site.
|
||||
if _hide_untranslated:
|
||||
fallbacks = []
|
||||
else:
|
||||
fallbacks = get_fallback_languages(lang, site_id=site_pk)
|
||||
self.languages = [lang] + [_lang for _lang in fallbacks if _lang != lang]
|
||||
else:
|
||||
# The request language is not configured for the current site.
|
||||
# Fallback to all configured public languages for the current site.
|
||||
self.languages = get_public_languages(site_pk)
|
||||
|
||||
def select_lang(self, page_contents: Iterable[PageContent]) -> Generator[PageContent, None, None]:
|
||||
"""Generator that returns only those page content objects passed that contain the first language
|
||||
present in the languages list."""
|
||||
lang_index = len(self.languages)
|
||||
translation = None
|
||||
page = None
|
||||
|
||||
for page_content in page_contents:
|
||||
if translation and page != page_content.page:
|
||||
yield translation
|
||||
lang_index = self.languages.index(page_content.language)
|
||||
translation = page_content
|
||||
page = page_content.page
|
||||
elif self.languages.index(page_content.language) < lang_index:
|
||||
lang_index = self.languages.index(page_content.language)
|
||||
translation = page_content
|
||||
page = page_content.page
|
||||
if translation:
|
||||
yield translation
|
||||
|
||||
def get_menu_node_for_page_content(
|
||||
self,
|
||||
page_content: PageContent,
|
||||
view_url: str | None = None,
|
||||
cut: bool = False,
|
||||
) -> CMSNavigationNode:
|
||||
"""
|
||||
Transform a CMS page content object into a navigation node.
|
||||
|
||||
:param page: The page to transform.
|
||||
:param languages: The list of the current language plus fallbacks used to render the menu.
|
||||
:param view_url: If given, serves as a "pattern" for a view url with the assumption that "/0/" is replaced
|
||||
by the actual page content pk. Default is None.
|
||||
:param cut: If True the parent_id is set to None. Default is False.
|
||||
:returns: A CMSNavigationNode instance.
|
||||
"""
|
||||
page = page_content.page
|
||||
|
||||
# These are simple to port over, since they are not calculated.
|
||||
# Other attributes will be added conditionally later.
|
||||
visibility = page_content.limit_visibility_in_menu
|
||||
attr = {
|
||||
"is_page": True,
|
||||
"soft_root": page_content.soft_root,
|
||||
"redirect_url": page_content.redirect,
|
||||
"auth_required": page.login_required,
|
||||
"reverse_id": page.reverse_id,
|
||||
"is_home": page.is_home,
|
||||
"visible_for_authenticated": visibility in VISIBLE_FOR_AUTHENTICATED,
|
||||
"visible_for_anonymous": visibility in VISIBLE_FOR_ANONYMOUS,
|
||||
}
|
||||
|
||||
extenders = []
|
||||
if page.navigation_extenders:
|
||||
if page.navigation_extenders in self.renderer.menus:
|
||||
extenders.append(page.navigation_extenders)
|
||||
elif f"{page.navigation_extenders}:{page.pk}" in self.renderer.menus:
|
||||
extenders.append(f"{page.navigation_extenders}:{page.pk}")
|
||||
# Is this page an apphook? If so, we need to handle the apphooks's nodes
|
||||
# Only run this if we have a translation in the requested language for this
|
||||
# object. The page content cache should have been prepopulated in CMSMenu.get_nodes
|
||||
# but otherwise, just request the title normally
|
||||
if page.application_urls and page_content.language == self.languages[0]:
|
||||
# it means it is an apphook
|
||||
app = apphook_pool.get_apphook(page.application_urls)
|
||||
if app:
|
||||
extenders.extend(app.get_menus(page, self.languages[0]))
|
||||
# CMSAattachMenus are treated a bit differently to allow them to be
|
||||
# able to be attached to multiple points in the navigation.
|
||||
attr["navigation_extenders"] = [
|
||||
f"{ext.__name__}:{page.pk}" if hasattr(ext, "get_instances") else getattr(ext, "__name__", ext)
|
||||
for ext in extenders
|
||||
]
|
||||
|
||||
# Now finally, build the NavigationNode object and return it.
|
||||
# The parent_id is manually set by the menu get_nodes method.
|
||||
if view_url:
|
||||
# Build view url by replacing "/0/" in the url template by the actual pk of the page content object
|
||||
# Hacky, but faster than calling `admin_reverse` for each page content object
|
||||
url = re.sub("(/0/)", f"/{page_content.pk}/", view_url)
|
||||
else:
|
||||
url = page.get_absolute_url(language=page_content.language)
|
||||
|
||||
return CMSNavigationNode(
|
||||
title=page_content.menu_title or page_content.title,
|
||||
url=url,
|
||||
id=page.pk,
|
||||
parent_id=None if cut else page_content.page.parent_id,
|
||||
attr=attr,
|
||||
visible=page_content.in_navigation,
|
||||
language=(page_content.language if page_content.language != self.languages[0] else None),
|
||||
)
|
||||
|
||||
def get_nodes(self, request) -> list[NavigationNode]:
|
||||
"""
|
||||
Returns a list of NavigationNode objects representing the navigation nodes to be displayed in the menu.
|
||||
This method is performance-critical since the number of page content objects can be
|
||||
large.
|
||||
|
||||
:param self: The instance of the class.
|
||||
:param request: The HTTP request object.
|
||||
:return: A list of NavigationNode objects representing the navigation nodes.
|
||||
:rtype: list[NavigationNode]
|
||||
|
||||
.. note::
|
||||
|
||||
* The method retrieves the necessary data from the database to build the navigation nodes for the menu.
|
||||
* The behavior of the method depends on whether the edit mode or preview mode is active in the toolbar.
|
||||
* If either edit mode or preview mode is active, the method retrieves all current page content objects
|
||||
visible in the admin for the current page.
|
||||
* If neither edit mode nor preview mode is active, the method retrieves only public page content objects.
|
||||
* The retrieved page contents are filtered based on the specified languages, sorted by page path,
|
||||
and filtered by site.
|
||||
* Only specific fields of the page content objects are selected to optimize performance.
|
||||
* If either edit mode or preview mode is active, a preview URL is constructed for a "virtual" non-existing
|
||||
page content with id=0 to avoid too many calls to ``revert`` the admin URL.
|
||||
* The method includes a nested function for prefetching URLs and filling the URL cache.
|
||||
* The visibility of the page contents is further filtered based on authentication and permissions.
|
||||
* The homepage is determined based on the page contents and marked for cutting if necessary.
|
||||
* The menu node for each page content is created using the get_menu_node_for_page_content method of the
|
||||
instance.
|
||||
* The prefetch_urls function is called for each page content to fill the URL cache and provide necessary
|
||||
data for creating the menu node.
|
||||
* The select_lang method is used to filter the page contents based on the specified language preferences.
|
||||
"""
|
||||
site = self.renderer.site
|
||||
toolbar = get_toolbar_from_request(request)
|
||||
|
||||
if toolbar.edit_mode_active or toolbar.preview_mode_active:
|
||||
# Get all translations visible in the admin for the current page
|
||||
translations_qs = PageContent.admin_manager.current_content()
|
||||
else:
|
||||
# Only get public translations
|
||||
translations_qs = PageContent.objects
|
||||
|
||||
page_contents = (
|
||||
translations_qs.filter(language__in=self.languages)
|
||||
.order_by("page__path")
|
||||
.filter(page__site=site)
|
||||
.select_related("page")
|
||||
.only(
|
||||
"page_id",
|
||||
"language",
|
||||
"menu_title",
|
||||
"title",
|
||||
"limit_visibility_in_menu",
|
||||
"soft_root",
|
||||
"in_navigation",
|
||||
"redirect",
|
||||
"page__site_id",
|
||||
"page__parent_id",
|
||||
"page__path", # needed by the view-restriction check in get_visible_page_contents
|
||||
"page__is_home",
|
||||
"page__login_required",
|
||||
"page__reverse_id",
|
||||
"page__navigation_extenders",
|
||||
"page__application_urls",
|
||||
)
|
||||
)
|
||||
if toolbar.edit_mode_active or toolbar.preview_mode_active:
|
||||
# Edit URL for a "virtual" non-existing page content with id=0. This is used to quickly build many
|
||||
# edit urls by replacing "/0/" by the page content pk in the edit url
|
||||
view_url = get_object_edit_url(PageContent(id=0)) if toolbar.edit_mode_active else get_object_preview_url(PageContent(id=0))
|
||||
|
||||
def prefetch_urls(page_content: PageContent) -> PageContent:
|
||||
return page_content
|
||||
else:
|
||||
view_url = None # No short-cut here
|
||||
prefetched_urls = PageUrl.objects.filter(
|
||||
language__in=(page_content.language for page_content in page_contents),
|
||||
page_id__in=(page_content.page.pk for page_content in page_contents),
|
||||
) # Fetch the PageUrl objects
|
||||
# Prepare for filling urls_cache
|
||||
filtered_urls = defaultdict(dict)
|
||||
for page_url in prefetched_urls:
|
||||
filtered_urls[page_url.page_id][page_url.language] = page_url
|
||||
|
||||
def prefetch_urls(page_content: PageContent) -> PageContent:
|
||||
# Fill url cache for existing urls and page contents into the cache
|
||||
# Access the cache directly to not lead to a db hit when accessing
|
||||
# non-existing languages
|
||||
page_content.page.urls_cache = filtered_urls.get(page_content.page_id)
|
||||
return page_content
|
||||
|
||||
page_contents = get_visible_page_contents(request, page_contents, site)
|
||||
home = next((page_content for page_content in page_contents if page_content.page.is_home), None)
|
||||
|
||||
# Find homepage
|
||||
cut_homepage = home and not home.in_navigation
|
||||
homepage_pk = home.page.pk if home else None
|
||||
|
||||
return [
|
||||
self.get_menu_node_for_page_content(
|
||||
prefetch_urls(page_content),
|
||||
view_url=view_url,
|
||||
cut=page_content.page.parent_id == homepage_pk and cut_homepage,
|
||||
)
|
||||
for page_content in self.select_lang(page_contents)
|
||||
]
|
||||
|
||||
|
||||
menu_pool.register_menu(CMSMenu)
|
||||
|
||||
|
||||
class NavExtender(Modifier):
|
||||
def modify(self, request, nodes, namespace, root_id, post_cut, breadcrumb):
|
||||
if post_cut:
|
||||
return nodes
|
||||
# rearrange the parent relations
|
||||
# Find home
|
||||
home = next((n for n in nodes if n.attr.get("is_home", False)), None)
|
||||
# Find nodes with NavExtenders
|
||||
exts = []
|
||||
for node in nodes:
|
||||
extenders = node.attr.get("navigation_extenders", None)
|
||||
if extenders:
|
||||
for ext in extenders:
|
||||
if ext not in exts:
|
||||
exts.append(ext)
|
||||
# Link the nodes
|
||||
for extnode in nodes:
|
||||
if extnode.namespace == ext and not extnode.parent_id:
|
||||
# if home has nav extenders but home is not visible
|
||||
if node == home and not node.visible:
|
||||
# extnode.parent_id = None
|
||||
extnode.parent_namespace = None
|
||||
extnode.parent = None
|
||||
else:
|
||||
extnode.parent_id = node.id
|
||||
extnode.parent_namespace = node.namespace
|
||||
extnode.parent = node
|
||||
node.children.append(extnode)
|
||||
removed = []
|
||||
|
||||
# find all not assigned nodes
|
||||
for menu in self.renderer.menus.items():
|
||||
if hasattr(menu[1], "cms_enabled") and menu[1].cms_enabled and menu[0] not in exts:
|
||||
for node in nodes:
|
||||
if node.namespace == menu[0]:
|
||||
removed.append(node)
|
||||
if breadcrumb:
|
||||
# if breadcrumb and home not in navigation add node
|
||||
if breadcrumb and home and not home.visible:
|
||||
home.visible = True
|
||||
if request.path_info == home.get_absolute_url():
|
||||
home.selected = True
|
||||
else:
|
||||
home.selected = False
|
||||
# remove all nodes that are nav_extenders and not assigned
|
||||
for node in removed:
|
||||
nodes.remove(node)
|
||||
return nodes
|
||||
|
||||
|
||||
menu_pool.register_modifier(NavExtender)
|
||||
|
||||
|
||||
class SoftRootCutter(Modifier):
|
||||
"""
|
||||
A soft root is a page that acts as the root for a menu navigation tree.
|
||||
|
||||
Typically, this will be a page that is the root of a significant new
|
||||
section on your site.
|
||||
|
||||
When the soft root feature is enabled, the navigation menu for any page
|
||||
will start at the nearest soft root, rather than at the real root of
|
||||
the site’s page hierarchy.
|
||||
|
||||
This feature is useful when your site has deep page hierarchies (and
|
||||
therefore multiple levels in its navigation trees). In such a case, you
|
||||
usually don’t want to present site visitors with deep menus of nested
|
||||
items.
|
||||
|
||||
For example, you’re on the page -Introduction to Bleeding-?, so the menu
|
||||
might look like this:
|
||||
|
||||
* School of Medicine
|
||||
* Medical Education
|
||||
* Departments
|
||||
* Department of Lorem Ipsum
|
||||
* Department of Donec Imperdiet
|
||||
* Department of Cras Eros
|
||||
* Department of Mediaeval Surgery
|
||||
* Theory
|
||||
* Cures
|
||||
* Bleeding
|
||||
* Introduction to Bleeding <this is the current page>
|
||||
* Bleeding - the scientific evidence
|
||||
* Cleaning up the mess
|
||||
* Cupping
|
||||
* Leaches
|
||||
* Maggots
|
||||
* Techniques
|
||||
* Instruments
|
||||
* Department of Curabitur a Purus
|
||||
* Department of Sed Accumsan
|
||||
* Department of Etiam
|
||||
* Research
|
||||
* Administration
|
||||
* Contact us
|
||||
* Impressum
|
||||
|
||||
which is frankly overwhelming.
|
||||
|
||||
By making "Department of Mediaeval Surgery" a soft root, the menu
|
||||
becomes much more manageable:
|
||||
|
||||
* Department of Mediaeval Surgery
|
||||
* Theory
|
||||
* Cures
|
||||
* Bleeding
|
||||
* Introduction to Bleeding <current page>
|
||||
* Bleeding - the scientific evidence
|
||||
* Cleaning up the mess
|
||||
* Cupping
|
||||
* Leaches
|
||||
* Maggots
|
||||
* Techniques
|
||||
* Instruments
|
||||
"""
|
||||
|
||||
def modify(self, request, nodes, namespace, root_id, post_cut, breadcrumb):
|
||||
# only apply this modifier if we're pre-cut (since what we do is cut)
|
||||
# or if no id argument is provided, indicating {% show_menu_below_id %}
|
||||
if post_cut or root_id:
|
||||
return nodes
|
||||
selected = None
|
||||
root_nodes = []
|
||||
# find the selected node as well as all the root nodes
|
||||
for node in nodes:
|
||||
if node.selected:
|
||||
selected = node
|
||||
if not node.parent:
|
||||
root_nodes.append(node)
|
||||
|
||||
# if we found a selected ...
|
||||
if selected:
|
||||
# and the selected is a softroot
|
||||
if selected.attr.get("soft_root", False):
|
||||
# get it's descendants
|
||||
nodes = selected.get_descendants()
|
||||
# remove the link to parent
|
||||
selected.parent = None
|
||||
# make the selected page the root in the menu
|
||||
nodes = [selected] + nodes
|
||||
else:
|
||||
# if it's not a soft root, walk ancestors (upwards!)
|
||||
nodes = self.find_ancestors_and_remove_children(selected, nodes)
|
||||
return nodes
|
||||
|
||||
def find_and_remove_children(self, node, nodes):
|
||||
for child in node.children:
|
||||
if child.attr.get("soft_root", False):
|
||||
self.remove_children(child, nodes)
|
||||
return nodes
|
||||
|
||||
def remove_children(self, node, nodes):
|
||||
for child in node.children:
|
||||
nodes.remove(child)
|
||||
self.remove_children(child, nodes)
|
||||
node.children = []
|
||||
|
||||
def find_ancestors_and_remove_children(self, node, nodes):
|
||||
"""
|
||||
Check ancestors of node for soft roots
|
||||
"""
|
||||
if node.parent:
|
||||
if node.parent.attr.get("soft_root", False):
|
||||
nodes = node.parent.get_descendants()
|
||||
node.parent.parent = None
|
||||
nodes = [node.parent] + nodes
|
||||
else:
|
||||
nodes = self.find_ancestors_and_remove_children(node.parent, nodes)
|
||||
else:
|
||||
for newnode in nodes:
|
||||
if newnode != node and not newnode.parent:
|
||||
self.find_and_remove_children(newnode, nodes)
|
||||
for child in node.children:
|
||||
if child != node:
|
||||
self.find_and_remove_children(child, nodes)
|
||||
return nodes
|
||||
|
||||
|
||||
menu_pool.register_modifier(SoftRootCutter)
|
||||
19
cms/cms_plugins.py
Normal file
19
cms/cms_plugins.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from cms.models.placeholderpluginmodel import PlaceholderReference
|
||||
from cms.plugin_base import CMSPluginBase
|
||||
from cms.plugin_pool import plugin_pool
|
||||
|
||||
|
||||
class PlaceholderPlugin(CMSPluginBase):
|
||||
name = _("Placeholder")
|
||||
parent_classes = ['0'] # so you will not be able to add it something
|
||||
# require_parent = True
|
||||
render_plugin = False
|
||||
admin_preview = False
|
||||
system = True
|
||||
|
||||
model = PlaceholderReference
|
||||
|
||||
|
||||
plugin_pool.register_plugin(PlaceholderPlugin)
|
||||
730
cms/cms_toolbars.py
Normal file
730
cms/cms_toolbars.py
Normal file
|
|
@ -0,0 +1,730 @@
|
|||
from django.conf import settings
|
||||
from django.contrib import admin
|
||||
from django.contrib.auth import get_permission_codename, get_user_model
|
||||
from django.contrib.auth.models import AnonymousUser
|
||||
from django.contrib.sites.models import Site
|
||||
from django.urls import NoReverseMatch, Resolver404, resolve, reverse
|
||||
from django.utils.translation import (
|
||||
gettext_lazy as _,
|
||||
override as force_language,
|
||||
)
|
||||
|
||||
from cms.api import can_change_page
|
||||
from cms.constants import TEMPLATE_INHERITANCE_MAGIC
|
||||
from cms.models import Page, PageContent, PageType, Placeholder
|
||||
from cms.toolbar.items import REFRESH_PAGE, ButtonList, SubMenu, TemplateItem
|
||||
from cms.toolbar.utils import (
|
||||
get_object_edit_url,
|
||||
get_object_preview_url,
|
||||
get_object_structure_url,
|
||||
)
|
||||
from cms.toolbar_base import CMSToolbar
|
||||
from cms.toolbar_pool import toolbar_pool
|
||||
from cms.utils import get_language_from_request, page_permissions
|
||||
from cms.utils.conf import get_cms_setting
|
||||
from cms.utils.i18n import get_language_dict, get_language_tuple
|
||||
from cms.utils.page_permissions import (
|
||||
user_can_change_page,
|
||||
user_can_delete_page,
|
||||
user_can_view_page,
|
||||
)
|
||||
from cms.utils.permissions import user_can_view_placeholder_source
|
||||
from cms.utils.urlutils import add_url_parameters, admin_reverse
|
||||
from menus.utils import DefaultLanguageChanger
|
||||
|
||||
# Identifiers for search
|
||||
ADMIN_MENU_IDENTIFIER = "admin-menu"
|
||||
"""
|
||||
The *Site* menu (that usually shows the project's domain name, *example.com* by default).
|
||||
``ADMIN_MENU_IDENTIFIER`` allows you to get hold of this object easily using
|
||||
:meth:`cms.toolbar.toolbar.CMSToolbar.get_menu`.
|
||||
"""
|
||||
LANGUAGE_MENU_IDENTIFIER = "language-menu"
|
||||
"""
|
||||
The *Language* menu. ``LANGUAGE_MENU_IDENTIFIER`` allows you to get hold of this object
|
||||
easily using :meth:`cms.toolbar.toolbar.CMSToolbar.get_menu`.
|
||||
"""
|
||||
HELP_MENU_IDENTIFIER = "help-menu"
|
||||
"""
|
||||
The *Help* menu. ``HELP_MENU_IDENTIFIER`` allows you to get hold of this object easily
|
||||
using :meth:`cms.toolbar.toolbar.CMSToolbar.get_menu`.
|
||||
"""
|
||||
HELP_MENU_BREAK = "Help Menu Break"
|
||||
TEMPLATE_MENU_BREAK = "Template Menu Break"
|
||||
PAGE_MENU_IDENTIFIER = "page"
|
||||
"""
|
||||
The *Page* menu. ``PAGE_MENU_IDENTIFIER`` allows you to get hold of this object
|
||||
easily using :meth:`cms.toolbar.toolbar.CMSToolbar.get_menu`.
|
||||
"""
|
||||
PAGE_MENU_ADD_IDENTIFIER = "add_page"
|
||||
PAGE_MENU_FIRST_BREAK = "Page Menu First Break"
|
||||
PAGE_MENU_SECOND_BREAK = "Page Menu Second Break"
|
||||
PAGE_MENU_THIRD_BREAK = "Page Menu Third Break"
|
||||
PAGE_MENU_FOURTH_BREAK = "Page Menu Fourth Break"
|
||||
PAGE_MENU_LAST_BREAK = "Page Menu Last Break"
|
||||
HISTORY_MENU_BREAK = "History Menu Break"
|
||||
MANAGE_PAGES_BREAK = "Manage Pages Break"
|
||||
ADMIN_SITES_BREAK = "Admin Sites Break"
|
||||
ADMINISTRATION_BREAK = "Administration Break"
|
||||
CLIPBOARD_BREAK = "Clipboard Break"
|
||||
USER_SETTINGS_BREAK = "User Settings Break"
|
||||
ADD_PAGE_LANGUAGE_BREAK = "Add page language Break"
|
||||
REMOVE_PAGE_LANGUAGE_BREAK = "Remove page language Break"
|
||||
COPY_PAGE_LANGUAGE_BREAK = "Copy page language Break"
|
||||
TOOLBAR_DISABLE_BREAK = "Toolbar disable Break"
|
||||
SHORTCUTS_BREAK = "Shortcuts Break"
|
||||
|
||||
DEFAULT_HELP_MENU_ITEMS = (
|
||||
(_("Documentation"), "https://docs.django-cms.org/en/stable/"),
|
||||
(_("User guide"), "https://user-guide.django-cms.org"),
|
||||
(_("Discord community"), "https://www.django-cms.org/discord"),
|
||||
(_("What's new"), "https://www.django-cms.org/en/blog/"),
|
||||
)
|
||||
|
||||
|
||||
@toolbar_pool.register
|
||||
class PlaceholderToolbar(CMSToolbar):
|
||||
"""
|
||||
Adds placeholder edit buttons if placeholders or static placeholders are detected in the template
|
||||
"""
|
||||
|
||||
def populate(self):
|
||||
self.page = self.request.current_page
|
||||
|
||||
def post_template_populate(self):
|
||||
super().post_template_populate()
|
||||
self.add_wizard_button()
|
||||
self.render_object_editable_buttons()
|
||||
|
||||
def add_wizard_button(self):
|
||||
from cms.wizards.wizard_pool import entry_choices
|
||||
|
||||
title = _("Create")
|
||||
|
||||
user = self.request.user
|
||||
page_pk = self.page.pk if self.page else ""
|
||||
disabled = not list(entry_choices(user, self.page, site=self.current_site))
|
||||
|
||||
url = "{url}?page={page}&language={lang}&edit".format(
|
||||
url=admin_reverse("cms_wizard_create"),
|
||||
page=page_pk,
|
||||
lang=self.toolbar.site_language,
|
||||
)
|
||||
self.toolbar.add_modal_button(title, url, side=self.toolbar.RIGHT, disabled=disabled, on_close=REFRESH_PAGE)
|
||||
|
||||
def render_object_editable_buttons(self):
|
||||
self.init_placeholders()
|
||||
|
||||
if not self.toolbar.obj:
|
||||
return
|
||||
|
||||
# Edit button
|
||||
if self.toolbar.content_mode_active and self._can_add_button():
|
||||
self.add_edit_button()
|
||||
# Preview button
|
||||
if not self.toolbar.preview_mode_active and get_cms_setting("TEMPLATES") and self._can_add_button():
|
||||
# Only add preview button if there are templates available for previewing
|
||||
self.add_preview_button()
|
||||
# Structure mode
|
||||
if self._can_add_structure_mode():
|
||||
self.add_structure_mode()
|
||||
|
||||
def init_placeholders(self):
|
||||
request = self.request
|
||||
toolbar = self.toolbar
|
||||
|
||||
if toolbar._async and "placeholders[]" in request.GET:
|
||||
# AJAX request to reload page structure.
|
||||
placeholder_ids = request.GET.getlist("placeholders[]")
|
||||
self.placeholders = Placeholder.objects.filter(pk__in=placeholder_ids)
|
||||
else:
|
||||
if toolbar.structure_mode_active and not toolbar.uses_legacy_structure_mode:
|
||||
# User has explicitly requested structure mode.
|
||||
# and the object (page, blog, etc..) allows for the non-legacy structure mode.
|
||||
renderer = toolbar.structure_renderer
|
||||
else:
|
||||
renderer = toolbar.get_content_renderer()
|
||||
|
||||
self.placeholders = renderer.get_rendered_placeholders()
|
||||
|
||||
# Helpers to check whether buttons can be rendered
|
||||
def _has_page_change_perm(self):
|
||||
if self.page and user_can_change_page(self.request.user, page=self.page):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _has_placeholder_change_perm(self):
|
||||
if not self.placeholders:
|
||||
return False
|
||||
return any(ph for ph in self.placeholders if ph.has_change_permission(self.request.user))
|
||||
|
||||
def _has_page_view_perm(self):
|
||||
if self.page and user_can_view_page(self.request.user, self.page):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _has_placeholder_view_perm(self):
|
||||
obj = self.toolbar.obj
|
||||
if obj is None:
|
||||
return False
|
||||
return user_can_view_placeholder_source(self.request.user, obj)
|
||||
|
||||
def _can_add_button(self):
|
||||
if self._has_page_change_perm():
|
||||
return True
|
||||
elif self._has_placeholder_change_perm():
|
||||
return True
|
||||
return False
|
||||
|
||||
def _can_add_structure_mode(self):
|
||||
# Viewing the structure board only requires view permission: editing
|
||||
# the plugins stays gated by change permission at the plugin endpoints.
|
||||
# This lets headless reviewers (and view-only staff) inspect content
|
||||
# structure without edit rights.
|
||||
if not self.request.user.has_perm("cms.use_structure"):
|
||||
return False
|
||||
|
||||
if self.page and not self.page.application_urls and self._has_page_view_perm():
|
||||
return True
|
||||
elif self._has_placeholder_view_perm():
|
||||
return True
|
||||
return False
|
||||
|
||||
# Buttons
|
||||
def add_edit_button(self):
|
||||
url = get_object_edit_url(self.toolbar.obj, language=self.toolbar.request_language, params=self.request.GET)
|
||||
item = ButtonList(side=self.toolbar.RIGHT)
|
||||
item.add_button(
|
||||
_("Edit"),
|
||||
url=url,
|
||||
disabled=False,
|
||||
extra_classes=["cms-btn", "cms-btn-action", "cms-btn-switch-edit"],
|
||||
)
|
||||
self.toolbar.add_item(item)
|
||||
|
||||
def add_preview_button(self):
|
||||
url = get_object_preview_url(self.toolbar.obj, language=self.toolbar.request_language, params=self.request.GET)
|
||||
if url:
|
||||
item = ButtonList(side=self.toolbar.RIGHT)
|
||||
item.add_button(
|
||||
_("Preview"),
|
||||
url=url,
|
||||
disabled=False,
|
||||
extra_classes=["cms-btn", "cms-btn-switch-save"],
|
||||
)
|
||||
self.toolbar.add_item(item)
|
||||
|
||||
def add_structure_mode(self, extra_classes=("cms-toolbar-item-cms-mode-switcher",)):
|
||||
structure_active = self.toolbar.structure_mode_active
|
||||
edit_mode_active = not structure_active and self.toolbar.edit_mode_active
|
||||
build_url = get_object_structure_url(
|
||||
self.toolbar.obj, language=self.toolbar.request_language, params=self.request.GET
|
||||
)
|
||||
edit_url = get_object_edit_url(
|
||||
self.toolbar.obj, language=self.toolbar.request_language, params=self.request.GET
|
||||
)
|
||||
switcher = self.toolbar.add_button_list(
|
||||
"Mode Switcher",
|
||||
side=self.toolbar.RIGHT,
|
||||
extra_classes=extra_classes,
|
||||
)
|
||||
switcher.add_button(
|
||||
_("Structure"),
|
||||
build_url,
|
||||
active=structure_active,
|
||||
disabled=False,
|
||||
extra_classes="cms-structure-btn",
|
||||
)
|
||||
switcher.add_button(
|
||||
_("Content"),
|
||||
edit_url,
|
||||
active=edit_mode_active,
|
||||
disabled=False,
|
||||
extra_classes="cms-content-btn",
|
||||
)
|
||||
|
||||
|
||||
@toolbar_pool.register
|
||||
class AppearanceToolbar(CMSToolbar):
|
||||
"""
|
||||
Adds appearance switches, esp. for dark and light mode
|
||||
"""
|
||||
|
||||
color_scheme_toggle = get_cms_setting("COLOR_SCHEME_TOGGLE")
|
||||
|
||||
def populate(self):
|
||||
if self.color_scheme_toggle:
|
||||
dark_mode_toggle = TemplateItem(
|
||||
template="cms/toolbar/items/dark_mode_toggle.html",
|
||||
side=self.toolbar.RIGHT,
|
||||
)
|
||||
self.toolbar.add_item(dark_mode_toggle)
|
||||
|
||||
|
||||
@toolbar_pool.register
|
||||
class HelpToolbar(CMSToolbar):
|
||||
"""
|
||||
Adds a help menu to the toolbar
|
||||
"""
|
||||
|
||||
def populate(self):
|
||||
if not get_cms_setting("ENABLE_HELP"):
|
||||
return
|
||||
help_menu = self._get_or_create_help_menu()
|
||||
self._add_default_menu_items(help_menu)
|
||||
self._add_extra_menu_items(help_menu)
|
||||
|
||||
def _get_or_create_help_menu(self) -> SubMenu:
|
||||
help_menu = self.toolbar.get_or_create_menu(HELP_MENU_IDENTIFIER, _("Help"), position=-1)
|
||||
help_menu.items = [] # reset the items so we don't duplicate
|
||||
return help_menu
|
||||
|
||||
def _add_default_menu_items(self, help_menu: SubMenu):
|
||||
for label, url in DEFAULT_HELP_MENU_ITEMS:
|
||||
help_menu.add_link_item(label, url=url)
|
||||
|
||||
def _add_extra_menu_items(self, help_menu: SubMenu):
|
||||
if extra_menu_items := get_cms_setting("EXTRA_HELP_MENU_ITEMS"):
|
||||
help_menu.add_break(HELP_MENU_BREAK)
|
||||
for label, url in extra_menu_items:
|
||||
help_menu.add_link_item(label, url=url)
|
||||
|
||||
|
||||
@toolbar_pool.register
|
||||
class BasicToolbar(CMSToolbar):
|
||||
"""
|
||||
Basic Toolbar for site and languages menu
|
||||
"""
|
||||
|
||||
page = None
|
||||
_language_menu = None
|
||||
_admin_menu = None
|
||||
|
||||
def init_from_request(self):
|
||||
self.page = self.request.current_page
|
||||
|
||||
def populate(self):
|
||||
if not self.page:
|
||||
self.init_from_request()
|
||||
self.clipboard = self.request.toolbar.user_settings.clipboard
|
||||
self.add_admin_menu()
|
||||
self.add_language_menu()
|
||||
|
||||
def add_admin_menu(self):
|
||||
if not self._admin_menu:
|
||||
self._admin_menu = self.toolbar.get_or_create_menu(ADMIN_MENU_IDENTIFIER, self.current_site.name)
|
||||
# Users button
|
||||
self.add_users_button(self._admin_menu)
|
||||
|
||||
# sites menu
|
||||
sites_queryset = Site.objects.order_by("name")
|
||||
|
||||
if len(sites_queryset) > 1:
|
||||
sites_menu = self._admin_menu.get_or_create_menu("sites", _("Sites"))
|
||||
sites_menu.add_sideframe_item(_("Admin Sites"), url=admin_reverse("sites_site_changelist"))
|
||||
sites_menu.add_break(ADMIN_SITES_BREAK)
|
||||
for site in sites_queryset:
|
||||
sites_menu.add_link_item(
|
||||
site.name, url="//%s" % site.domain, active=site.pk == self.current_site.pk
|
||||
)
|
||||
|
||||
# admin
|
||||
self._admin_menu.add_sideframe_item(_("Administration"), url=admin_reverse("index"))
|
||||
self._admin_menu.add_break(ADMINISTRATION_BREAK)
|
||||
|
||||
# cms users settings
|
||||
self._admin_menu.add_sideframe_item(_("User settings"), url=admin_reverse("cms_usersettings_change"))
|
||||
self._admin_menu.add_break(USER_SETTINGS_BREAK)
|
||||
|
||||
# clipboard
|
||||
if self.toolbar.edit_mode_active:
|
||||
# True if the clipboard exists and there's plugins in it.
|
||||
clipboard_is_bound = self.toolbar.clipboard_plugin
|
||||
|
||||
self._admin_menu.add_link_item(
|
||||
_("Clipboard..."),
|
||||
url="#",
|
||||
extra_classes=["cms-clipboard-trigger"],
|
||||
disabled=not clipboard_is_bound,
|
||||
)
|
||||
self._admin_menu.add_link_item(
|
||||
_("Clear clipboard"),
|
||||
url="#",
|
||||
extra_classes=["cms-clipboard-empty"],
|
||||
disabled=not clipboard_is_bound,
|
||||
)
|
||||
self._admin_menu.add_break(CLIPBOARD_BREAK)
|
||||
|
||||
# Disable toolbar
|
||||
self._admin_menu.add_link_item(
|
||||
_("Disable toolbar"), url="?%s" % get_cms_setting("CMS_TOOLBAR_URL__DISABLE")
|
||||
)
|
||||
self._admin_menu.add_break(TOOLBAR_DISABLE_BREAK)
|
||||
self._admin_menu.add_link_item(_("Shortcuts..."), url="#", extra_classes=("cms-show-shortcuts",))
|
||||
self._admin_menu.add_break(SHORTCUTS_BREAK)
|
||||
|
||||
# logout
|
||||
self.add_logout_button(self._admin_menu)
|
||||
|
||||
def add_users_button(self, parent):
|
||||
User = get_user_model()
|
||||
|
||||
if User in admin.site._registry:
|
||||
opts = User._meta
|
||||
|
||||
if self.request.user.has_perm("{}.{}".format(opts.app_label, get_permission_codename("change", opts))):
|
||||
user_changelist_url = admin_reverse(f"{opts.app_label}_{opts.model_name}_changelist")
|
||||
parent.add_sideframe_item(_("Users"), url=user_changelist_url)
|
||||
|
||||
def add_logout_button(self, parent):
|
||||
if self.page and not self.page.login_required:
|
||||
anon_can_access = page_permissions.user_can_view_page(
|
||||
user=AnonymousUser(),
|
||||
page=self.page,
|
||||
site=self.current_site,
|
||||
)
|
||||
else:
|
||||
anon_can_access = False
|
||||
|
||||
on_success = self.toolbar.REFRESH_PAGE if anon_can_access else "/"
|
||||
|
||||
# We'll show "Logout Joe Bloggs" if the name fields in auth.User are completed, else "Logout jbloggs". If
|
||||
# anything goes wrong, it'll just be "Logout".
|
||||
|
||||
user_name = self.get_username()
|
||||
logout_menu_text = _("Logout %s") % user_name if user_name else _("Logout")
|
||||
|
||||
parent.add_ajax_item(
|
||||
logout_menu_text,
|
||||
action=admin_reverse("logout"),
|
||||
active=True,
|
||||
on_success=on_success,
|
||||
method="POST",
|
||||
)
|
||||
|
||||
def add_language_menu(self):
|
||||
if settings.USE_I18N and not self._language_menu:
|
||||
languages = get_language_tuple(self.current_site.pk)
|
||||
if len(languages) > 1:
|
||||
# Menu only meaningful if more than one language is installed
|
||||
self._language_menu = self.toolbar.get_or_create_menu(
|
||||
LANGUAGE_MENU_IDENTIFIER, _("Language"), position=-1
|
||||
)
|
||||
language_changer = getattr(self.request, "_language_changer", DefaultLanguageChanger(self.request))
|
||||
for code, name in languages:
|
||||
try:
|
||||
url = language_changer(code)
|
||||
except NoReverseMatch:
|
||||
url = DefaultLanguageChanger(self.request)(code)
|
||||
if url:
|
||||
self._language_menu.add_link_item(name, url=url, active=self.current_lang == code)
|
||||
else:
|
||||
# We do not have to check every time the toolbar is created
|
||||
self._language_menu = True # Pretend the language menu is already there
|
||||
|
||||
def get_username(self, user=None, default=""):
|
||||
user = user or self.request.user
|
||||
try:
|
||||
name = user.get_full_name()
|
||||
if name:
|
||||
return name
|
||||
else:
|
||||
return user.get_username()
|
||||
except (AttributeError, NotImplementedError):
|
||||
return default
|
||||
|
||||
|
||||
@toolbar_pool.register
|
||||
class PageToolbar(CMSToolbar):
|
||||
_changed_admin_menu = None
|
||||
watch_models = [Page, PageType]
|
||||
|
||||
def get_page_content(self):
|
||||
if not getattr(self, "page", None):
|
||||
# No page, no page content
|
||||
return None
|
||||
if hasattr(self, "obj") and isinstance(self.obj, PageContent):
|
||||
# Toolbar object already set (e.g., in edit or preview mode)
|
||||
return self.obj
|
||||
# Get from db
|
||||
page_content = self.page.get_admin_content(language=self.current_lang, fallback=False)
|
||||
return page_content or None
|
||||
|
||||
def has_page_change_permission(self):
|
||||
if not hasattr(self, "page_change_permission"):
|
||||
self.page_change_permission = can_change_page(self.request) and self.toolbar.object_is_editable()
|
||||
return self.page_change_permission
|
||||
|
||||
def in_apphook(self):
|
||||
with force_language(self.toolbar.request_language):
|
||||
try:
|
||||
resolver = resolve(self.toolbar.request_path)
|
||||
except Resolver404:
|
||||
return False
|
||||
else:
|
||||
cms_views = ("render_object_edit", "render_object_preview", "render_object_structure", "details")
|
||||
return resolver.func.__name__ not in cms_views
|
||||
|
||||
def in_apphook_root(self):
|
||||
"""
|
||||
Returns True if the request is for a page handled by an apphook, but
|
||||
is also the page it is attached to.
|
||||
:return: Boolean
|
||||
"""
|
||||
page = getattr(self.request, "current_page", False)
|
||||
if page:
|
||||
language = get_language_from_request(self.request)
|
||||
return self.toolbar.request_path == page.get_absolute_url(language=language)
|
||||
return False
|
||||
|
||||
def get_on_delete_redirect_url(self):
|
||||
language = self.current_lang
|
||||
parent_page = self.page.parent if self.page else None
|
||||
|
||||
# if the current page has a parent in the request's current language redirect to it
|
||||
if parent_page and language in parent_page.get_languages():
|
||||
return get_object_preview_url(
|
||||
parent_page.pagecontent_set(manager="admin_manager").latest_content(language=language).first()
|
||||
)
|
||||
|
||||
# else redirect to root, do not redirect to Page.objects.get_home() because user could have deleted the last
|
||||
# page, if DEBUG == False this could cause a 404
|
||||
try:
|
||||
return reverse("pages-root")
|
||||
except NoReverseMatch:
|
||||
return admin_reverse("cms_pagecontent_changelist")
|
||||
|
||||
# Populate
|
||||
def populate(self):
|
||||
self.page = self.request.current_page
|
||||
self.page_content = self.get_page_content()
|
||||
self.permissions_activated = get_cms_setting("PERMISSION")
|
||||
self.change_admin_menu()
|
||||
self.add_page_menu()
|
||||
self.change_language_menu()
|
||||
|
||||
# Menus
|
||||
def change_language_menu(self):
|
||||
if self.toolbar.edit_mode_active and self.page:
|
||||
can_change = page_permissions.user_can_change_page(
|
||||
user=self.request.user,
|
||||
page=self.page,
|
||||
site=self.current_site,
|
||||
)
|
||||
else:
|
||||
can_change = False
|
||||
if can_change:
|
||||
language_menu = self.toolbar.get_menu(LANGUAGE_MENU_IDENTIFIER)
|
||||
if not language_menu:
|
||||
return None
|
||||
|
||||
languages = get_language_dict(self.current_site.pk)
|
||||
|
||||
remove = [(code, languages.get(code, code)) for code in self.page.get_languages() if code in languages]
|
||||
add = [lang for lang in languages.items() if lang not in remove]
|
||||
copy = [
|
||||
(code, name)
|
||||
for code, name in languages.items()
|
||||
if code != self.current_lang and (code, name) in remove
|
||||
]
|
||||
|
||||
if add or remove or copy:
|
||||
language_menu.add_break(ADD_PAGE_LANGUAGE_BREAK)
|
||||
|
||||
if add:
|
||||
add_plugins_menu = language_menu.get_or_create_menu(
|
||||
f"{LANGUAGE_MENU_IDENTIFIER}-add", _("Add Translation")
|
||||
)
|
||||
|
||||
page_add_url = admin_reverse("cms_pagecontent_add")
|
||||
|
||||
for code, name in add:
|
||||
url = add_url_parameters(
|
||||
page_add_url, cms_page=self.page.pk, parent_page=self.page.id, language=code
|
||||
)
|
||||
add_plugins_menu.add_modal_item(name, url=url)
|
||||
|
||||
if remove:
|
||||
remove_plugins_menu = language_menu.get_or_create_menu(
|
||||
f"{LANGUAGE_MENU_IDENTIFIER}-del", _("Delete Translation")
|
||||
)
|
||||
disabled = len(remove) == 1
|
||||
for code, name in remove:
|
||||
pagecontent = self.page.get_content_obj(code)
|
||||
if pagecontent:
|
||||
translation_delete_url = admin_reverse("cms_pagecontent_delete", args=(pagecontent.pk,))
|
||||
url = add_url_parameters(translation_delete_url, language=code)
|
||||
remove_plugins_menu.add_modal_item(name, url=url, disabled=disabled)
|
||||
|
||||
if copy:
|
||||
copy_plugins_menu = language_menu.get_or_create_menu(
|
||||
f"{LANGUAGE_MENU_IDENTIFIER}-copy", _("Copy all plugins")
|
||||
)
|
||||
title = _("from %s")
|
||||
question = _("Are you sure you want to copy all plugins from %s?")
|
||||
|
||||
for code, name in copy:
|
||||
pagecontent = self.page.get_content_obj(code)
|
||||
page_copy_url = admin_reverse("cms_pagecontent_copy_language", args=(pagecontent.pk,))
|
||||
copy_plugins_menu.add_ajax_item(
|
||||
title % name,
|
||||
action=page_copy_url,
|
||||
data={"source_language": code, "target_language": self.current_lang},
|
||||
question=question % name,
|
||||
on_success=self.toolbar.REFRESH_PAGE,
|
||||
)
|
||||
|
||||
def change_admin_menu(self):
|
||||
can_change_page = self.has_page_change_permission()
|
||||
|
||||
if not can_change_page:
|
||||
# Check if the user has permissions to change at least one page
|
||||
can_change_page = page_permissions.user_can_change_at_least_one_page(
|
||||
user=self.request.user,
|
||||
site=self.current_site,
|
||||
)
|
||||
|
||||
if not self._changed_admin_menu and can_change_page:
|
||||
admin_menu = self.toolbar.get_or_create_menu(ADMIN_MENU_IDENTIFIER)
|
||||
url = admin_reverse("cms_pagecontent_changelist") # cms page admin
|
||||
params = {"language": self.toolbar.request_language}
|
||||
if self.page:
|
||||
params["page_id"] = self.page.pk
|
||||
url = add_url_parameters(url, params)
|
||||
admin_menu.add_sideframe_item(_("Pages"), url=url, position=0)
|
||||
# Used to prevent duplicates
|
||||
self._changed_admin_menu = True
|
||||
|
||||
def add_page_menu(self):
|
||||
if self.page and self.page_content:
|
||||
edit_mode = self.toolbar.edit_mode_active
|
||||
refresh = self.toolbar.REFRESH_PAGE
|
||||
can_change = user_can_change_page(
|
||||
user=self.request.user,
|
||||
page=self.page,
|
||||
site=self.current_site,
|
||||
)
|
||||
|
||||
# menu for current page
|
||||
# NOTE: disabled if the current path is "deeper" into the
|
||||
# application's url patterns than its root. This is because
|
||||
# when the Content Manager is at the root of the app-hook,
|
||||
# some of the page options still make sense.
|
||||
current_page_menu = self.toolbar.get_or_create_menu(
|
||||
PAGE_MENU_IDENTIFIER, _("Page"), position=1, disabled=self.in_apphook() and not self.in_apphook_root()
|
||||
)
|
||||
|
||||
new_page_params = {"edit": 1, "site": self.page.site_id}
|
||||
new_sub_page_params = {"edit": 1, "parent_page": self.page.id}
|
||||
|
||||
add_page_url = admin_reverse("cms_pagecontent_add")
|
||||
advanced_url = admin_reverse("cms_page_advanced", args=(self.page.pk,))
|
||||
page_settings_url = admin_reverse("cms_pagecontent_change", args=(self.page_content.pk,))
|
||||
duplicate_page_url = admin_reverse("cms_pagecontent_duplicate", args=[self.page_content.pk])
|
||||
|
||||
can_add_root_page = page_permissions.user_can_add_page(
|
||||
user=self.request.user,
|
||||
site=self.current_site,
|
||||
)
|
||||
|
||||
if self.page.parent:
|
||||
new_page_params["parent_page"] = self.page.parent.id
|
||||
can_add_sibling_page = page_permissions.user_can_add_subpage(
|
||||
user=self.request.user,
|
||||
target=self.page.parent,
|
||||
)
|
||||
else:
|
||||
can_add_sibling_page = can_add_root_page
|
||||
|
||||
can_add_sub_page = page_permissions.user_can_add_subpage(
|
||||
user=self.request.user,
|
||||
target=self.page,
|
||||
)
|
||||
|
||||
# page operations menu
|
||||
add_page_menu = current_page_menu.get_or_create_menu(
|
||||
PAGE_MENU_ADD_IDENTIFIER,
|
||||
_("Create Page"),
|
||||
)
|
||||
|
||||
add_page_menu_modal_items = (
|
||||
(_("New Page"), new_page_params, can_add_sibling_page),
|
||||
(_("New Sub Page"), new_sub_page_params, can_add_sub_page),
|
||||
)
|
||||
|
||||
for title, params, has_perm in add_page_menu_modal_items:
|
||||
params.update(language=self.toolbar.request_language)
|
||||
add_page_menu.add_modal_item(
|
||||
title,
|
||||
url=add_url_parameters(add_page_url, params),
|
||||
disabled=not has_perm,
|
||||
)
|
||||
|
||||
add_page_menu.add_modal_item(
|
||||
_("Duplicate this Page"),
|
||||
url=add_url_parameters(duplicate_page_url, {"language": self.toolbar.request_language}),
|
||||
disabled=not can_add_sibling_page,
|
||||
)
|
||||
|
||||
# first break
|
||||
current_page_menu.add_break(PAGE_MENU_FIRST_BREAK)
|
||||
|
||||
# page settings
|
||||
page_settings_url = add_url_parameters(page_settings_url, language=self.toolbar.request_language)
|
||||
settings_disabled = not can_change
|
||||
current_page_menu.add_modal_item(
|
||||
_("Page settings"), url=page_settings_url, disabled=settings_disabled, on_close=refresh
|
||||
)
|
||||
|
||||
# advanced settings
|
||||
advanced_url = add_url_parameters(advanced_url, language=self.toolbar.request_language)
|
||||
can_change_advanced = self.page.has_advanced_settings_permission(self.request.user)
|
||||
can_change_permissions = get_cms_setting("PERMISSION") and self.page.has_change_permissions_permission(
|
||||
self.request.user
|
||||
)
|
||||
advanced_disabled = not can_change_advanced and not can_change_permissions
|
||||
current_page_menu.add_modal_item(_("Advanced settings"), url=advanced_url, disabled=advanced_disabled)
|
||||
|
||||
# templates menu
|
||||
if edit_mode:
|
||||
action = admin_reverse("cms_pagecontent_change_template", args=(self.page_content.pk,))
|
||||
|
||||
if get_cms_setting("TEMPLATES"):
|
||||
options = self.page_content.template_choices
|
||||
template_menu = _("Templates")
|
||||
else:
|
||||
options = [(placeholders[0], placeholders[2]) for placeholders in get_cms_setting("PLACEHOLDERS")]
|
||||
template_menu = _("Placeholders")
|
||||
if options:
|
||||
templates_menu = current_page_menu.get_or_create_menu(
|
||||
"templates",
|
||||
template_menu,
|
||||
disabled=not can_change,
|
||||
)
|
||||
|
||||
for path, name in options:
|
||||
active = self.page_content.template == path
|
||||
if path == TEMPLATE_INHERITANCE_MAGIC:
|
||||
templates_menu.add_break(TEMPLATE_MENU_BREAK)
|
||||
templates_menu.add_ajax_item(
|
||||
name, action=action, data={"template": path}, active=active, on_success=refresh
|
||||
)
|
||||
|
||||
# navigation toggle
|
||||
in_navigation = self.page_content.in_navigation
|
||||
nav_title = _("Hide in navigation") if in_navigation else _("Display in navigation")
|
||||
nav_action = admin_reverse("cms_pagecontent_change_innavigation", args=(self.page_content.pk,))
|
||||
current_page_menu.add_ajax_item(
|
||||
nav_title,
|
||||
action=nav_action,
|
||||
disabled=(not edit_mode or not can_change),
|
||||
on_success=refresh,
|
||||
)
|
||||
|
||||
# delete
|
||||
delete_url = admin_reverse("cms_page_delete", args=(self.page.pk,))
|
||||
delete_disabled = not edit_mode or not user_can_delete_page(self.request.user, page=self.page)
|
||||
on_delete_redirect_url = self.get_on_delete_redirect_url() if not delete_disabled else None
|
||||
current_page_menu.add_modal_item(
|
||||
_("Delete page"), url=delete_url, on_close=on_delete_redirect_url, disabled=delete_disabled
|
||||
)
|
||||
56
cms/cms_wizards.py
Executable file
56
cms/cms_wizards.py
Executable file
|
|
@ -0,0 +1,56 @@
|
|||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from cms.models import Page
|
||||
from cms.utils.page_permissions import user_can_add_page, user_can_add_subpage
|
||||
|
||||
from .forms.wizards import CreateCMSPageForm, CreateCMSSubPageForm
|
||||
from .wizards.wizard_base import Wizard
|
||||
|
||||
|
||||
class CMSPageWizard(Wizard):
|
||||
|
||||
def user_has_add_permission(self, user, page=None, **kwargs):
|
||||
site = kwargs.get("site")
|
||||
parent_page = page.parent if page else None
|
||||
if page and parent_page:
|
||||
# User is adding a page which will be a right
|
||||
# sibling to the current page.
|
||||
return user_can_add_subpage(user, target=parent_page, site=site or parent_page.site)
|
||||
elif page:
|
||||
return user_can_add_page(user, site=site or page.site)
|
||||
return user_can_add_page(user, site=site)
|
||||
|
||||
def get_success_url(self, obj, **kwargs):
|
||||
page_content = obj.pagecontent_set(manager="admin_manager").first()
|
||||
return super().get_success_url(page_content, **kwargs)
|
||||
|
||||
|
||||
class CMSSubPageWizard(Wizard):
|
||||
|
||||
def user_has_add_permission(self, user, page=None, **kwargs):
|
||||
if not page or page.application_urls:
|
||||
# We can't really add a sub-page to a non-existent page. Or to an
|
||||
# app-hooked page.
|
||||
return False
|
||||
return user_can_add_subpage(user, target=page)
|
||||
|
||||
def get_success_url(self, obj, **kwargs):
|
||||
page_content = obj.pagecontent_set(manager="admin_manager").first()
|
||||
return super().get_success_url(page_content, **kwargs)
|
||||
|
||||
|
||||
cms_page_wizard = CMSPageWizard(
|
||||
title=_("New page"),
|
||||
weight=100,
|
||||
form=CreateCMSPageForm,
|
||||
model=Page,
|
||||
description=_("Create a new page next to the current page.")
|
||||
)
|
||||
|
||||
cms_subpage_wizard = CMSSubPageWizard(
|
||||
title=_("New sub page"),
|
||||
weight=110,
|
||||
form=CreateCMSSubPageForm,
|
||||
model=Page,
|
||||
description=_("Create a page below the current page.")
|
||||
)
|
||||
90
cms/constants.py
Normal file
90
cms/constants.py
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
"""
|
||||
Collection of important constants used throughout django CMS.
|
||||
"""
|
||||
|
||||
|
||||
#: The token used to identify when a user selects "inherit" as template for a page.
|
||||
TEMPLATE_INHERITANCE_MAGIC = 'INHERIT'
|
||||
|
||||
REFRESH_PAGE = 'REFRESH_PAGE'
|
||||
"""
|
||||
Supplied to ``on_close`` arguments to refresh the current page when the frame is closed, for
|
||||
example:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from cms.constants import REFRESH_PAGE
|
||||
|
||||
self.toolbar.add_modal_item(
|
||||
'Modal item',
|
||||
url=modal_url,
|
||||
on_close=REFRESH_PAGE
|
||||
)
|
||||
"""
|
||||
|
||||
FOLLOW_REDIRECT = 'FOLLOW_REDIRECT'
|
||||
URL_CHANGE = 'URL_CHANGE'
|
||||
|
||||
#: Used as a position indicator in the toolbar: On the right side.
|
||||
RIGHT = object() # this is a trick so "foo is RIGHT" will only ever work for this, same goes for LEFT.
|
||||
|
||||
#: Used as a position indicator in the toolbar: On the left side.
|
||||
LEFT = object()
|
||||
|
||||
PAGE_TYPES_ID = "page_types"
|
||||
PAGE_TREE_POSITIONS = ('last-child', 'first-child', 'left', 'right')
|
||||
|
||||
#: Used for the ``limit_visibility_in_menu`` keyword argument to
|
||||
#: :func: `create_page`.Does not limit menu visibility.
|
||||
VISIBILITY_ALL = None
|
||||
|
||||
#: Used for the ``limit_visibility_in_menu`` keyword argument to :func: `create_page`.
|
||||
#: Limits menu visibility to authenticated users.
|
||||
VISIBILITY_USERS = 1
|
||||
|
||||
#: Used for the ``limit_visibility_in_menu`` keyword argument to :func: `create_page`.
|
||||
#: Limits menu visibility to anonymous(not authenticated) users.
|
||||
VISIBILITY_ANONYMOUS = 2
|
||||
|
||||
X_FRAME_OPTIONS_INHERIT = 0
|
||||
X_FRAME_OPTIONS_DENY = 1
|
||||
X_FRAME_OPTIONS_SAMEORIGIN = 2
|
||||
X_FRAME_OPTIONS_ALLOW = 3
|
||||
|
||||
PAGE_USERNAME_MAX_LENGTH = 255
|
||||
|
||||
SLUG_REGEXP = '[0-9A-Za-z-_.//]+'
|
||||
|
||||
#: Used for cache control headers: 0 seconds, i.e. now.
|
||||
EXPIRE_NOW = 0
|
||||
|
||||
#: Used for cache control headers: 365 * 24 * 3600 seconds, i.e. one year. HTTP specification says
|
||||
#: max caching should only be up to one year.
|
||||
MAX_EXPIRATION_TTL = 365 * 24 * 3600
|
||||
|
||||
|
||||
def _get_toolbar_js(plugin_type: str) -> str:
|
||||
return f'<script data-cms-{plugin_type} id="cms-{plugin_type}-%(pk)s" type="application/json">%(config)s</script>'
|
||||
|
||||
|
||||
PLUGIN_TOOLBAR_JS = _get_toolbar_js("plugin")
|
||||
PLACEHOLDER_TOOLBAR_JS = _get_toolbar_js("placeholder")
|
||||
|
||||
# In the permissions system we use user levels to determine
|
||||
# the depth in which the user has permissions.
|
||||
# This constant represents a user that can see pages at all depths.
|
||||
ROOT_USER_LEVEL = -1
|
||||
|
||||
GRANT_ALL_PERMISSIONS = 'All'
|
||||
|
||||
PUBLISH_COMMENT = "Publish"
|
||||
|
||||
SCRIPT_USERNAME = 'script'
|
||||
|
||||
CMS_CONFIG_NAME = 'cms_config'
|
||||
|
||||
MODAL_HTML_REDIRECT = '<body><a class="cms-view-new-object" target="_top" href="{url}">Redirecting...</a></body>'
|
||||
|
||||
#: Default chunk_size for QuerySet.iterator() calls on plugin querysets.
|
||||
#: Required since Django 5.0 when prefetch_related is applied.
|
||||
PLUGIN_ITERATOR_CHUNK_SIZE = 2000
|
||||
31
cms/context_processors.py
Normal file
31
cms/context_processors.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
from functools import cache
|
||||
|
||||
from django.utils.functional import lazy
|
||||
|
||||
from cms.utils.conf import get_cms_setting
|
||||
from cms.utils.page import get_page_template_from_request
|
||||
|
||||
|
||||
def cms_settings(request):
|
||||
"""
|
||||
Adds cms-related variables to the context.
|
||||
"""
|
||||
from menus.menu_pool import MenuRenderer
|
||||
|
||||
@cache
|
||||
def _get_menu_renderer():
|
||||
# We use lru_cache to avoid getting the manager
|
||||
# every time this function is called.
|
||||
from menus.menu_pool import menu_pool
|
||||
return menu_pool.get_renderer(request)
|
||||
|
||||
# Now use lazy() to avoid getting the menu renderer
|
||||
# up until the point is needed.
|
||||
# lazy() does not memoize results, is why lru_cache is needed.
|
||||
_get_menu_renderer = lazy(_get_menu_renderer, MenuRenderer)
|
||||
|
||||
return {
|
||||
'cms_menu_renderer': _get_menu_renderer(),
|
||||
'CMS_MEDIA_URL': get_cms_setting('MEDIA_URL'),
|
||||
'CMS_TEMPLATE': lambda: get_page_template_from_request(request),
|
||||
}
|
||||
85
cms/exceptions.py
Normal file
85
cms/exceptions.py
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
class PluginAlreadyRegistered(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class PluginNotRegistered(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class PluginLimitReached(Exception):
|
||||
"""Gets triggered when a placeholder has reached its plugin limit."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class AppAlreadyRegistered(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ToolbarAlreadyRegistered(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class ToolbarNotRegistered(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class NotImplemented(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class SubClassNeededError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class MissingFormError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class NoHomeFound(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class PermissionsException(Exception):
|
||||
"""Base permission exception"""
|
||||
|
||||
|
||||
class NoPermissionsException(PermissionsException):
|
||||
"""Can be fired when some violate action is performed on permission system."""
|
||||
|
||||
|
||||
class PublicIsUnmodifiable(Exception):
|
||||
"""A method was invoked on the public copy, but is only valid for the
|
||||
draft version"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class PublicVersionNeeded(Exception):
|
||||
"""A Public version of this page is needed"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class Deprecated(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class DuplicatePlaceholderWarning(Warning):
|
||||
pass
|
||||
|
||||
|
||||
class DontUsePageAttributeWarning(Warning):
|
||||
pass
|
||||
|
||||
|
||||
class LanguageError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class PluginConsistencyError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class PlaceholderNotFound(Exception):
|
||||
pass
|
||||
11
cms/extensions/__init__.py
Normal file
11
cms/extensions/__init__.py
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
from .admin import PageContentExtensionAdmin, PageExtensionAdmin
|
||||
from .extension_pool import extension_pool
|
||||
from .models import PageContentExtension, PageExtension
|
||||
|
||||
__all__ = [
|
||||
'extension_pool',
|
||||
'PageContentExtension',
|
||||
'PageContentExtensionAdmin',
|
||||
'PageExtension',
|
||||
'PageExtensionAdmin'
|
||||
]
|
||||
109
cms/extensions/admin.py
Normal file
109
cms/extensions/admin.py
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
from django.contrib import admin
|
||||
from django.contrib.admin.options import csrf_protect_m
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from django.http import HttpResponseRedirect
|
||||
from django.urls import reverse
|
||||
|
||||
from cms.models import Page, PageContent
|
||||
from cms.utils.page_permissions import user_can_change_page
|
||||
from cms.utils.urlutils import static_with_version
|
||||
|
||||
|
||||
class ExtensionAdmin(admin.ModelAdmin):
|
||||
change_form_template = "admin/cms/extensions/change_form.html"
|
||||
add_form_template = "admin/cms/extensions/change_form.html"
|
||||
|
||||
class Media:
|
||||
css = {"all": (static_with_version("cms/css/cms.admin.css"),)}
|
||||
|
||||
|
||||
class PageExtensionAdmin(ExtensionAdmin):
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
if not change and 'extended_object' in request.GET:
|
||||
obj.extended_object = Page.objects.get(pk=request.GET['extended_object'])
|
||||
page = Page.objects.get(pk=request.GET['extended_object'])
|
||||
else:
|
||||
page = obj.extended_object
|
||||
if not user_can_change_page(request.user, page):
|
||||
raise PermissionDenied()
|
||||
super().save_model(request, obj, form, change)
|
||||
|
||||
def delete_model(self, request, obj):
|
||||
if not obj.extended_object.has_change_permission(request.user):
|
||||
raise PermissionDenied()
|
||||
obj.delete()
|
||||
|
||||
def get_model_perms(self, request):
|
||||
"""
|
||||
Return empty perms dict thus hiding the model from admin index.
|
||||
"""
|
||||
return {}
|
||||
|
||||
@csrf_protect_m
|
||||
def add_view(self, request, form_url='', extra_context=None):
|
||||
"""
|
||||
Check if the page already has an extension object. If so, redirect to edit view instead.
|
||||
"""
|
||||
extended_object_id = request.GET.get('extended_object', False)
|
||||
if extended_object_id:
|
||||
try:
|
||||
page = Page.objects.get(pk=extended_object_id)
|
||||
extension = self.model.objects.get(extended_object=page)
|
||||
opts = self.model._meta
|
||||
change_url = reverse(
|
||||
f'admin:{opts.app_label}_{opts.model_name}_change',
|
||||
args=(extension.pk,),
|
||||
current_app=self.admin_site.name
|
||||
)
|
||||
return HttpResponseRedirect(change_url)
|
||||
except self.model.DoesNotExist:
|
||||
pass
|
||||
return super().add_view(request, form_url, extra_context)
|
||||
|
||||
|
||||
class PageContentExtensionAdmin(ExtensionAdmin):
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
if not change and 'extended_object' in request.GET:
|
||||
obj.extended_object = PageContent.admin_manager.get(pk=request.GET['extended_object'])
|
||||
content = PageContent.admin_manager.get(pk=request.GET['extended_object'])
|
||||
else:
|
||||
content = obj.extended_object
|
||||
if not user_can_change_page(request.user, page=content.page):
|
||||
raise PermissionDenied()
|
||||
super().save_model(request, obj, form, change)
|
||||
|
||||
def delete_model(self, request, obj):
|
||||
page = obj.extended_object.page
|
||||
|
||||
if not user_can_change_page(request.user, page):
|
||||
raise PermissionDenied()
|
||||
obj.delete()
|
||||
|
||||
def get_model_perms(self, request):
|
||||
"""
|
||||
Return empty perms dict thus hiding the model from admin index.
|
||||
"""
|
||||
return {}
|
||||
|
||||
@csrf_protect_m
|
||||
def add_view(self, request, form_url='', extra_context=None):
|
||||
"""
|
||||
Check if the page already has an extension object. If so, redirect to edit view instead.
|
||||
"""
|
||||
extended_object_id = request.GET.get('extended_object', False)
|
||||
if extended_object_id:
|
||||
try:
|
||||
content = PageContent.admin_manager.get(pk=extended_object_id)
|
||||
extension = self.model.objects.get(extended_object=content)
|
||||
opts = self.model._meta
|
||||
change_url = reverse(
|
||||
f'admin:{opts.app_label}_{opts.model_name}_change',
|
||||
args=(extension.pk,),
|
||||
current_app=self.admin_site.name
|
||||
)
|
||||
return HttpResponseRedirect(change_url)
|
||||
except self.model.DoesNotExist:
|
||||
pass
|
||||
return super().add_view(request, form_url, extra_context)
|
||||
94
cms/extensions/extension_pool.py
Normal file
94
cms/extensions/extension_pool.py
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
from cms.exceptions import SubClassNeededError
|
||||
|
||||
from .models import PageContentExtension, PageExtension
|
||||
|
||||
|
||||
class ExtensionPool:
|
||||
def __init__(self):
|
||||
self.page_extensions = set()
|
||||
self.page_content_extensions = set()
|
||||
|
||||
def register(self, extension):
|
||||
"""
|
||||
Registers the given extension.
|
||||
|
||||
Example::
|
||||
|
||||
class MyExtension(PageExtension):
|
||||
pass
|
||||
|
||||
extension_pool.register(MyExtension)
|
||||
|
||||
or as decorator::
|
||||
|
||||
@extension_pool.register
|
||||
class MyExtension(PageExtension):
|
||||
pass
|
||||
|
||||
"""
|
||||
|
||||
if issubclass(extension, PageExtension):
|
||||
self.page_extensions.add(extension)
|
||||
elif issubclass(extension, PageContentExtension):
|
||||
self.page_content_extensions.add(extension)
|
||||
else:
|
||||
raise SubClassNeededError(
|
||||
f'Extension has to subclass either {PageExtension} or {PageContentExtension}. {extension} does not!'
|
||||
)
|
||||
return extension
|
||||
|
||||
def unregister(self, extension):
|
||||
"""
|
||||
Unregisters the given extension. No error is thrown if given extension isn't an extension or wasn't
|
||||
registered yet.
|
||||
"""
|
||||
|
||||
try:
|
||||
if issubclass(extension, PageExtension):
|
||||
self.page_extensions.remove(extension)
|
||||
elif issubclass(extension, PageContentExtension):
|
||||
self.page_content_extensions.remove(extension)
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
def _copy_page_extensions(self, source_page, target_page, language):
|
||||
for extension in self.page_extensions:
|
||||
for instance in extension.objects.filter(extended_object=source_page):
|
||||
instance.copy(target_page, language)
|
||||
|
||||
def _copy_content_extensions(self, source_page, target_page, language):
|
||||
source_content = source_page.pagecontent_set(manager="admin_manager").current_content(language=language).get()
|
||||
target_content = target_page.pagecontent_set(manager="admin_manager").current_content(language=language).get()
|
||||
for extension in self.page_content_extensions:
|
||||
for instance in extension.objects.filter(extended_object=source_content):
|
||||
instance.copy(target_content, language)
|
||||
|
||||
def copy_extensions(self, source_page, target_page, languages=None):
|
||||
if not languages:
|
||||
languages = target_page.get_languages()
|
||||
if self.page_extensions:
|
||||
self._copy_page_extensions(source_page, target_page, None)
|
||||
for language in languages:
|
||||
if self.page_content_extensions:
|
||||
self._copy_content_extensions(source_page, target_page, language)
|
||||
|
||||
def get_page_extensions(self, page=None):
|
||||
extensions = []
|
||||
for extension in self.page_extensions:
|
||||
if page:
|
||||
extensions.extend(list(extension.objects.filter(extended_object=page)))
|
||||
else:
|
||||
extensions.extend(list(extension.objects.all()))
|
||||
return extensions
|
||||
|
||||
def get_page_content_extensions(self, page_content=None):
|
||||
extensions = []
|
||||
for extension in self.page_content_extensions:
|
||||
if page_content:
|
||||
extensions.extend(list(extension.objects.filter(extended_object=page_content)))
|
||||
else:
|
||||
extensions.extend(list(extension.objects.all()))
|
||||
return extensions
|
||||
|
||||
|
||||
extension_pool = ExtensionPool()
|
||||
69
cms/extensions/models.py
Normal file
69
cms/extensions/models.py
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
from django.db import models
|
||||
from django.db.models import ManyToManyField
|
||||
|
||||
from cms.models import Page, PageContent
|
||||
|
||||
|
||||
class BaseExtension(models.Model):
|
||||
extended_object = None
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def get_page(self): # pragma: no cover
|
||||
raise NotImplementedError('Function must be overwritten in subclasses and return the extended page object.')
|
||||
|
||||
def copy_relations(self, oldinstance, language):
|
||||
"""
|
||||
Copy relations like many to many or foreign key relations to the public version.
|
||||
Similar to the same named cms plugin function.
|
||||
|
||||
:param oldinstance: the draft version of the extension
|
||||
"""
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def _get_related_objects(cls):
|
||||
fields = cls._meta._get_fields(
|
||||
forward=False, reverse=True,
|
||||
include_parents=True,
|
||||
include_hidden=False,
|
||||
)
|
||||
return list(obj for obj in fields if not isinstance(obj.field, ManyToManyField))
|
||||
|
||||
def copy(self, target, language):
|
||||
"""
|
||||
This method copies this extension to an unrelated-target.
|
||||
"""
|
||||
clone = self.__class__.objects.get(pk=self.pk) # get a copy of this instance
|
||||
clone.pk = None
|
||||
clone.extended_object = target # set the new public object
|
||||
|
||||
# Nullify all concrete parent primary keys. See issue #5494
|
||||
for parent, field in clone._meta.parents.items():
|
||||
if field:
|
||||
setattr(clone, parent._meta.pk.attname, None)
|
||||
|
||||
clone.save()
|
||||
clone.copy_relations(self, language)
|
||||
return clone
|
||||
|
||||
|
||||
class PageExtension(BaseExtension):
|
||||
extended_object = models.OneToOneField(Page, on_delete=models.CASCADE, editable=False)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def get_page(self):
|
||||
return self.extended_object
|
||||
|
||||
|
||||
class PageContentExtension(BaseExtension):
|
||||
extended_object = models.OneToOneField(PageContent, on_delete=models.CASCADE, editable=False)
|
||||
|
||||
class Meta:
|
||||
abstract = True
|
||||
|
||||
def get_page(self):
|
||||
return self.extended_object.page
|
||||
101
cms/extensions/toolbar.py
Normal file
101
cms/extensions/toolbar.py
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
from django.urls import NoReverseMatch
|
||||
|
||||
from cms.models import PageContent
|
||||
from cms.toolbar_base import CMSToolbar
|
||||
from cms.utils.page_permissions import user_can_change_page
|
||||
from cms.utils.urlutils import admin_reverse
|
||||
|
||||
|
||||
class ExtensionToolbar(CMSToolbar):
|
||||
"""Offers simplified API for providing the user access to the admin of page extensions and
|
||||
page content extensions through the toolbar."""
|
||||
model = None
|
||||
page = None
|
||||
page_content = None
|
||||
|
||||
def _setup_extension_toolbar(self):
|
||||
"""
|
||||
Does all the sanity check for the current environment:
|
||||
|
||||
* that a page exists
|
||||
* permissions check on the current page
|
||||
|
||||
It returns the page menu or None if the above conditions are not met
|
||||
"""
|
||||
page = self._get_page()
|
||||
|
||||
if page and user_can_change_page(self.request.user, page=page):
|
||||
return self.toolbar.get_or_create_menu("page")
|
||||
return
|
||||
|
||||
def _get_page(self):
|
||||
if not self.page:
|
||||
obj = self.toolbar.get_object() # Try getting the PageContent object from the toolbar
|
||||
if isinstance(obj, PageContent):
|
||||
self.page = obj.page
|
||||
self.page_content = obj
|
||||
else:
|
||||
self.page = self.request.current_page # Otherwise get Page object from the request
|
||||
if self.page:
|
||||
self.page_content = self.page.get_content_obj(self.current_lang)
|
||||
return self.page
|
||||
|
||||
def get_page_extension_admin(self):
|
||||
"""
|
||||
Get the admin url for the page extension menu item, depending on whether a PageExtension instance exists
|
||||
for the current page or not.
|
||||
|
||||
Return a tuple of the current extension and the url; the extension is None if no instance exists,
|
||||
the url is None is no admin is registered for the extension.
|
||||
"""
|
||||
page = self._get_page()
|
||||
# Page extension
|
||||
try:
|
||||
page_extension = self.model.objects.get(extended_object_id=page.pk)
|
||||
except self.model.DoesNotExist:
|
||||
page_extension = None
|
||||
try:
|
||||
app_label, model_name = self.model._meta.app_label, self.model.__name__.lower()
|
||||
if page_extension:
|
||||
admin_url = admin_reverse(f"{app_label}_{model_name}_change", args=(page_extension.pk,))
|
||||
else:
|
||||
admin_url = "{}?extended_object={}".format(
|
||||
admin_reverse(f"{app_label}_{model_name}_add"), self.page.pk
|
||||
)
|
||||
except NoReverseMatch: # pragma: no cover
|
||||
admin_url = None
|
||||
return page_extension, admin_url
|
||||
|
||||
def get_page_content_extension_admin(self, page_content_obj=None):
|
||||
"""
|
||||
Get the admin url for the page content extensions menu item, depending on whether a
|
||||
:class:`~cms.extensions.models.PageContentExtension` instance exists for the
|
||||
:class:`~cms.models.contentmodels.PageContent` displayed.
|
||||
|
||||
Return a tuple of the page content extension and the url; the extension is None
|
||||
if no instance exists, the url is None is no admin is registered for the extension.
|
||||
"""
|
||||
self._get_page()
|
||||
page_content = page_content_obj or self.page_content
|
||||
try:
|
||||
pagecontent_extension = self.model.objects.get(extended_object_id=page_content.pk)
|
||||
except self.model.DoesNotExist:
|
||||
pagecontent_extension = None
|
||||
try:
|
||||
app_label, model_name = self.model._meta.app_label, self.model.__name__.lower()
|
||||
if pagecontent_extension:
|
||||
admin_url = admin_reverse(f"{app_label}_{model_name}_change", args=(pagecontent_extension.pk,))
|
||||
else:
|
||||
admin_url = "{}?extended_object={}".format(
|
||||
admin_reverse(f"{app_label}_{model_name}_add"), page_content.pk
|
||||
)
|
||||
except NoReverseMatch: # pragma: no cover
|
||||
admin_url = None
|
||||
return pagecontent_extension, admin_url
|
||||
|
||||
def _get_sub_menu(self, current_menu, key, label, position=None):
|
||||
"""
|
||||
Utility function to get a submenu of the current menu
|
||||
"""
|
||||
extension_menu = current_menu.get_or_create_menu(key, label, position=position)
|
||||
return extension_menu
|
||||
0
cms/forms/__init__.py
Normal file
0
cms/forms/__init__.py
Normal file
116
cms/forms/fields.py
Normal file
116
cms/forms/fields.py
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
from django import forms
|
||||
from django.contrib.admin.widgets import RelatedFieldWidgetWrapper
|
||||
from django.core.validators import EMPTY_VALUES
|
||||
from django.forms import ChoiceField
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from cms.forms.utils import get_page_choices, get_site_choices
|
||||
from cms.forms.validators import validate_url
|
||||
from cms.forms.widgets import PageSelectWidget, PageSmartLinkWidget
|
||||
from cms.models.pagemodel import Page
|
||||
|
||||
|
||||
class PageSelectFormField(forms.MultiValueField):
|
||||
"""
|
||||
Behaves like a :class:`django.forms.ModelChoiceField` field for the
|
||||
:class:`cms.models.pagemodel.Page` model, but displays itself as a split
|
||||
field with a select drop-down for the site and one for the page. It also
|
||||
indents the page names based on what level they're on, so that the page
|
||||
select drop-down is easier to use. This takes the same arguments as
|
||||
:class:`django.forms.ModelChoiceField`.
|
||||
"""
|
||||
widget = PageSelectWidget
|
||||
default_error_messages = {
|
||||
'invalid_site': _('Select a valid site'),
|
||||
'invalid_page': _('Select a valid page'),
|
||||
}
|
||||
|
||||
def __init__(self, queryset=None, empty_label="---------", cache_choices=False,
|
||||
required=True, widget=None, to_field_name=None, limit_choices_to=None, *args, **kwargs):
|
||||
errors = self.default_error_messages.copy()
|
||||
if 'error_messages' in kwargs:
|
||||
errors.update(kwargs['error_messages'])
|
||||
self.limit_choices_to = limit_choices_to
|
||||
kwargs['required'] = required
|
||||
fields = (
|
||||
ChoiceField(choices=get_site_choices, required=False, error_messages={'invalid': errors['invalid_site']}),
|
||||
ChoiceField(choices=get_page_choices, required=False, error_messages={'invalid': errors['invalid_page']}),
|
||||
)
|
||||
|
||||
# Remove the unexpected blank kwarg if it's supplied,
|
||||
# causes an error where the MultiValueField doesn't expect it
|
||||
# https://github.com/django/django/commit/da79ee472d803963dc3ea81ee67767dc06068aac
|
||||
if 'blank' in kwargs:
|
||||
del kwargs['blank']
|
||||
|
||||
super().__init__(fields, *args, **kwargs)
|
||||
|
||||
def compress(self, data_list):
|
||||
if data_list:
|
||||
page_id = data_list[1]
|
||||
|
||||
if page_id in EMPTY_VALUES:
|
||||
if not self.required:
|
||||
return None
|
||||
raise forms.ValidationError(self.error_messages['invalid_page'])
|
||||
return Page.objects.get(pk=page_id)
|
||||
return None
|
||||
|
||||
def has_changed(self, initial, data):
|
||||
is_empty = data and (len(data) >= 2 and data[1] in [None, ''])
|
||||
|
||||
if isinstance(self.widget, RelatedFieldWidgetWrapper):
|
||||
self.widget.decompress = self.widget.widget.decompress
|
||||
|
||||
if is_empty and initial is None:
|
||||
# when empty data will have [u'1', u'', u''] as value
|
||||
# this will cause django to always return True because of the '1'
|
||||
# so we simply follow django's default behavior when initial is None and data is "empty"
|
||||
data = ['' for x in range(0, len(data))]
|
||||
return super().has_changed(initial, data)
|
||||
|
||||
def _has_changed(self, initial, data):
|
||||
return self.has_changed(initial, data)
|
||||
|
||||
|
||||
class PageSmartLinkField(forms.CharField):
|
||||
"""
|
||||
A field making use of ``cms.forms.widgets.PageSmartLinkWidget``.
|
||||
This field will offer you a list of matching internal pages as you type.
|
||||
You can either pick one or enter an arbitrary URL to create a non-existing entry.
|
||||
Takes a `placeholder_text` argument to define the text displayed inside the
|
||||
input before you type.
|
||||
|
||||
The widget uses an ajax request to try to find pages match. It will try to find
|
||||
case-insensitive matches amongst public and published pages on the `title`, `path`,
|
||||
`page_title`, `menu_title` fields.
|
||||
|
||||
.. note::
|
||||
|
||||
``PageSmartLinkField`` stores its value as a plain URL string, so links
|
||||
do not follow pages that are later moved or renamed. For dynamically
|
||||
linking to pages, other CMS content, or external URLs, the preferred
|
||||
approach is the ``LinkField`` provided by
|
||||
`djangocms-link <https://github.com/django-cms/djangocms-link>`_, which
|
||||
keeps references stable across moves and supports a configurable set of
|
||||
link targets.
|
||||
"""
|
||||
widget = PageSmartLinkWidget
|
||||
default_validators = [validate_url]
|
||||
|
||||
def __init__(self, max_length=None, min_length=None, placeholder_text=None,
|
||||
ajax_view=None, *args, **kwargs):
|
||||
self.placeholder_text = placeholder_text
|
||||
widget = self.widget(ajax_view=ajax_view)
|
||||
super().__init__(
|
||||
max_length=max_length, min_length=min_length, widget=widget, *args, **kwargs
|
||||
)
|
||||
|
||||
def widget_attrs(self, widget):
|
||||
attrs = super().widget_attrs(widget)
|
||||
attrs.update({'placeholder_text': self.placeholder_text})
|
||||
return attrs
|
||||
|
||||
def clean(self, value):
|
||||
value = self.to_python(value).strip()
|
||||
return super().clean(value)
|
||||
15
cms/forms/login.py
Normal file
15
cms/forms/login.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
from django import forms
|
||||
from django.contrib.auth.forms import AuthenticationForm
|
||||
|
||||
|
||||
class CMSToolbarLoginForm(AuthenticationForm):
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
kwargs['prefix'] = kwargs.get('prefix', 'cms')
|
||||
self.fields['username'].widget = forms.TextInput(
|
||||
attrs={'required': 'required'}
|
||||
)
|
||||
self.fields['password'].widget = forms.PasswordInput(
|
||||
attrs={'required': 'required'}
|
||||
)
|
||||
107
cms/forms/utils.py
Normal file
107
cms/forms/utils.py
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
from django.contrib.sites.models import Site
|
||||
from django.db.models import Prefetch
|
||||
from django.db.models.signals import post_delete, post_save
|
||||
from django.utils.html import escape
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from cms.cache.choices import (
|
||||
_page_cache_key,
|
||||
_site_cache_key,
|
||||
clean_page_choices_cache,
|
||||
clean_site_choices_cache,
|
||||
)
|
||||
from cms.models import Page
|
||||
from cms.utils import i18n
|
||||
|
||||
|
||||
def get_sites():
|
||||
sites = (
|
||||
Site
|
||||
.objects
|
||||
.filter(djangocms_pages__isnull=False)
|
||||
.order_by('name')
|
||||
.distinct()
|
||||
)
|
||||
return sites
|
||||
|
||||
|
||||
def get_page_choices_for_site(site, language):
|
||||
from cms.models import PageContent
|
||||
|
||||
fallbacks = i18n.get_fallback_languages(language, site_id=site.pk)
|
||||
languages = [language] + fallbacks
|
||||
translation_lookup = Prefetch(
|
||||
'pagecontent_set',
|
||||
to_attr='filtered_translations',
|
||||
queryset=PageContent.objects.filter(language__in=languages).only('pk', 'page', 'language', 'title')
|
||||
)
|
||||
pages = (
|
||||
Page
|
||||
.objects
|
||||
.on_site(site)
|
||||
.prefetch_related(translation_lookup)
|
||||
.order_by('path')
|
||||
.only('pk')
|
||||
)
|
||||
|
||||
for page in pages:
|
||||
translations = page.filtered_translations
|
||||
pagecontent_by_language = {trans.language: trans.title for trans in translations}
|
||||
|
||||
for lang in languages:
|
||||
# EmptyPageContent is used to prevent the cms from trying
|
||||
# to find a translation in the database
|
||||
if lang in pagecontent_by_language:
|
||||
title = pagecontent_by_language[lang]
|
||||
indent = " " * (page.depth - 1)
|
||||
label = mark_safe(f"{indent}{escape(title)}")
|
||||
yield (page.pk, label)
|
||||
break
|
||||
|
||||
|
||||
def update_site_and_page_choices(language=None):
|
||||
if language is None:
|
||||
language = i18n.get_current_language()
|
||||
|
||||
site_choices = []
|
||||
page_choices = [('', '----')]
|
||||
site_choices_key = _site_cache_key(language)
|
||||
page_choices_key = _page_cache_key(language)
|
||||
|
||||
for site in get_sites():
|
||||
_page_choices = list(get_page_choices_for_site(site, language))
|
||||
site_choices.append((site.pk, site.name))
|
||||
page_choices.append((site.name, _page_choices))
|
||||
|
||||
from django.core.cache import cache
|
||||
|
||||
# We set it to 1 day here because we actively invalidate this cache.
|
||||
cache.set(site_choices_key, site_choices, 86400)
|
||||
cache.set(page_choices_key, page_choices, 86400)
|
||||
return site_choices, page_choices
|
||||
|
||||
|
||||
def get_site_choices(lang=None):
|
||||
from django.core.cache import cache
|
||||
|
||||
lang = lang or i18n.get_current_language()
|
||||
site_choices = cache.get(_site_cache_key(lang))
|
||||
if site_choices is None:
|
||||
site_choices, _ = update_site_and_page_choices(lang)
|
||||
return site_choices
|
||||
|
||||
|
||||
def get_page_choices(lang=None):
|
||||
from django.core.cache import cache
|
||||
|
||||
lang = lang or i18n.get_current_language()
|
||||
page_choices = cache.get(_page_cache_key(lang))
|
||||
if page_choices is None:
|
||||
_, page_choices = update_site_and_page_choices(lang)
|
||||
return page_choices
|
||||
|
||||
|
||||
post_save.connect(clean_page_choices_cache, sender=Page)
|
||||
post_save.connect(clean_site_choices_cache, sender=Site)
|
||||
post_delete.connect(clean_page_choices_cache, sender=Page)
|
||||
post_delete.connect(clean_site_choices_cache, sender=Site)
|
||||
117
cms/forms/validators.py
Normal file
117
cms/forms/validators.py
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
from typing import TYPE_CHECKING, Optional
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import RegexValidator, URLValidator
|
||||
from django.utils.html import escape
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext
|
||||
|
||||
from cms.utils.urlutils import admin_reverse, path_segment_regex, relative_url_regex
|
||||
|
||||
if TYPE_CHECKING:
|
||||
# Only needed for type hinting - avoid circular import
|
||||
from cms.models.pagemodel import Page
|
||||
|
||||
|
||||
def validate_relative_url(value):
|
||||
RegexValidator(regex=relative_url_regex)(value)
|
||||
|
||||
|
||||
def validate_url(value):
|
||||
if "<" in value or ">" in value:
|
||||
# ``relative_url_regex`` excludes angle brackets, but the absolute fallback below
|
||||
# is Django's ``URLValidator``, whose path pattern accepts them as long as the
|
||||
# value carries no space (``https://example.com/<script>alert(1)</script>``).
|
||||
# Reject them before either branch runs, so markup cannot enter through a scheme.
|
||||
raise ValidationError(
|
||||
gettext("Enter a valid relative or absolute URL."),
|
||||
code="invalid",
|
||||
)
|
||||
|
||||
try:
|
||||
# Validate relative urls first
|
||||
validate_relative_url(value)
|
||||
except ValidationError:
|
||||
# Fallback to absolute urls
|
||||
URLValidator()(value)
|
||||
else:
|
||||
# ``relative_url_regex`` only constrains the *characters* of a relative
|
||||
# path, not the part before the first slash. Values such as
|
||||
# ``javascript:alert(1)/x``, ``data:text/html,...`` or ``vbscript:.../x``
|
||||
# therefore match the relative branch while still carrying a scheme that
|
||||
# executes script when the value is later rendered into an ``href``
|
||||
# (CWE-79). A genuine relative URL has no scheme, so reject any value
|
||||
# that does. (Off-site targets such as ``https://example.com`` or
|
||||
# ``//example.com`` are intentionally permitted -- this field allows
|
||||
# absolute redirects -- and navigate rather than execute script.)
|
||||
if urlsplit(value).scheme:
|
||||
raise ValidationError(
|
||||
gettext("Enter a valid relative or absolute URL."),
|
||||
code="invalid",
|
||||
)
|
||||
|
||||
|
||||
def validate_path_segment(value):
|
||||
"""Validate a single path segment, i.e. a path that contains no "/"."""
|
||||
RegexValidator(
|
||||
regex=path_segment_regex,
|
||||
message=gettext("Enter a valid relative or absolute URL."),
|
||||
code="invalid",
|
||||
)(value)
|
||||
|
||||
|
||||
def validate_path(value):
|
||||
"""Validate a page path.
|
||||
|
||||
Unlike :func:`validate_url` this also accepts a single path segment such as ``contact``,
|
||||
which ``relative_url_regex`` -- and therefore :func:`validate_url` -- never matches.
|
||||
"""
|
||||
if "/" in value:
|
||||
validate_url(value)
|
||||
else:
|
||||
validate_path_segment(value)
|
||||
|
||||
|
||||
def validate_url_uniqueness(
|
||||
site, path: str, language: str, user_language: str | None = None, exclude_page: Optional["Page"] = None
|
||||
):
|
||||
"""Checks for conflicting urls"""
|
||||
from cms.models.pagemodel import PageUrl
|
||||
|
||||
# Single-segment paths used to skip validation entirely, so an "Overwrite URL" without a
|
||||
# "/" could hold arbitrary characters.
|
||||
validate_path(path)
|
||||
|
||||
path = path.strip("/")
|
||||
page_urls = PageUrl.objects.get_for_site(site, language=language).filter(path=path)
|
||||
|
||||
if exclude_page:
|
||||
page_urls = page_urls.exclude(page=exclude_page.pk)
|
||||
|
||||
try:
|
||||
conflict_page = page_urls[0].page
|
||||
except IndexError:
|
||||
return True
|
||||
|
||||
conflict_translation = conflict_page.get_content_obj(language, fallback=False)
|
||||
|
||||
if conflict_translation: # No empty page content
|
||||
change_url = admin_reverse("cms_pagecontent_change", args=[conflict_translation.pk])
|
||||
else:
|
||||
change_url = "" # Empty page has no slug
|
||||
if user_language:
|
||||
change_url += f"?language={user_language}"
|
||||
|
||||
conflict_url = f'<a href="{change_url}" target="_blank">{escape(str(conflict_translation.title))}</a>'
|
||||
|
||||
if exclude_page:
|
||||
message = gettext("Page %(conflict_page)s has the same url '%(url)s' as current page \"%(instance)s\".")
|
||||
else:
|
||||
message = gettext("Page %(conflict_page)s has the same url '%(url)s' as current page.")
|
||||
message = message % {
|
||||
"conflict_page": conflict_url,
|
||||
"url": escape(path),
|
||||
"instance": escape(exclude_page.get_title(language)) if exclude_page else "",
|
||||
}
|
||||
raise ValidationError(mark_safe(message))
|
||||
249
cms/forms/widgets.py
Normal file
249
cms/forms/widgets.py
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
from django.contrib.auth import get_permission_codename
|
||||
from django.contrib.sites.models import Site
|
||||
from django.forms.widgets import MultiWidget, Select, TextInput
|
||||
from django.templatetags.static import static
|
||||
from django.urls import NoReverseMatch, reverse_lazy
|
||||
from django.utils.encoding import force_str
|
||||
from django.utils.html import escape, format_html
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from cms.forms.utils import get_page_choices, get_site_choices
|
||||
from cms.models import Page, PageUser
|
||||
from cms.utils.urlutils import admin_reverse, static_with_version
|
||||
|
||||
|
||||
class PageSelectWidget(MultiWidget):
|
||||
"""A widget that allows selecting a page by first selecting a site and then
|
||||
a page on that site in a two-step process.
|
||||
"""
|
||||
template_name = 'cms/widgets/pageselectwidget.html'
|
||||
|
||||
class Media:
|
||||
js = (
|
||||
static_with_version('cms/js/dist/bundle.forms.pageselectwidget.min.js'),
|
||||
)
|
||||
|
||||
def __init__(self, site_choices=None, page_choices=None, attrs=None):
|
||||
if attrs is not None:
|
||||
self.attrs = attrs.copy()
|
||||
else:
|
||||
self.attrs = {}
|
||||
self.choices = []
|
||||
super().__init__((Select, Select, Select), attrs)
|
||||
|
||||
def decompress(self, value):
|
||||
"""
|
||||
receives a page_id in value and returns the site_id and page_id
|
||||
of that page or the current site_id and None if no page_id is given.
|
||||
"""
|
||||
if value:
|
||||
page = Page.objects.get(pk=value)
|
||||
return [page.site_id, page.pk, page.pk]
|
||||
site = Site.objects.first()
|
||||
return [site.pk, None, None]
|
||||
|
||||
def _has_changed(self, initial, data):
|
||||
# THIS IS A COPY OF django.forms.widgets.Widget._has_changed()
|
||||
# (except for the first if statement)
|
||||
|
||||
"""
|
||||
Return True if data differs from initial.
|
||||
"""
|
||||
# For purposes of seeing whether something has changed, None is
|
||||
# the same as an empty string, if the data or initial value we get
|
||||
# is None, replace it w/ ''.
|
||||
if data is None or (len(data) >= 2 and data[1] in [None, '']):
|
||||
data_value = ''
|
||||
else:
|
||||
data_value = data
|
||||
if initial is None:
|
||||
initial_value = ''
|
||||
else:
|
||||
initial_value = initial
|
||||
if force_str(initial_value) != force_str(data_value):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _build_widgets(self):
|
||||
site_choices = get_site_choices()
|
||||
page_choices = get_page_choices()
|
||||
self.site_choices = site_choices
|
||||
self.choices = page_choices
|
||||
self.widgets = (
|
||||
Select(choices=site_choices),
|
||||
Select(choices=[('', '----')]),
|
||||
Select(choices=self.choices, attrs={'style': "display:none;"}),
|
||||
)
|
||||
|
||||
def get_context(self, name, value, attrs):
|
||||
self._build_widgets()
|
||||
context = super().get_context(name, value, attrs)
|
||||
context['widget']['script_data'] = {"name": name}
|
||||
return context
|
||||
|
||||
def format_output(self, rendered_widgets):
|
||||
return ' '.join(rendered_widgets)
|
||||
|
||||
|
||||
class PageSmartLinkWidget(TextInput):
|
||||
"""Presents the user with a Select2 widget to select a page and returns the link to this page as a string."""
|
||||
template_name = 'cms/widgets/pagesmartlinkwidget.html'
|
||||
|
||||
class Media:
|
||||
css = {
|
||||
'screen': (
|
||||
'admin/css/vendor/select2/select2.css',
|
||||
'admin/css/autocomplete.css',
|
||||
),
|
||||
}
|
||||
js = (
|
||||
'admin/js/vendor/jquery/jquery.js',
|
||||
'admin/js/vendor/select2/select2.full.js',
|
||||
'admin/js/jquery.init.js',
|
||||
static_with_version('cms/js/dist/bundle.forms.pagesmartlinkwidget.min.js'),
|
||||
)
|
||||
|
||||
def __init__(self, attrs=None, ajax_view=None):
|
||||
super().__init__(attrs)
|
||||
self.ajax_url = self.get_ajax_url(ajax_view=ajax_view)
|
||||
|
||||
def get_ajax_url(self, ajax_view):
|
||||
try:
|
||||
return reverse_lazy(ajax_view)
|
||||
except NoReverseMatch:
|
||||
raise Exception(
|
||||
'You should provide an ajax_view argument that can be reversed to the PageSmartLinkWidget'
|
||||
)
|
||||
|
||||
def _build_script_data(self, name, value, attrs):
|
||||
return {
|
||||
"id": attrs.get('id', ''),
|
||||
"text": str(attrs.get('placeholder_text', '')),
|
||||
"lang": self.language,
|
||||
"url": force_str(self.ajax_url),
|
||||
}
|
||||
|
||||
def get_context(self, name, value, attrs):
|
||||
context = super().get_context(name, value, attrs)
|
||||
context['widget']['script_data'] = self._build_script_data(name, value, context['widget']['attrs'])
|
||||
return context
|
||||
|
||||
|
||||
class UserSelectAdminWidget(Select):
|
||||
"""Special widget used in page permission inlines, because we have to render
|
||||
an add user (plus) icon, but point it somewhere else - to special user creation
|
||||
view, which is accessible only if user haves "add user" permissions.
|
||||
|
||||
Current user should be assigned to widget in form constructor as an user
|
||||
attribute.
|
||||
"""
|
||||
def render(self, name, value, attrs=None, choices=(), renderer=None):
|
||||
output = [super().render(name, value, attrs, renderer=renderer)]
|
||||
if hasattr(self, 'user') and (
|
||||
self.user.is_superuser or self.user.has_perm(
|
||||
PageUser._meta.app_label + '.' + get_permission_codename('add', PageUser._meta))
|
||||
):
|
||||
# append + icon
|
||||
add_url = admin_reverse('cms_pageuser_add')
|
||||
output.append(format_html(
|
||||
'<a href="{}?_popup=1" class="related-widget-wrapper-link add-related"'
|
||||
' id="add_id_{}" data-popup="yes" title="{}">'
|
||||
'<img src="{}" alt="{}" width="20" height="20"></a>',
|
||||
add_url,
|
||||
name,
|
||||
_('Add another'),
|
||||
static('admin/img/icon-addlink.svg'),
|
||||
_('Add'),
|
||||
))
|
||||
return mark_safe(''.join(output))
|
||||
|
||||
|
||||
class AppHookSelect(Select):
|
||||
|
||||
"""Special widget used for the App Hook selector in the Advanced Settings
|
||||
of the Page Admin. It adds support for a data attribute per option and
|
||||
includes supporting JS into the page.
|
||||
"""
|
||||
|
||||
class Media:
|
||||
js = (
|
||||
static_with_version('cms/js/dist/bundle.forms.apphookselect.min.js'),
|
||||
)
|
||||
|
||||
def __init__(self, attrs=None, choices=(), app_namespaces={}):
|
||||
self.app_namespaces = app_namespaces
|
||||
super().__init__(attrs, choices)
|
||||
|
||||
def create_option(self, name, value, label, selected, index, subindex=None, attrs=None):
|
||||
option = super().create_option(name, value, label, selected, index, subindex, attrs)
|
||||
if value in self.app_namespaces:
|
||||
option['attrs']['data-namespace'] = escape(self.app_namespaces[value])
|
||||
return option
|
||||
|
||||
def _build_option(self, selected_choices, option_value, option_label):
|
||||
if option_value is None:
|
||||
option_value = ''
|
||||
option_value = force_str(option_value)
|
||||
if option_value in selected_choices:
|
||||
selected_html = mark_safe(' selected="selected"')
|
||||
if not self.allow_multiple_selected:
|
||||
# Only allow for a single selection.
|
||||
selected_choices.remove(option_value)
|
||||
else:
|
||||
selected_html = ''
|
||||
|
||||
if option_value in self.app_namespaces:
|
||||
data_html = mark_safe(' data-namespace="%s"' % escape(self.app_namespaces[option_value]))
|
||||
else:
|
||||
data_html = ''
|
||||
return option_value, selected_html, data_html, force_str(option_label)
|
||||
|
||||
def render_option(self, selected_choices, option_value, option_label):
|
||||
option_data = self._build_option(selected_choices, option_value, option_label)
|
||||
return '<option value="%s"%s%s>%s</option>' % option_data
|
||||
|
||||
|
||||
class ApplicationConfigSelect(Select):
|
||||
"""
|
||||
Special widget -populate by javascript- that shows application configurations
|
||||
depending on selected Apphooks.
|
||||
|
||||
Required data are injected in the page as JSON data that forms.apphookselect.js
|
||||
uses to create the appropriate data structure.
|
||||
|
||||
A stub 'addlink' link is created and filled in with the correct URL by the same
|
||||
javascript.
|
||||
"""
|
||||
template_name = 'cms/widgets/applicationconfigselect.html'
|
||||
|
||||
class Media:
|
||||
js = (
|
||||
static_with_version('cms/js/dist/bundle.forms.apphookselect.min.js'),
|
||||
)
|
||||
|
||||
def __init__(self, attrs=None, choices=(), app_configs={}):
|
||||
self.app_configs = app_configs
|
||||
super().__init__(attrs, choices)
|
||||
|
||||
def _build_script_data(self, name, value, attrs):
|
||||
configs = {
|
||||
str(application): [[str(config.pk), str(config)] for config in cms_app.get_configs()]
|
||||
for application, cms_app in self.app_configs.items()
|
||||
}
|
||||
urls = {
|
||||
str(application): cms_app.get_config_add_url()
|
||||
for application, cms_app in self.app_configs.items()
|
||||
}
|
||||
|
||||
return {
|
||||
"apphooks_configuration": configs,
|
||||
"apphooks_configuration_url": urls,
|
||||
"apphooks_configuration_value": str(value) if value is not None else "",
|
||||
# apphook_configuration_value needs to correspond to correspond to str(config.pk)
|
||||
}
|
||||
|
||||
def get_context(self, name, value, attrs):
|
||||
context = super().get_context(name, value, attrs)
|
||||
context['widget']['script_data'] = self._build_script_data(name, value, context['widget']['attrs'])
|
||||
return context
|
||||
176
cms/forms/wizards.py
Normal file
176
cms/forms/wizards.py
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
from django import forms
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import transaction
|
||||
from django.utils.html import strip_tags
|
||||
from django.utils.text import slugify
|
||||
from django.utils.translation import gettext, gettext_lazy as _
|
||||
|
||||
from cms.admin.forms import AddPageForm
|
||||
from cms.plugin_pool import plugin_pool
|
||||
from cms.utils import permissions
|
||||
from cms.utils.conf import get_cms_setting
|
||||
from cms.utils.page import get_available_slug
|
||||
from cms.utils.page_permissions import user_can_add_page, user_can_add_subpage
|
||||
|
||||
try:
|
||||
# djangocms_text is not guaranteed to be available
|
||||
from djangocms_text.widgets import TextEditorWidget
|
||||
text_widget = TextEditorWidget
|
||||
except ImportError:
|
||||
text_widget = forms.Textarea
|
||||
|
||||
|
||||
class CreateCMSPageForm(AddPageForm):
|
||||
sub_page_form = False
|
||||
|
||||
# Field overrides
|
||||
menu_title = None
|
||||
page_title = None
|
||||
meta_description = None
|
||||
|
||||
content = forms.CharField(
|
||||
label=_('Content'), widget=text_widget, required=False,
|
||||
help_text=_("Optional. If supplied, will be automatically added "
|
||||
"within a new text plugin.")
|
||||
)
|
||||
|
||||
class Media:
|
||||
js = (
|
||||
# This simply adds some JS for
|
||||
# hiding/showing the content field based on the selection of this select.
|
||||
'cms/js/widgets/wizard.pagetypeselect.js',
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.fields['title'].help_text = _("Provide a title for the new page.")
|
||||
self.fields['slug'].required = False
|
||||
self.fields['slug'].help_text = _("Leave empty for automatic slug, or override as required.")
|
||||
|
||||
def get_placeholder(self, page_content, slot=None):
|
||||
"""
|
||||
Returns the named placeholder or, if no «slot» provided, the first
|
||||
editable, non-static placeholder or None.
|
||||
"""
|
||||
placeholders = page_content.get_placeholders()
|
||||
|
||||
if slot:
|
||||
placeholders = placeholders.filter(slot=slot)
|
||||
|
||||
for ph in placeholders:
|
||||
if not ph.is_static and ph.is_editable:
|
||||
return ph
|
||||
return None
|
||||
|
||||
@property
|
||||
def _language(self):
|
||||
return self.language_code
|
||||
|
||||
def clean(self):
|
||||
"""
|
||||
Validates that either the slug is provided, or that slugification from
|
||||
`title` produces a valid slug.
|
||||
:return:
|
||||
"""
|
||||
data = self.cleaned_data
|
||||
|
||||
if self._errors:
|
||||
return data
|
||||
|
||||
slug = slugify(data.get('slug')) or slugify(data['title'])
|
||||
if not slug:
|
||||
data["slug"] = ""
|
||||
forms.ValidationError({
|
||||
"slug": [_("Cannot automatically create slug. Please provide one manually.")],
|
||||
})
|
||||
|
||||
parent_page = data.get('parent_page')
|
||||
|
||||
if parent_page:
|
||||
base = parent_page.get_path(self._language)
|
||||
path = f'{base}/{slug}' if base else slug
|
||||
else:
|
||||
base = ''
|
||||
path = slug
|
||||
|
||||
data['slug'] = get_available_slug(self._site, path, self._language, suffix=None)
|
||||
data['path'] = '{}/{}'.format(base, data['slug']) if base else data['slug']
|
||||
|
||||
if not data['slug']:
|
||||
raise forms.ValidationError(_("Please provide a valid slug."))
|
||||
return data
|
||||
|
||||
def clean_content(self):
|
||||
# A rich-text editor submits an empty value as an empty paragraph
|
||||
# (e.g. ``<p></p>`` or ``<p> </p>``) rather than an empty string.
|
||||
# Treat any value without visible text as empty so the wizard does not
|
||||
# create a blank text plugin on the new page.
|
||||
content = self.cleaned_data.get("content")
|
||||
if content and not strip_tags(content).replace("\xa0", "").replace(" ", "").strip():
|
||||
return ""
|
||||
return content
|
||||
|
||||
def clean_parent_page(self):
|
||||
# Check to see if this user has permissions to make this page. We've
|
||||
# already checked this when producing a list of wizard entries, but this
|
||||
# is to prevent people from possible form-hacking.
|
||||
if self._page and self.sub_page_form:
|
||||
# User is adding a page which will be a direct
|
||||
# child of the current page.
|
||||
parent_page = self._page
|
||||
elif self._page and self._page.parent:
|
||||
# User is adding a page which will be a right
|
||||
# sibling to the current page.
|
||||
parent_page = self._page.parent
|
||||
else:
|
||||
parent_page = None
|
||||
|
||||
if parent_page:
|
||||
if parent_page.site_id != self._site.pk:
|
||||
raise ValidationError("Site doesn't match the parent's page site")
|
||||
has_perm = user_can_add_subpage(self._user, target=parent_page, site=self._site)
|
||||
else:
|
||||
has_perm = user_can_add_page(self._user, site=self._site)
|
||||
|
||||
if not has_perm:
|
||||
message = gettext('You don\'t have the permissions required to add a page.')
|
||||
raise ValidationError(message)
|
||||
return parent_page if parent_page else None
|
||||
|
||||
def get_template(self):
|
||||
return get_cms_setting('PAGE_WIZARD_DEFAULT_TEMPLATE')
|
||||
|
||||
@transaction.atomic
|
||||
def save(self, **kwargs):
|
||||
from cms.api import add_plugin
|
||||
|
||||
new_translation = super().save(**kwargs)
|
||||
new_page = new_translation.page
|
||||
|
||||
if self.cleaned_data.get("page_type"):
|
||||
return new_page
|
||||
|
||||
# If the user provided content, then use that instead.
|
||||
content = self.cleaned_data.get('content')
|
||||
plugin_type = get_cms_setting('PAGE_WIZARD_CONTENT_PLUGIN')
|
||||
plugin_body = get_cms_setting('PAGE_WIZARD_CONTENT_PLUGIN_BODY')
|
||||
slot = get_cms_setting('PAGE_WIZARD_CONTENT_PLACEHOLDER')
|
||||
|
||||
if plugin_type in plugin_pool.plugins and plugin_body:
|
||||
if content and permissions.has_plugin_permission(
|
||||
self._user, plugin_type, "add"):
|
||||
placeholder = self.get_placeholder(new_translation, slot=slot)
|
||||
if placeholder:
|
||||
opts = {
|
||||
'placeholder': placeholder,
|
||||
'plugin_type': plugin_type,
|
||||
'language': self.language_code,
|
||||
plugin_body: content,
|
||||
}
|
||||
add_plugin(**opts)
|
||||
return new_page
|
||||
|
||||
|
||||
class CreateCMSSubPageForm(CreateCMSPageForm):
|
||||
|
||||
sub_page_form = True
|
||||
2264
cms/locale/af/LC_MESSAGES/django.po
Normal file
2264
cms/locale/af/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load diff
22
cms/locale/af/LC_MESSAGES/djangojs.po
Normal file
22
cms/locale/af/LC_MESSAGES/djangojs.po
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# SOME DESCRIPTIVE TITLE.
|
||||
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
|
||||
# This file is distributed under the same license as the PACKAGE package.
|
||||
#
|
||||
# Translators:
|
||||
# Translators:
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: django CMS\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-12-11 19:18+0000\n"
|
||||
"PO-Revision-Date: 2010-11-30 06:49+0000\n"
|
||||
"Last-Translator: yakky <i.spalletti@nephila.it>\n"
|
||||
"Language-Team: Afrikaans (http://app.transifex.com/divio/django-cms/language/af/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Language: af\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
|
||||
msgid "Are you sure you want to change tabs without saving the page first?"
|
||||
msgstr ""
|
||||
1741
cms/locale/ar/LC_MESSAGES/django.po
Normal file
1741
cms/locale/ar/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load diff
27
cms/locale/ar/LC_MESSAGES/djangojs.po
Normal file
27
cms/locale/ar/LC_MESSAGES/djangojs.po
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
# SOME DESCRIPTIVE TITLE.
|
||||
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
|
||||
# This file is distributed under the same license as the PACKAGE package.
|
||||
#
|
||||
# Translators:
|
||||
# Translators:
|
||||
# Abdelkader Maarouf <dep2g2@gmail.com>, 2015
|
||||
# Ahmed H <info@draco-003.com>, 2013
|
||||
# Bashar Al-Abdulhadi, 2017
|
||||
# Bashar Al-Abdulhadi, 2017
|
||||
# Bashar Ghadanfar <10.tens@gmail.com>, 2017
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: django CMS\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-12-11 19:18+0000\n"
|
||||
"PO-Revision-Date: 2010-11-30 06:49+0000\n"
|
||||
"Last-Translator: Bashar Ghadanfar <10.tens@gmail.com>, 2017\n"
|
||||
"Language-Team: Arabic (http://app.transifex.com/divio/django-cms/language/ar/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Language: ar\n"
|
||||
"Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5;\n"
|
||||
|
||||
msgid "Are you sure you want to change tabs without saving the page first?"
|
||||
msgstr "هل أنت متأكد من الإنتقال الى تبويب أخر دون حفظ الصفحة أولاً؟"
|
||||
2268
cms/locale/ar_SA/LC_MESSAGES/django.po
Normal file
2268
cms/locale/ar_SA/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load diff
22
cms/locale/ar_SA/LC_MESSAGES/djangojs.po
Normal file
22
cms/locale/ar_SA/LC_MESSAGES/djangojs.po
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# SOME DESCRIPTIVE TITLE.
|
||||
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
|
||||
# This file is distributed under the same license as the PACKAGE package.
|
||||
#
|
||||
# Translators:
|
||||
# Translators:
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: django CMS\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-12-11 19:18+0000\n"
|
||||
"PO-Revision-Date: 2010-11-30 06:49+0000\n"
|
||||
"Last-Translator: yakky <i.spalletti@nephila.it>\n"
|
||||
"Language-Team: Arabic (Saudi Arabia) (http://app.transifex.com/divio/django-cms/language/ar_SA/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Language: ar_SA\n"
|
||||
"Plural-Forms: nplurals=6; plural=(n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 && n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5);\n"
|
||||
|
||||
msgid "Are you sure you want to change tabs without saving the page first?"
|
||||
msgstr ""
|
||||
1632
cms/locale/be/LC_MESSAGES/django.po
Normal file
1632
cms/locale/be/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load diff
22
cms/locale/be/LC_MESSAGES/djangojs.po
Normal file
22
cms/locale/be/LC_MESSAGES/djangojs.po
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# SOME DESCRIPTIVE TITLE.
|
||||
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
|
||||
# This file is distributed under the same license as the PACKAGE package.
|
||||
#
|
||||
# Translators:
|
||||
# Translators:
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: django CMS\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-12-11 19:18+0000\n"
|
||||
"PO-Revision-Date: 2010-11-30 06:49+0000\n"
|
||||
"Last-Translator: yakky <i.spalletti@nephila.it>\n"
|
||||
"Language-Team: Belarusian (http://app.transifex.com/divio/django-cms/language/be/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Language: be\n"
|
||||
"Plural-Forms: nplurals=4; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<12 || n%100>14) ? 1 : n%10==0 || (n%10>=5 && n%10<=9) || (n%100>=11 && n%100<=14)? 2 : 3);\n"
|
||||
|
||||
msgid "Are you sure you want to change tabs without saving the page first?"
|
||||
msgstr ""
|
||||
1763
cms/locale/bg/LC_MESSAGES/django.po
Normal file
1763
cms/locale/bg/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load diff
27
cms/locale/bg/LC_MESSAGES/djangojs.po
Normal file
27
cms/locale/bg/LC_MESSAGES/djangojs.po
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
# SOME DESCRIPTIVE TITLE.
|
||||
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
|
||||
# This file is distributed under the same license as the PACKAGE package.
|
||||
#
|
||||
# Translators:
|
||||
# Translators:
|
||||
# Boris Chervenkov <office@sentido.bg>, 2012
|
||||
# Boyko Amarov <boyko.amarov@gmail.com>, 2013
|
||||
# Ivaylo Nakov, 2025
|
||||
# Ivaylo Nakov, 2025
|
||||
# suleyman <posta@suleymans.com>, 2011
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: django CMS\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-12-11 19:18+0000\n"
|
||||
"PO-Revision-Date: 2010-11-30 06:49+0000\n"
|
||||
"Last-Translator: suleyman <posta@suleymans.com>, 2011\n"
|
||||
"Language-Team: Bulgarian (http://app.transifex.com/divio/django-cms/language/bg/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Language: bg\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
|
||||
msgid "Are you sure you want to change tabs without saving the page first?"
|
||||
msgstr "Наистина ли искате да промените раздели без първо да запазите страницата?"
|
||||
1753
cms/locale/bn/LC_MESSAGES/django.po
Normal file
1753
cms/locale/bn/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load diff
23
cms/locale/bn/LC_MESSAGES/djangojs.po
Normal file
23
cms/locale/bn/LC_MESSAGES/djangojs.po
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# SOME DESCRIPTIVE TITLE.
|
||||
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
|
||||
# This file is distributed under the same license as the PACKAGE package.
|
||||
#
|
||||
# Translators:
|
||||
# Translators:
|
||||
# Aniruddha Adhikary <aniruddha@adhikary.net>, 2013
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: django CMS\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-12-11 19:18+0000\n"
|
||||
"PO-Revision-Date: 2010-11-30 06:49+0000\n"
|
||||
"Last-Translator: Aniruddha Adhikary <aniruddha@adhikary.net>, 2013\n"
|
||||
"Language-Team: Bengali (http://app.transifex.com/divio/django-cms/language/bn/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Language: bn\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
|
||||
msgid "Are you sure you want to change tabs without saving the page first?"
|
||||
msgstr "আপনি কি নিশ্চিত যে, আপনি পৃষ্ঠাটি সংরক্ষণ না করেই ট্যাব পরিবর্তন করতে চান?"
|
||||
1764
cms/locale/ca/LC_MESSAGES/django.po
Normal file
1764
cms/locale/ca/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load diff
23
cms/locale/ca/LC_MESSAGES/djangojs.po
Normal file
23
cms/locale/ca/LC_MESSAGES/djangojs.po
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# SOME DESCRIPTIVE TITLE.
|
||||
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
|
||||
# This file is distributed under the same license as the PACKAGE package.
|
||||
#
|
||||
# Translators:
|
||||
# Translators:
|
||||
# Roger Pons <rogerpons@gmail.com>, 2012,2015
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: django CMS\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-12-11 19:18+0000\n"
|
||||
"PO-Revision-Date: 2010-11-30 06:49+0000\n"
|
||||
"Last-Translator: Roger Pons <rogerpons@gmail.com>, 2012,2015\n"
|
||||
"Language-Team: Catalan (http://app.transifex.com/divio/django-cms/language/ca/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Language: ca\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
|
||||
msgid "Are you sure you want to change tabs without saving the page first?"
|
||||
msgstr "Esteu segur que voleu canviar de secció sense guardar la primera pàgina?"
|
||||
1983
cms/locale/cmn/LC_MESSAGES/django.po
Normal file
1983
cms/locale/cmn/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load diff
22
cms/locale/cmn/LC_MESSAGES/djangojs.po
Normal file
22
cms/locale/cmn/LC_MESSAGES/djangojs.po
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# SOME DESCRIPTIVE TITLE.
|
||||
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
|
||||
# This file is distributed under the same license as the PACKAGE package.
|
||||
#
|
||||
# Translators:
|
||||
# Translators:
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: django CMS\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2020-11-05 12:20+0000\n"
|
||||
"PO-Revision-Date: 2018-01-11 16:38+0000\n"
|
||||
"Last-Translator: Paulo Alvarado <paulo.alvarado@divio.ch>\n"
|
||||
"Language-Team: Chinese (Mandarin) (http://www.transifex.com/divio/django-cms/language/cmn/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Language: cmn\n"
|
||||
"Plural-Forms: nplurals=1; plural=0;\n"
|
||||
|
||||
msgid "Are you sure you want to change tabs without saving the page first?"
|
||||
msgstr ""
|
||||
1757
cms/locale/cs/LC_MESSAGES/django.po
Normal file
1757
cms/locale/cs/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load diff
26
cms/locale/cs/LC_MESSAGES/djangojs.po
Normal file
26
cms/locale/cs/LC_MESSAGES/djangojs.po
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
# SOME DESCRIPTIVE TITLE.
|
||||
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
|
||||
# This file is distributed under the same license as the PACKAGE package.
|
||||
#
|
||||
# Translators:
|
||||
# Translators:
|
||||
# Jakub Dorňák <jakub.dornak@misli.cz>, 2013
|
||||
# Jakub Dorňák <jakub.dornak@misli.cz>, 2013
|
||||
# xlu <xlu@seznam.cz>, 2013
|
||||
# xlu <xlu@seznam.cz>, 2013
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: django CMS\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-12-11 19:18+0000\n"
|
||||
"PO-Revision-Date: 2010-11-30 06:49+0000\n"
|
||||
"Last-Translator: xlu <xlu@seznam.cz>, 2013\n"
|
||||
"Language-Team: Czech (http://app.transifex.com/divio/django-cms/language/cs/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Language: cs\n"
|
||||
"Plural-Forms: nplurals=4; plural=(n == 1 && n % 1 == 0) ? 0 : (n >= 2 && n <= 4 && n % 1 == 0) ? 1: (n % 1 != 0 ) ? 2 : 3;\n"
|
||||
|
||||
msgid "Are you sure you want to change tabs without saving the page first?"
|
||||
msgstr "Určitě chcete změnit záložku bez uložení změn?"
|
||||
2266
cms/locale/cs_CZ/LC_MESSAGES/django.po
Normal file
2266
cms/locale/cs_CZ/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load diff
22
cms/locale/cs_CZ/LC_MESSAGES/djangojs.po
Normal file
22
cms/locale/cs_CZ/LC_MESSAGES/djangojs.po
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# SOME DESCRIPTIVE TITLE.
|
||||
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
|
||||
# This file is distributed under the same license as the PACKAGE package.
|
||||
#
|
||||
# Translators:
|
||||
# Translators:
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: django CMS\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-12-11 19:18+0000\n"
|
||||
"PO-Revision-Date: 2010-11-30 06:49+0000\n"
|
||||
"Last-Translator: yakky <i.spalletti@nephila.it>\n"
|
||||
"Language-Team: Czech (Czech Republic) (http://app.transifex.com/divio/django-cms/language/cs_CZ/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Language: cs_CZ\n"
|
||||
"Plural-Forms: nplurals=4; plural=(n == 1 && n % 1 == 0) ? 0 : (n >= 2 && n <= 4 && n % 1 == 0) ? 1: (n % 1 != 0 ) ? 2 : 3;\n"
|
||||
|
||||
msgid "Are you sure you want to change tabs without saving the page first?"
|
||||
msgstr ""
|
||||
2266
cms/locale/cy/LC_MESSAGES/django.po
Normal file
2266
cms/locale/cy/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load diff
22
cms/locale/cy/LC_MESSAGES/djangojs.po
Normal file
22
cms/locale/cy/LC_MESSAGES/djangojs.po
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# SOME DESCRIPTIVE TITLE.
|
||||
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
|
||||
# This file is distributed under the same license as the PACKAGE package.
|
||||
#
|
||||
# Translators:
|
||||
# Translators:
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: django CMS\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-12-11 19:18+0000\n"
|
||||
"PO-Revision-Date: 2010-11-30 06:49+0000\n"
|
||||
"Last-Translator: yakky <i.spalletti@nephila.it>\n"
|
||||
"Language-Team: Welsh (http://app.transifex.com/divio/django-cms/language/cy/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Language: cy\n"
|
||||
"Plural-Forms: nplurals=4; plural=(n==1) ? 0 : (n==2) ? 1 : (n != 8 && n != 11) ? 2 : 3;\n"
|
||||
|
||||
msgid "Are you sure you want to change tabs without saving the page first?"
|
||||
msgstr ""
|
||||
1660
cms/locale/da/LC_MESSAGES/django.po
Normal file
1660
cms/locale/da/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load diff
25
cms/locale/da/LC_MESSAGES/djangojs.po
Normal file
25
cms/locale/da/LC_MESSAGES/djangojs.po
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# SOME DESCRIPTIVE TITLE.
|
||||
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
|
||||
# This file is distributed under the same license as the PACKAGE package.
|
||||
#
|
||||
# Translators:
|
||||
# Translators:
|
||||
# Kristian Øllegaard <kristian@oellegaard.com>, 2011
|
||||
# Kristian Øllegaard <kristian@oellegaard.com>, 2011
|
||||
# Kristian Øllegaard <kristian@oellegaard.com>, 2011
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: django CMS\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-12-11 19:18+0000\n"
|
||||
"PO-Revision-Date: 2010-11-30 06:49+0000\n"
|
||||
"Last-Translator: Kristian Øllegaard <kristian@oellegaard.com>, 2011\n"
|
||||
"Language-Team: Danish (http://app.transifex.com/divio/django-cms/language/da/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Language: da\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
|
||||
msgid "Are you sure you want to change tabs without saving the page first?"
|
||||
msgstr "Er du sikker på du vil ændre tabs uden at gemme siden først?"
|
||||
1774
cms/locale/de/LC_MESSAGES/django.po
Normal file
1774
cms/locale/de/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load diff
26
cms/locale/de/LC_MESSAGES/djangojs.po
Normal file
26
cms/locale/de/LC_MESSAGES/djangojs.po
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
# SOME DESCRIPTIVE TITLE.
|
||||
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
|
||||
# This file is distributed under the same license as the PACKAGE package.
|
||||
#
|
||||
# Translators:
|
||||
# Translators:
|
||||
# Jonas Obrist <me@ojii.ch>, 2011
|
||||
# Michael P. Jung <michael.jung@terreon.de>, 2013
|
||||
# Peter Wischer <peter.wischer@lwl.org>, 2022
|
||||
# Stefan T. Oertel <me@schneck.cc>, 2015
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: django CMS\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-12-11 19:18+0000\n"
|
||||
"PO-Revision-Date: 2010-11-30 06:49+0000\n"
|
||||
"Last-Translator: Stefan T. Oertel <me@schneck.cc>, 2015\n"
|
||||
"Language-Team: German (http://app.transifex.com/divio/django-cms/language/de/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Language: de\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
|
||||
msgid "Are you sure you want to change tabs without saving the page first?"
|
||||
msgstr "Sind Sie sicher Sie wollen den Reiter wechseln ohne vorher zu speichern?"
|
||||
1781
cms/locale/el/LC_MESSAGES/django.po
Normal file
1781
cms/locale/el/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load diff
23
cms/locale/el/LC_MESSAGES/djangojs.po
Normal file
23
cms/locale/el/LC_MESSAGES/djangojs.po
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# SOME DESCRIPTIVE TITLE.
|
||||
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
|
||||
# This file is distributed under the same license as the PACKAGE package.
|
||||
#
|
||||
# Translators:
|
||||
# Translators:
|
||||
# George Petsagourakis <petsagouris@gmail.com>, 2013
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: django CMS\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-12-11 19:18+0000\n"
|
||||
"PO-Revision-Date: 2010-11-30 06:49+0000\n"
|
||||
"Last-Translator: George Petsagourakis <petsagouris@gmail.com>, 2013\n"
|
||||
"Language-Team: Greek (http://app.transifex.com/divio/django-cms/language/el/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Language: el\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
|
||||
msgid "Are you sure you want to change tabs without saving the page first?"
|
||||
msgstr "Είσαι σίγουρος πως θες να αλλάξεις καρτέλες χωρίς να έχεις αποθηκεύσει πρώτα τη σελίδα?"
|
||||
1992
cms/locale/en/LC_MESSAGES/django.po
Normal file
1992
cms/locale/en/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load diff
23
cms/locale/en/LC_MESSAGES/djangojs.po
Normal file
23
cms/locale/en/LC_MESSAGES/djangojs.po
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# SOME DESCRIPTIVE TITLE.
|
||||
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
|
||||
# This file is distributed under the same license as the PACKAGE package.
|
||||
#
|
||||
# Translators:
|
||||
# Jonas Obrist <ojiidotch@gmail.com>, 2011
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: django-cms\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-12-11 19:18+0000\n"
|
||||
"PO-Revision-Date: 2015-10-31 12:28+0000\n"
|
||||
"Last-Translator: yakky <i.spalletti@nephila.it>\n"
|
||||
"Language-Team: English (http://www.transifex.com/divio/django-cms/language/"
|
||||
"en/)\n"
|
||||
"Language: en\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
|
||||
msgid "Are you sure you want to change tabs without saving the page first?"
|
||||
msgstr "Are you sure you want to change tabs without saving the page first?"
|
||||
2266
cms/locale/en_GB/LC_MESSAGES/django.po
Normal file
2266
cms/locale/en_GB/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load diff
22
cms/locale/en_GB/LC_MESSAGES/djangojs.po
Normal file
22
cms/locale/en_GB/LC_MESSAGES/djangojs.po
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# SOME DESCRIPTIVE TITLE.
|
||||
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
|
||||
# This file is distributed under the same license as the PACKAGE package.
|
||||
#
|
||||
# Translators:
|
||||
# Translators:
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: django CMS\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-12-11 19:18+0000\n"
|
||||
"PO-Revision-Date: 2010-11-30 06:49+0000\n"
|
||||
"Last-Translator: yakky <i.spalletti@nephila.it>\n"
|
||||
"Language-Team: English (United Kingdom) (http://app.transifex.com/divio/django-cms/language/en_GB/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Language: en_GB\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
|
||||
msgid "Are you sure you want to change tabs without saving the page first?"
|
||||
msgstr ""
|
||||
2264
cms/locale/en_HK/LC_MESSAGES/django.po
Normal file
2264
cms/locale/en_HK/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load diff
22
cms/locale/en_HK/LC_MESSAGES/djangojs.po
Normal file
22
cms/locale/en_HK/LC_MESSAGES/djangojs.po
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# SOME DESCRIPTIVE TITLE.
|
||||
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
|
||||
# This file is distributed under the same license as the PACKAGE package.
|
||||
#
|
||||
# Translators:
|
||||
# Translators:
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: django CMS\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-12-11 19:18+0000\n"
|
||||
"PO-Revision-Date: 2010-11-30 06:49+0000\n"
|
||||
"Last-Translator: yakky <i.spalletti@nephila.it>\n"
|
||||
"Language-Team: English (Hong Kong) (http://app.transifex.com/divio/django-cms/language/en_HK/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Language: en_HK\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
|
||||
msgid "Are you sure you want to change tabs without saving the page first?"
|
||||
msgstr ""
|
||||
2264
cms/locale/en_US/LC_MESSAGES/django.po
Normal file
2264
cms/locale/en_US/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load diff
22
cms/locale/en_US/LC_MESSAGES/djangojs.po
Normal file
22
cms/locale/en_US/LC_MESSAGES/djangojs.po
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# SOME DESCRIPTIVE TITLE.
|
||||
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
|
||||
# This file is distributed under the same license as the PACKAGE package.
|
||||
#
|
||||
# Translators:
|
||||
# Translators:
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: django CMS\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-12-11 19:18+0000\n"
|
||||
"PO-Revision-Date: 2010-11-30 06:49+0000\n"
|
||||
"Last-Translator: yakky <i.spalletti@nephila.it>\n"
|
||||
"Language-Team: English (United States) (http://app.transifex.com/divio/django-cms/language/en_US/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Language: en_US\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
|
||||
msgid "Are you sure you want to change tabs without saving the page first?"
|
||||
msgstr ""
|
||||
2268
cms/locale/eo/LC_MESSAGES/django.po
Normal file
2268
cms/locale/eo/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load diff
22
cms/locale/eo/LC_MESSAGES/djangojs.po
Normal file
22
cms/locale/eo/LC_MESSAGES/djangojs.po
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# SOME DESCRIPTIVE TITLE.
|
||||
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
|
||||
# This file is distributed under the same license as the PACKAGE package.
|
||||
#
|
||||
# Translators:
|
||||
# Translators:
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: django CMS\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-12-11 19:18+0000\n"
|
||||
"PO-Revision-Date: 2010-11-30 06:49+0000\n"
|
||||
"Last-Translator: yakky <i.spalletti@nephila.it>\n"
|
||||
"Language-Team: Esperanto (http://app.transifex.com/divio/django-cms/language/eo/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Language: eo\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
|
||||
msgid "Are you sure you want to change tabs without saving the page first?"
|
||||
msgstr ""
|
||||
1780
cms/locale/es/LC_MESSAGES/django.po
Normal file
1780
cms/locale/es/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load diff
25
cms/locale/es/LC_MESSAGES/djangojs.po
Normal file
25
cms/locale/es/LC_MESSAGES/djangojs.po
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# SOME DESCRIPTIVE TITLE.
|
||||
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
|
||||
# This file is distributed under the same license as the PACKAGE package.
|
||||
#
|
||||
# Translators:
|
||||
# Translators:
|
||||
# e4db27214f7e7544f2022c647b585925_bb0e321, 2015
|
||||
# Edwin Caldon <edycop@gmail.com>, 2011
|
||||
# e4db27214f7e7544f2022c647b585925_bb0e321, 2015
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: django CMS\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-12-11 19:18+0000\n"
|
||||
"PO-Revision-Date: 2010-11-30 06:49+0000\n"
|
||||
"Last-Translator: e4db27214f7e7544f2022c647b585925_bb0e321, 2015\n"
|
||||
"Language-Team: Spanish (http://app.transifex.com/divio/django-cms/language/es/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Language: es\n"
|
||||
"Plural-Forms: nplurals=3; plural=n == 1 ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;\n"
|
||||
|
||||
msgid "Are you sure you want to change tabs without saving the page first?"
|
||||
msgstr "¿Estás seguro de que desea cambiar de pestañas sin guardar antes la página?"
|
||||
2268
cms/locale/es_AR/LC_MESSAGES/django.po
Normal file
2268
cms/locale/es_AR/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load diff
23
cms/locale/es_AR/LC_MESSAGES/djangojs.po
Normal file
23
cms/locale/es_AR/LC_MESSAGES/djangojs.po
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# SOME DESCRIPTIVE TITLE.
|
||||
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
|
||||
# This file is distributed under the same license as the PACKAGE package.
|
||||
#
|
||||
# Translators:
|
||||
# Translators:
|
||||
# Edwin Caldon <edycop@gmail.com>, 2011
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: django CMS\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-12-11 19:18+0000\n"
|
||||
"PO-Revision-Date: 2010-11-30 06:49+0000\n"
|
||||
"Last-Translator: Edwin Caldon <edycop@gmail.com>, 2011\n"
|
||||
"Language-Team: Spanish (Argentina) (http://app.transifex.com/divio/django-cms/language/es_AR/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Language: es_AR\n"
|
||||
"Plural-Forms: nplurals=3; plural=n == 1 ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;\n"
|
||||
|
||||
msgid "Are you sure you want to change tabs without saving the page first?"
|
||||
msgstr "¿Estás seguro de que desea cambiar de pestañas sin guardar antes la página?"
|
||||
2267
cms/locale/es_BO/LC_MESSAGES/django.po
Normal file
2267
cms/locale/es_BO/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load diff
25
cms/locale/es_BO/LC_MESSAGES/djangojs.po
Normal file
25
cms/locale/es_BO/LC_MESSAGES/djangojs.po
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# SOME DESCRIPTIVE TITLE.
|
||||
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
|
||||
# This file is distributed under the same license as the PACKAGE package.
|
||||
#
|
||||
# Translators:
|
||||
# Translators:
|
||||
# Edwin Caldon <edycop@gmail.com>, 2011
|
||||
# mariocesar <mariocesar.c50@gmail.com>, 2011
|
||||
# mariocesar <mariocesar.c50@gmail.com>, 2011
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: django CMS\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-12-11 19:18+0000\n"
|
||||
"PO-Revision-Date: 2010-11-30 06:49+0000\n"
|
||||
"Last-Translator: mariocesar <mariocesar.c50@gmail.com>, 2011\n"
|
||||
"Language-Team: Spanish (Bolivia) (http://app.transifex.com/divio/django-cms/language/es_BO/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Language: es_BO\n"
|
||||
"Plural-Forms: nplurals=3; plural=n == 1 ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;\n"
|
||||
|
||||
msgid "Are you sure you want to change tabs without saving the page first?"
|
||||
msgstr "¿Estás seguro de que desea cambiar de pestañas sin guardar antes la página?"
|
||||
2265
cms/locale/es_DO/LC_MESSAGES/django.po
Normal file
2265
cms/locale/es_DO/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load diff
22
cms/locale/es_DO/LC_MESSAGES/djangojs.po
Normal file
22
cms/locale/es_DO/LC_MESSAGES/djangojs.po
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# SOME DESCRIPTIVE TITLE.
|
||||
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
|
||||
# This file is distributed under the same license as the PACKAGE package.
|
||||
#
|
||||
# Translators:
|
||||
# Translators:
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: django CMS\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-12-11 19:18+0000\n"
|
||||
"PO-Revision-Date: 2010-11-30 06:49+0000\n"
|
||||
"Last-Translator: yakky <i.spalletti@nephila.it>\n"
|
||||
"Language-Team: Spanish (Dominican Republic) (http://app.transifex.com/divio/django-cms/language/es_DO/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Language: es_DO\n"
|
||||
"Plural-Forms: nplurals=3; plural=n == 1 ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;\n"
|
||||
|
||||
msgid "Are you sure you want to change tabs without saving the page first?"
|
||||
msgstr ""
|
||||
1638
cms/locale/et/LC_MESSAGES/django.po
Normal file
1638
cms/locale/et/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load diff
24
cms/locale/et/LC_MESSAGES/djangojs.po
Normal file
24
cms/locale/et/LC_MESSAGES/djangojs.po
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
# SOME DESCRIPTIVE TITLE.
|
||||
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
|
||||
# This file is distributed under the same license as the PACKAGE package.
|
||||
#
|
||||
# Translators:
|
||||
# Translators:
|
||||
# Martin <martinpajuste@gmail.com>, 2012
|
||||
# Martin <martinpajuste@gmail.com>, 2016
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: django CMS\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-12-11 19:18+0000\n"
|
||||
"PO-Revision-Date: 2010-11-30 06:49+0000\n"
|
||||
"Last-Translator: Martin <martinpajuste@gmail.com>, 2016\n"
|
||||
"Language-Team: Estonian (http://app.transifex.com/divio/django-cms/language/et/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Language: et\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
|
||||
msgid "Are you sure you want to change tabs without saving the page first?"
|
||||
msgstr "Kas olete kindel, et soovite sakki vahetada ilma lehte salvestamata?"
|
||||
1642
cms/locale/eu/LC_MESSAGES/django.po
Normal file
1642
cms/locale/eu/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load diff
23
cms/locale/eu/LC_MESSAGES/djangojs.po
Normal file
23
cms/locale/eu/LC_MESSAGES/djangojs.po
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# SOME DESCRIPTIVE TITLE.
|
||||
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
|
||||
# This file is distributed under the same license as the PACKAGE package.
|
||||
#
|
||||
# Translators:
|
||||
# Translators:
|
||||
# Ales Zabala Alava <shagi@gisa-elkartea.org>, 2012
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: django CMS\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-12-11 19:18+0000\n"
|
||||
"PO-Revision-Date: 2010-11-30 06:49+0000\n"
|
||||
"Last-Translator: Ales Zabala Alava <shagi@gisa-elkartea.org>, 2012\n"
|
||||
"Language-Team: Basque (http://app.transifex.com/divio/django-cms/language/eu/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Language: eu\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
|
||||
msgid "Are you sure you want to change tabs without saving the page first?"
|
||||
msgstr "Ziur orria gorde gabe fitxaz aldatu nahi duzula?"
|
||||
1756
cms/locale/fa/LC_MESSAGES/django.po
Normal file
1756
cms/locale/fa/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load diff
25
cms/locale/fa/LC_MESSAGES/djangojs.po
Normal file
25
cms/locale/fa/LC_MESSAGES/djangojs.po
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# SOME DESCRIPTIVE TITLE.
|
||||
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
|
||||
# This file is distributed under the same license as the PACKAGE package.
|
||||
#
|
||||
# Translators:
|
||||
# Translators:
|
||||
# cafe-click <cafeclick@rocketmail.com>, 2013
|
||||
# Pouya Abbassi, 2016
|
||||
# Pouya Abbassi, 2016
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: django CMS\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-12-11 19:18+0000\n"
|
||||
"PO-Revision-Date: 2010-11-30 06:49+0000\n"
|
||||
"Last-Translator: Pouya Abbassi, 2016\n"
|
||||
"Language-Team: Persian (http://app.transifex.com/divio/django-cms/language/fa/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Language: fa\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
|
||||
|
||||
msgid "Are you sure you want to change tabs without saving the page first?"
|
||||
msgstr "آیا مطمئن هستید میخواهید تبها را تغییر دهید بدون اینکه صفحه را از قبل ذخیره کرده باشید؟"
|
||||
1633
cms/locale/fa_IR/LC_MESSAGES/django.po
Normal file
1633
cms/locale/fa_IR/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load diff
23
cms/locale/fa_IR/LC_MESSAGES/djangojs.po
Normal file
23
cms/locale/fa_IR/LC_MESSAGES/djangojs.po
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# SOME DESCRIPTIVE TITLE.
|
||||
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
|
||||
# This file is distributed under the same license as the PACKAGE package.
|
||||
#
|
||||
# Translators:
|
||||
# Translators:
|
||||
# Javad Miraftabzadeh <javad.miraftabzadeh@gmail.com>, 2016
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: django CMS\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-12-11 19:18+0000\n"
|
||||
"PO-Revision-Date: 2010-11-30 06:49+0000\n"
|
||||
"Last-Translator: Javad Miraftabzadeh <javad.miraftabzadeh@gmail.com>, 2016\n"
|
||||
"Language-Team: Persian (Iran) (http://app.transifex.com/divio/django-cms/language/fa_IR/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Language: fa_IR\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
|
||||
|
||||
msgid "Are you sure you want to change tabs without saving the page first?"
|
||||
msgstr "آیا مطمئنید که میخواهید زبانهها را پیش از ذخیره صفحه تغییر دهید؟"
|
||||
1762
cms/locale/fi/LC_MESSAGES/django.po
Normal file
1762
cms/locale/fi/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load diff
23
cms/locale/fi/LC_MESSAGES/djangojs.po
Normal file
23
cms/locale/fi/LC_MESSAGES/djangojs.po
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
# SOME DESCRIPTIVE TITLE.
|
||||
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
|
||||
# This file is distributed under the same license as the PACKAGE package.
|
||||
#
|
||||
# Translators:
|
||||
# Translators:
|
||||
# Niklas Jerva <niklas.jerva@gmail.com>, 2017
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: django CMS\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-12-11 19:18+0000\n"
|
||||
"PO-Revision-Date: 2010-11-30 06:49+0000\n"
|
||||
"Last-Translator: Niklas Jerva <niklas.jerva@gmail.com>, 2017\n"
|
||||
"Language-Team: Finnish (http://app.transifex.com/divio/django-cms/language/fi/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Language: fi\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
|
||||
msgid "Are you sure you want to change tabs without saving the page first?"
|
||||
msgstr "Haluatko varmasti muuttaa välilehtiä tallentamatta sivua ensin?"
|
||||
2269
cms/locale/fi_FI/LC_MESSAGES/django.po
Normal file
2269
cms/locale/fi_FI/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load diff
22
cms/locale/fi_FI/LC_MESSAGES/djangojs.po
Normal file
22
cms/locale/fi_FI/LC_MESSAGES/djangojs.po
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
# SOME DESCRIPTIVE TITLE.
|
||||
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
|
||||
# This file is distributed under the same license as the PACKAGE package.
|
||||
#
|
||||
# Translators:
|
||||
# Translators:
|
||||
msgid ""
|
||||
msgstr ""
|
||||
"Project-Id-Version: django CMS\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2023-12-11 19:18+0000\n"
|
||||
"PO-Revision-Date: 2010-11-30 06:49+0000\n"
|
||||
"Last-Translator: yakky <i.spalletti@nephila.it>\n"
|
||||
"Language-Team: Finnish (Finland) (http://app.transifex.com/divio/django-cms/language/fi_FI/)\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Language: fi_FI\n"
|
||||
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
|
||||
|
||||
msgid "Are you sure you want to change tabs without saving the page first?"
|
||||
msgstr ""
|
||||
1782
cms/locale/fr/LC_MESSAGES/django.po
Normal file
1782
cms/locale/fr/LC_MESSAGES/django.po
Normal file
File diff suppressed because it is too large
Load diff
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue