forked from ungleich-public/dynamicweb
bruks #1
1311 changed files with 337168 additions and 167 deletions
|
|
@ -1 +1 @@
|
|||
3.4.3
|
||||
3.14.2
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ from django.conf.urls import url
|
|||
from .views import IndexView, LoginView, ContactView
|
||||
|
||||
|
||||
app_name = "alplora"
|
||||
|
||||
urlpatterns = [
|
||||
url(r'^$', IndexView.as_view(), name='index'),
|
||||
url(r'login/', LoginView.as_view(), name='login'),
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
from django.views.generic import TemplateView
|
||||
|
||||
from django.utils.translation import get_language, get_language_info
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic.edit import FormView
|
||||
from django.contrib import messages
|
||||
from django.shortcuts import render
|
||||
|
|
|
|||
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,
|
||||
])
|
||||
1363
cms/admin/placeholderadmin.py
Normal file
1363
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
|
||||
19
cms/extensions/__init__.py
Normal file
19
cms/extensions/__init__.py
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
from .admin import PageContentExtensionAdmin, PageExtensionAdmin
|
||||
from .extension_pool import extension_pool
|
||||
from .models import PageContentExtension, PageExtension
|
||||
|
||||
|
||||
# Legacy django-cms compatibility
|
||||
TitleExtension = PageContentExtension
|
||||
TitleExtensionAdmin = PageContentExtensionAdmin
|
||||
|
||||
|
||||
__all__ = [
|
||||
'extension_pool',
|
||||
'PageContentExtension',
|
||||
'PageContentExtensionAdmin',
|
||||
'PageExtension',
|
||||
'PageExtensionAdmin',
|
||||
'TitleExtension',
|
||||
'TitleExtensionAdmin',
|
||||
]
|
||||
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?"
|
||||
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