dynamicweb/cms/admin/pageadmin.py

1513 lines
60 KiB
Python
Raw Normal View History

from __future__ import annotations
import json
import re
from collections import defaultdict, namedtuple
import django
from django.conf import settings
from django.contrib import admin, messages
from django.contrib.admin.helpers import AdminForm
from django.contrib.admin.options import IS_POPUP_VAR
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import (
PermissionDenied,
ValidationError,
)
from django.db import transaction
from django.db.models import Prefetch, Q
from django.db.models.query import QuerySet
from django.forms import ModelForm
from django.forms.fields import IntegerField
from django.http import (
Http404,
HttpResponse,
HttpResponseBadRequest,
HttpResponseForbidden,
HttpResponseRedirect,
QueryDict,
)
from django.shortcuts import get_object_or_404, render
from django.template.defaultfilters import escape
from django.template.loader import get_template
from django.template.response import SimpleTemplateResponse, TemplateResponse
from django.urls import re_path
from django.utils.decorators import method_decorator
from django.utils.encoding import force_str
from django.utils.html import format_html
from django.utils.safestring import mark_safe
from django.utils.text import capfirst
from django.utils.translation import gettext as _
from django.views.decorators.http import require_POST
from cms import operations
from cms.admin.forms import (
AddPageForm,
AdvancedSettingsForm,
ChangeListForm,
ChangePageForm,
CopyPageForm,
CopyPermissionForm,
DuplicatePageForm,
MovePageForm,
)
from cms.admin.permissionadmin import PERMISSION_ADMIN_INLINES
from cms.cache.permissions import clear_permission_cache
from cms.constants import MODAL_HTML_REDIRECT
from cms.models import (
CMSPlugin,
GlobalPagePermission,
Page,
PageContent,
PagePermission,
PageUrl,
Placeholder,
)
from cms.models.pagemodel import AdminCacheDict
from cms.models.permissionmodels import PermissionTuple
from cms.operations.helpers import (
send_post_page_operation,
send_pre_page_operation,
)
from cms.plugin_pool import plugin_pool
from cms.signals.apphook import set_restart_trigger
from cms.toolbar.utils import get_object_edit_url
from cms.utils import get_current_site, page_permissions, permissions
from cms.utils.admin import get_site_from_request, jsonify_request
from cms.utils.compat.warnings import RemovedInDjangoCMS60Warning
from cms.utils.conf import get_cms_setting
from cms.utils.i18n import (
get_language_list,
get_language_object,
get_language_tuple,
get_site_language_from_request,
)
from cms.utils.permissions import clear_permission_lru_caches
from cms.utils.plugins import copy_plugins_to_placeholder, downcast_plugins
from cms.utils.urlutils import admin_reverse, static_with_version
require_POST = method_decorator(require_POST)
#: Upper bound for the number of pages :meth:`PageAdmin.get_list` returns. The
#: view feeds an autocomplete, so a full inventory of the site is never useful.
PAGE_SMART_LINK_MAX_RESULTS = 50
class PageDeleteMessageMixin:
"""Expressive and simplified delete confirmation message for pages and translations."""
delete_confirmation_template = "admin/cms/page/delete_confirmation.html"
def get_deleted_objects(self, objs, request):
"""Minimize complexity of delete selected confirmation: Only show pages, page contents and plugins numbers,
only show Page and PageContent objects in the details.
"""
def recursively_remove(deleted_objects: list | str) -> list | str:
"""Remove all objects that are not Page or PageContent from the nested list of deleted objects.
Reformat the messages."""
if isinstance(deleted_objects, str):
return deleted_objects
result = []
for obj in deleted_objects:
item = recursively_remove(obj)
if isinstance(item, str):
if obj.startswith(f"{capfirst(Page._meta.verbose_name)}: "):
# ``Page`` is registered in the admin, so Django built this entry with
# ``format_html()`` as ``Page: <a href="...">title</a>``: the title inside the
# anchor is already escaped and must not be escaped a second time.
text = re.findall(r">(.*)<", obj)
if text:
result.append(format_html("<b>{}</b>", mark_safe(text[0])))
else:
# No admin link, so this is Django's raw ``"Page: %s" % obj`` string.
result.append(
format_html("<b>{}</b>", item.removeprefix(f"{capfirst(Page._meta.verbose_name)}: "))
)
elif obj.startswith(f"{capfirst(PageUrl._meta.verbose_name)}: "):
# ``PageUrl`` has no admin, so Django returns the raw, unescaped
# ``"Page url: %s" % obj`` string. The path is author-controlled (it can be set
# through "Overwrite URL"), so it must not be marked safe (CWE-79).
result.insert(0, escape(item.removeprefix(f"{capfirst(PageUrl._meta.verbose_name)}: ")))
elif item:
result.append(item)
return result
if len(objs) == 1 and isinstance(objs[0], PageContent):
# Always look for all page/language combinations
objs = list(PageContent.admin_manager.filter(page=objs[0].page, language=objs[0].language))
to_delete, trad_model_count, perms_needed, protected = super().get_deleted_objects(objs, request)
to_delete = recursively_remove(to_delete)
model_count = {
Page._meta.verbose_name_plural: trad_model_count.get(Page._meta.verbose_name_plural, 0),
_("Translations"): trad_model_count.get(PageUrl._meta.verbose_name_plural, 0),
CMSPlugin._meta.verbose_name_plural: trad_model_count.get(CMSPlugin._meta.verbose_name_plural, 0),
}
return to_delete, model_count, perms_needed, protected
@admin.register(Page)
class PageAdmin(PageDeleteMessageMixin, admin.ModelAdmin):
actions_menu_template = "admin/cms/page/tree/actions_dropdown.html"
form = AdvancedSettingsForm
copy_form = CopyPageForm
move_form = MovePageForm
inlines = PERMISSION_ADMIN_INLINES
search_fields = ("=id", "urls__slug", "pagecontent_set__title", "reverse_id")
title_frontend_editable_fields = ["title", "menu_title", "page_title"]
def has_module_permission(self, request):
return False # Hides page model from the admin index
def has_add_permission(self, request):
return False
def has_change_permission(self, request, obj=None):
"""
Return true if the current user has permission on the page.
Return the string 'All' if the user has all rights.
"""
if obj is None:
site = get_site_from_request(request)
# Checks if user can change at least one page
return page_permissions.user_can_change_at_least_one_page(
user=request.user,
site=site,
)
return page_permissions.user_can_change_page(request.user, page=obj, site=obj.site)
def has_change_advanced_settings_permission(self, request, obj=None):
if not obj:
return False
return page_permissions.user_can_change_page_advanced_settings(request.user, page=obj, site=obj.site)
def log_deletion(self, request, object, object_repr):
# DJANGO_42
# Block the admin log for deletion. A signal takes care of this!
return
def log_deletions(self, request, queryset):
# Block the admin log for deletion. A signal takes care of this!
return
def get_admin_url(self, action, *args):
url_name = f"{self.opts.app_label}_{self.opts.model_name}_{action}"
return admin_reverse(url_name, args=args)
def get_preserved_filters(self, request):
"""
This override is in place to preserve the "language" get parameter in
the "Save" page redirect
"""
site = get_site_from_request(request)
preserved_filters_encoded = super().get_preserved_filters(request)
preserved_filters = QueryDict(preserved_filters_encoded).copy()
lang = get_site_language_from_request(request, site_id=site.pk)
if lang:
preserved_filters["language"] = lang
return preserved_filters.urlencode()
def get_page_from_id(self, page_id):
page_id = self.model._meta.pk.to_python(page_id)
try:
page = self.model.objects.get(pk=page_id)
except self.model.DoesNotExist:
page = None
return page
def get_urls(self):
"""Get the admin urls"""
info = f"{self.model._meta.app_label}_{self.model._meta.model_name}"
def pat(regex, fn):
return re_path(regex, self.admin_site.admin_view(fn), name=f"{info}_{fn.__name__}")
url_patterns = [
pat(r"^list/$", self.get_list),
pat(r"^(?P<object_id>[0-9]+)/actions-menu/$", self.actions_menu),
pat(r"^([0-9]+)/([a-z\-]+)/edit-field/$", self.edit_title_fields),
pat(r"^(?P<object_id>[0-9]+)/advanced-settings/$", self.advanced), # named object_id
pat(r"^([0-9]+)/move-page/$", self.move_page),
pat(r"^([0-9]+)/copy-page/$", self.copy_page),
pat(r"^([0-9]+)/dialog/copy/$", self.get_copy_dialog), # copy dialog
pat(r"^([0-9]+)/permissions/$", self.get_permissions),
pat(r"^(?P<object_id>[0-9]+)/set-home/$", self.set_home),
]
if plugin_pool.registered_plugins:
url_patterns += plugin_pool.get_patterns()
return url_patterns + super().get_urls()
def get_inline_instances(self, request, obj=None):
if obj and get_cms_setting("PERMISSION"):
can_change_perms = self.has_change_permissions_permission(request, obj=obj)
else:
can_change_perms = False
if can_change_perms:
return super().get_inline_instances(request, obj)
return []
def get_form(self, request, obj=None, **kwargs):
"""
Get PageForm for the Page model and modify its fields depending on
the request.
"""
if not self.has_change_advanced_settings_permission(request, obj=obj):
form = type("EmptyForm", (ModelForm,), {"Meta": type("Meta", (self.form.Meta,), {"fields": []})})
else:
form = super().get_form(request, obj, **kwargs)
form._site = get_site_from_request(request)
form._request = request
return form
def actions_menu(self, request, object_id, extra_context=None):
page = self.get_object(request, object_id=object_id)
if page is None:
raise self._get_404_exception(object_id)
if not self.has_view_permission(request, obj=page):
raise PermissionDenied("No permission for actions menu")
site = get_site_from_request(request)
paste_enabled = request.GET.get("has_copy") or request.GET.get("has_cut")
context = {
"page": page,
"opts": self.opts,
"site": site,
"page_is_restricted": page.has_view_restrictions(site),
"paste_enabled": paste_enabled,
"has_add_permission": page_permissions.user_can_add_subpage(request.user, target=page),
"has_copy_page_permission": page_permissions.user_can_view_page_draft(request.user, page, site=site),
"has_change_permission": self.has_change_permission(request, obj=page),
"has_change_advanced_settings_permission": self.has_change_advanced_settings_permission(request, obj=page),
"has_change_permissions_permission": self.has_change_permissions_permission(request, obj=page),
"has_move_page_permission": self.has_move_page_permission(request, obj=page),
"has_delete_permission": self.has_delete_permission(request, obj=page),
"CMS_PERMISSION": get_cms_setting("PERMISSION"),
}
if extra_context:
context.update(extra_context)
return render(request, self.actions_menu_template, context)
def advanced(self, request, object_id):
page = self.get_object(request, object_id=object_id)
if page is None:
raise self._get_404_exception(object_id)
if get_cms_setting("PERMISSION"):
show_permissions = self.has_change_permissions_permission(request, obj=page)
else:
show_permissions = False
show_advanced_settings = self.has_change_advanced_settings_permission(request, obj=page)
if not show_permissions and not show_advanced_settings:
raise PermissionDenied("No permission for editing advanced settings")
context = {
"title": _("Advanced Settings") if show_advanced_settings else _("Change Permissions"),
"show_permissions": show_permissions,
}
return self.change_view(request, object_id, extra_context=context)
def response_post_save_change(self, request, obj):
"""
Figure out where to redirect after the 'Save' button has been pressed
when adding a new object.
"""
can_change_any_page = page_permissions.user_can_change_at_least_one_page(
user=request.user,
site=obj.site,
use_cache=False,
)
if can_change_any_page:
query = self.get_preserved_filters(request)
post_url = admin_reverse("cms_pagecontent_changelist") + "?" + query + "&site=" + str(obj.site_id)
else:
post_url = admin_reverse("index")
return HttpResponseRedirect(post_url)
@require_POST
@transaction.atomic
def set_home(self, request, object_id):
page = self.get_object(request, object_id=object_id)
if page is None:
raise self._get_404_exception(object_id)
if not self.has_change_permission(request, page):
raise PermissionDenied("You do not have permission to set 'home'.")
if not page.is_potential_home():
return HttpResponseBadRequest(_("The page is not eligible to be home."))
# Setting a new home page demotes the current one and rewrites the url
# paths of both trees, so it takes change permission on both pages.
old_home = self.model.objects.filter(is_home=True, site=page.site_id).first()
if old_home and not self.has_change_permission(request, old_home):
raise PermissionDenied("You do not have permission to change the current home page.")
new_home_tree, old_home_tree = page.set_as_homepage(request.user)
# Check if one of the affected pages either from the old homepage
# or the homepage had an apphook attached
if old_home_tree:
apphooks_affected = old_home_tree.has_apphooks()
else:
apphooks_affected = False
if not apphooks_affected:
apphooks_affected = new_home_tree.has_apphooks()
if apphooks_affected:
# One or more pages affected by this operation was attached to an apphook.
# As a result, fire the apphook reload signal to reload the url patterns.
set_restart_trigger()
return HttpResponse("ok")
def get_list(self, request):
"""
This view is used by the PageSmartLinkWidget as the user type to feed the autocomplete drop-down.
"""
if not self.has_view_permission(request):
raise PermissionDenied("No permission for page list view")
if request.headers.get("x-requested-with") == "XMLHttpRequest":
site = get_site_from_request(request)
query_term = request.GET.get("q", "").strip("/")
if not query_term:
# ``icontains`` matches every page for an empty term, which would
# turn this autocomplete into a full listing of the site.
return HttpResponse(json.dumps([]), content_type="application/json")
language_code = request.GET.get("language_code", settings.LANGUAGE_CODE)
matching_pages = (
self.model.objects.on_site(site)
.filter(
Q(pagecontent_set__title__icontains=query_term, pagecontent_set__language=language_code)
| Q(urls__path__icontains=query_term, pagecontent_set__language=language_code)
| Q(pagecontent_set__menu_title__icontains=query_term, pagecontent_set__language=language_code)
| Q(pagecontent_set__page_title__icontains=query_term, pagecontent_set__language=language_code)
)
# Page types are blueprints for new pages, not link targets.
.filter(is_page_type=False)
.prefetch_related("urls", "pagecontent_set")
.distinct()[:PAGE_SMART_LINK_MAX_RESULTS]
)
results = []
for page in matching_pages:
results.append(
{
"path": page.get_path(language=language_code),
"title": page.get_title(language=language_code),
"redirect_url": page.get_absolute_url(language=language_code),
}
)
return HttpResponse(json.dumps(results), content_type="application/json")
return HttpResponseForbidden()
def changelist_view(self, request, extra_context=None):
parameter = "?" + request.GET.urlencode() if request.GET else ""
return HttpResponseRedirect(admin_reverse("cms_pagecontent_changelist") + parameter)
def response_delete(self, request, obj_display, obj_id):
"""
Determine the HttpResponse for the delete_view stage. Clear the user's permission
lru cache
"""
clear_permission_lru_caches(request.user)
return super().response_delete(request, obj_display, obj_id)
def delete_model(self, request, obj):
operation_token = send_pre_page_operation(
request=request, operation=operations.DELETE_PAGE, obj=obj, sender=self.model
)
cms_pages = [obj]
if obj.is_branch:
descendant_ids = obj.get_descendants().values_list("id", flat=True)
cms_pages.extend(self.model.objects.filter(id__in=descendant_ids))
# Delete all associated pages contents
ct_page_content = ContentType.objects.get_for_model(PageContent)
page_content_objs = PageContent.admin_manager.filter(page__in=cms_pages).values_list("pk", flat=True)
placeholders = Placeholder.objects.filter(
content_type=ct_page_content,
object_id__in=page_content_objs,
)
plugins = CMSPlugin.objects.filter(placeholder__in=placeholders)
QuerySet.delete(plugins)
placeholders.delete()
super().delete_model(request, obj)
send_post_page_operation(
request=request,
operation=operations.DELETE_PAGE,
token=operation_token,
obj=obj,
sender=self.model,
)
clear_permission_cache()
if obj.application_urls:
set_restart_trigger()
def get_copy_dialog(self, request, page_id):
if not get_cms_setting("PERMISSION"):
return HttpResponse("")
page = self.get_page_from_id(page_id)
if page is None:
raise self._get_404_exception(page_id)
if request.method == "GET":
data = request.GET
else:
data = request.POST
target_id = data.get("target")
site = get_site_from_request(request)
user = request.user
can_view_page = page_permissions.user_can_view_page(user, page, page.site)
if not can_view_page:
raise PermissionDenied
if target_id:
try:
target = Page.objects.get(pk=target_id)
except Page.DoesNotExist:
raise Http404
if not page_permissions.user_can_add_subpage(user, target, target.site):
raise PermissionDenied
elif not page_permissions.user_can_add_page(user, site):
raise PermissionDenied
context = {
"dialog_id": "dialog-copy",
"form": CopyPermissionForm(), # class needs to be instantiated
"opts": self.opts,
}
return render(request, "admin/cms/page/tree/copy_premissions.html", context)
def _get_404_exception(self, object_id):
exception = Http404(
_("%(name)s object with primary key %(key)r does not exist.")
% {
"name": force_str(self.opts.verbose_name),
"key": escape(object_id),
}
)
return exception
def has_view_permission(self, request, obj=None):
return self.has_change_permission(request, obj)
def has_change_permissions_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_delete_permission(self, request, obj=None):
"""
Returns True if the current user has permission to delete the page.
"""
if not obj:
return False
return page_permissions.user_can_delete_page(request.user, page=obj, site=obj.site)
def has_move_page_permission(self, request, obj=None):
if not obj:
return False
return page_permissions.user_can_move_page(user=request.user, page=obj, site=obj.site)
@require_POST
@transaction.atomic
def move_page(self, request, page_id, extra_context=None):
"""
Move the page to the requested target, at the given position.
NOTE: We have to change from one "coordinate system" to another to
adapt JSTree to Django Treebeard.
If the Tree looks like this:
<root>
⊢ …
⊢ …
⊢ Page 4
⊢ Page 5 (position 0)
⊢ …
For example,
target=4, position=1 => target=5, position="right"
target=4, position=0 => target=4, position="first-child"
"""
page = self.get_object(request, object_id=page_id)
if page is None:
return jsonify_request(HttpResponseBadRequest("error"))
user = request.user
form = self.move_form(request.POST or None, page=page, site=page.site)
if not form.is_valid():
return jsonify_request(HttpResponseBadRequest(str(form.errors.get("__all__", _("error")))))
target = form.cleaned_data["target"]
can_move_page = self.has_move_page_permission(request, obj=page)
# Does the user have permissions to do this...?
if not can_move_page or (target and not target.has_add_permission(user)):
message = _("Error! You don't have permissions to move this page. Please reload the page")
return jsonify_request(HttpResponseForbidden(message))
# The whole subtree moves along, so authorize the whole subtree: the
# destination may grant access that the source did not.
target_page, position = form.get_tree_options()
if position in ("first-child", "last-child"):
parent_page = target_page
else:
parent_page = target_page.parent if target_page else None
if not page_permissions.user_can_move_descendants(user, page, page.site, parent_page):
message = _("Error! You don't have permissions to move this page. Please reload the page")
return jsonify_request(HttpResponseForbidden(message))
operation_token = send_pre_page_operation(
request=request,
operation=operations.MOVE_PAGE,
obj=page,
sender=self.model,
)
form.move_page(user=user)
send_post_page_operation(
request=request,
operation=operations.MOVE_PAGE,
token=operation_token,
obj=page,
sender=self.model,
)
return jsonify_request(HttpResponse(status=200))
def get_permissions(self, request, page_id):
rows = []
user = request.user
page = self.get_object(request, object_id=page_id)
if page is None:
raise self._get_404_exception(page_id)
if not self.has_view_permission(request, obj=page):
raise PermissionDenied("No permission for page permissions view")
site = get_site_from_request(request)
PermissionRow = namedtuple("Permission", ["is_global", "can_change", "permission"])
global_permissions = GlobalPagePermission.objects.filter(sites__in=[site.pk])
can_change_global_permissions = permissions.user_can_change_global_permissions(user, site)
for permission in global_permissions.iterator():
row = PermissionRow(
is_global=True,
can_change=can_change_global_permissions,
permission=permission,
)
rows.append(row)
_page_permissions = PagePermission.objects.for_page(page).select_related("page")
if not can_change_global_permissions:
allowed_pages = page_permissions.get_change_perm_tuples(user, site, check_global=False)
for permission in _page_permissions.iterator():
if can_change_global_permissions:
can_change = True
else:
page_path = permission.page.path
can_change = any(PermissionTuple(perm_tuple).contains(page_path) for perm_tuple in allowed_pages)
row = PermissionRow(
is_global=False,
can_change=can_change,
permission=permission,
)
rows.append(row)
context = {
"page": page,
"rows": rows,
"opts": self.opts,
}
return render(request, "admin/cms/page/permissions.html", context)
@require_POST
@transaction.atomic
def copy_page(self, request, page_id):
"""
Copy the page and all its plugins and descendants to the requested
target, at the given position
"""
page = self.get_page_from_id(page_id)
if page is None:
return jsonify_request(HttpResponseBadRequest("error"))
user = request.user
site = get_site_from_request(request)
form = self.copy_form(request.POST or None, page=page, site=site)
if not form.is_valid():
return jsonify_request(HttpResponseBadRequest("error"))
target = form.cleaned_data["target"]
# User can only copy pages he can see
can_copy_page = page_permissions.user_can_view_page(user, page, page.site)
if can_copy_page and target:
# User can only copy a page into another one if he has permission
# to add a page under the target page.
can_copy_page = page_permissions.user_can_add_subpage(user, target, site)
elif can_copy_page:
# User can only copy / paste a page if he has permission to add a page
can_copy_page = page_permissions.user_can_add_page(user, site)
if can_copy_page:
target_page, position = form.get_tree_options()
if position in ("first-child", "last-child"):
parent_page = target_page
else:
parent_page = target_page.parent if target_page else None
can_copy_page = page_permissions.user_can_copy_descendants(
user, page, site, parent_page, form.cleaned_data["copy_permissions"]
)
if not can_copy_page:
message = _("Error! You don't have permissions to copy this page.")
return jsonify_request(HttpResponseForbidden(message))
page_languages = page.get_languages()
site_languages = get_language_list(site_id=site.pk)
if not any(lang in page_languages for lang in site_languages):
message = _(
"Error! "
"The page you're pasting is not translated in any of the languages configured by the target site."
)
return jsonify_request(HttpResponseBadRequest(message))
new_page = form.copy_page(user)
return HttpResponse(json.dumps({"id": new_page.pk}), content_type="application/json")
def edit_title_fields(self, request, page_id, language):
page = self.get_object(request, object_id=page_id)
if page is None:
raise self._get_404_exception(page_id)
if not self.has_change_permission(request, obj=page):
return HttpResponseForbidden(_("You do not have permission to edit this page"))
translation = page.get_admin_content(language)
if not translation:
raise Http404("No translation matches requested language.")
saved_successfully = False
raw_fields = request.GET.get("edit_fields", "title")
edit_fields = [field for field in raw_fields.split(",") if field in self.title_frontend_editable_fields]
cancel_clicked = request.POST.get("_cancel", False)
opts = PageContent._meta
if not edit_fields:
# Defaults to title
edit_fields = ("title",)
class PageTitleForm(django.forms.ModelForm):
"""
Dynamic form showing only the fields to be edited
"""
class Meta:
model = PageContent
fields = edit_fields
if not cancel_clicked and request.method == "POST":
form = PageTitleForm(instance=translation, data=request.POST)
if form.is_valid():
form.save()
saved_successfully = True
else:
form = PageTitleForm(instance=translation)
admin_form = AdminForm(
form, fieldsets=[(None, {"fields": edit_fields})], prepopulated_fields={}, model_admin=self
)
media = self.media + admin_form.media
context = {
"CMS_MEDIA_URL": get_cms_setting("MEDIA_URL"),
"title": "Title",
"plugin": page,
"plugin_id": page.pk,
"adminform": admin_form,
"add": False,
"is_popup": True,
"media": media,
"opts": opts,
"change": True,
"save_as": False,
"has_add_permission": False,
"window_close_timeout": 10,
}
if cancel_clicked:
# cancel button was clicked
context.update(
{
"cancel": True,
}
)
return render(request, "admin/cms/page/plugin/confirm_form.html", context)
if not cancel_clicked and request.method == "POST" and saved_successfully:
return render(request, "admin/cms/page/plugin/confirm_form.html", context)
return render(request, "admin/cms/page/plugin/change_form.html", context)
@admin.register(PageContent)
class PageContentAdmin(PageDeleteMessageMixin, admin.ModelAdmin):
ordering = ("page__path",)
search_fields = ("=id", "page__id", "page__urls__slug", "title", "page__reverse_id")
change_form_template = "admin/cms/page/change_form.html"
change_list_template = "admin/cms/page/tree/base.html"
actions_menu_template = "admin/cms/page/tree/actions_dropdown.html"
page_tree_row_template = "admin/cms/page/tree/menu.html"
class Media:
css = {"all": (static_with_version("cms/css/cms.admin.css"),)}
form = AddPageForm
add_form = form
change_form = ChangePageForm
copy_form = CopyPageForm
move_form = MovePageForm
changelist_form = ChangeListForm
duplicate_form = DuplicatePageForm
def log_addition(self, request, object, object_repr):
# Block the admin log for addition. A signal takes care of this!
return
def log_deletion(self, request, object, object_repr):
# DJANGO_42
# Block the admin log for deletion. A signal takes care of this!
return
def log_deletions(self, request, queryset):
# Block the admin log for deletion. A signal takes care of this!
return
def log_change(self, request, object, message):
# Block the admin log for change. A signal takes care of this!
return
def get_admin_url(self, action, *args):
url_name = f"{self.opts.app_label}_{self.opts.model_name}_{action}"
return admin_reverse(url_name, args=args)
def get_preserved_filters(self, request):
"""
This override is in place to preserve the "language" get parameter in
the "Save" page redirect
"""
site = get_site_from_request(request)
preserved_filters_encoded = super().get_preserved_filters(request)
preserved_filters = QueryDict(preserved_filters_encoded).copy()
lang = get_site_language_from_request(request, site_id=site.pk)
if lang:
preserved_filters["language"] = lang
if site and site.pk != get_current_site(request).pk:
preserved_filters["site"] = site.pk
return preserved_filters.urlencode()
def get_queryset(self, request):
return super().get_queryset(request).select_related("page")
def get_urls(self):
"""Get the admin urls"""
info = f"{self.model._meta.app_label}_{self.model._meta.model_name}"
def pat(regex, fn):
return re_path(regex, self.admin_site.admin_view(fn), name=f"{info}_{fn.__name__}")
url_patterns = [
pat(r"^get-tree/$", self.get_tree),
pat(r"^([0-9]+)/duplicate/$", self.duplicate),
pat(r"^([0-9]+)/copy-language/$", self.copy_language),
pat(r"^([0-9]+)/change-navigation/$", self.change_innavigation),
pat(r"^([0-9]+)/change-template/$", self.change_template),
]
return url_patterns + super().get_urls()
def get_fieldsets(self, request, obj=None):
form = self.get_form(request, obj, fields=None)
try:
fieldsets = form.fieldsets
except AttributeError:
fields = list(form.base_fields) + list(self.get_readonly_fields(request, obj))
fieldsets = [(None, {"fields": fields})]
return fieldsets
def get_form_class(self, request, obj=None, **kwargs):
if "change" in request.path_info:
return self.change_form
elif "duplicate" in request.path_info:
return self.duplicate_form
return self.add_form
def get_form(self, request, obj=None, **kwargs):
"""
Get PageForm for the Page model and modify its fields depending on
the request.
"""
form = super().get_form(request, obj, form=self.get_form_class(request, obj), **kwargs)
form._site = get_site_from_request(request)
form._request = request
return form
def duplicate(self, request, object_id):
"""
Leverages the add view logic to duplicate the page.
"""
obj = self.get_object(request, object_id=object_id)
if obj is None:
raise self._get_404_exception(object_id)
if request.method == "GET":
# source is a field in the form
# because its value is in the url,
# we have to set the initial value manually
request.GET = request.GET.copy()
request.GET["source"] = obj.page_id
return self.add_view(request)
def add_view(self, request, form_url="", extra_context=None):
site = get_site_from_request(request)
language = get_site_language_from_request(request, site_id=site.pk)
if extra_context is None:
extra_context = {}
try:
page_id = request.GET.get("cms_page") or request.POST.get("cms_page")
page_id = IntegerField().clean(page_id)
cms_page = Page.objects.get(pk=page_id)
except (ValidationError, Page.DoesNotExist):
cms_page = None
if cms_page:
# Adding content for an existing page in a language it does not have yet
extra_context["title"] = _("Add Translation")
elif "duplicate" in request.path_info:
extra_context["title"] = _("Add Page Copy")
elif "parent_page" in request.GET:
extra_context["title"] = _("New sub page")
else:
extra_context["title"] = _("New page")
if cms_page:
extra_context["cms_page"] = cms_page
extra_context["language_tabs"] = get_language_tuple(site.pk)
extra_context["filled_languages"] = self.get_filled_languages(request, cms_page)
extra_context["show_language_tabs"] = len(extra_context["language_tabs"])
extra_context["language"] = language
return super().add_view(request, form_url, extra_context=extra_context)
def change_view(self, request, object_id, form_url="", extra_context=None):
"""
The 'change' admin view for the PageContent model.
"""
if extra_context is None:
extra_context = {"basic_info": True}
obj = self.get_object(request, object_id=object_id)
if obj is None:
raise self._get_404_exception(object_id)
site = obj.page.site
context = {
"cms_page": obj.page,
"CMS_PERMISSION": get_cms_setting("PERMISSION"),
"can_change": self.has_change_permission(request, obj=obj),
"language": obj.language,
"language_tabs": get_language_tuple(site.pk),
"filled_languages": self.get_filled_languages(request, obj.page),
}
# Only offer the language selector for the latest content. Switching the language always
# navigates to the latest content of the target language, so for an older content object
# (e.g. an outdated version) switching languages back and forth would silently bring up a
# different (the latest) content object - confusing UX.
latest = obj.page.get_admin_content(obj.language)
is_latest_content = getattr(latest, "pk", None) == obj.pk
context["show_language_tabs"] = len(context["language_tabs"]) if is_latest_content else 0
context.update(extra_context or {})
if "basic_info" in extra_context:
_has_advanced_settings_perm = self.has_change_advanced_settings_permission(request, obj=obj)
context["can_change_advanced_settings"] = _has_advanced_settings_perm
return super().change_view(request, object_id, form_url=form_url, extra_context=context)
def response_add(self, request, obj):
redirect = request.POST.get("edit", False)
if redirect == "1":
from django.core.cache import cache
from cms.cache.permissions import get_cache_key, get_cache_permission_version
cache.delete(
get_cache_key(request.user, obj.page.site, "change_page"),
version=get_cache_permission_version(),
)
# redirect to the edit view if added from the toolbar
url = get_object_edit_url(obj) # Redirects to preview if necessary
return HttpResponse(MODAL_HTML_REDIRECT.format(url=url))
return super().response_add(request, obj)
def get_filled_languages(self, request, page):
site_id = page.site_id
filled_languages = page.get_languages()
allowed_languages = [lang[0] for lang in get_language_tuple(site_id)]
return [lang for lang in filled_languages if lang in allowed_languages]
def _get_404_exception(self, object_id):
exception = Http404(
_("%(name)s object with primary key %(key)r does not exist.")
% {
"name": force_str(self.opts.verbose_name),
"key": escape(object_id),
}
)
return exception
def _has_add_permission_from_request(self, request):
if parent_id := request.GET.get("parent_page"):
try:
parent_id = IntegerField().clean(parent_id)
parent_item = Page.objects.get(id=parent_id)
except (ValidationError, Page.DoesNotExist):
return False
else:
parent_item = None
if parent_item:
has_perm = page_permissions.user_can_add_subpage(
request.user,
target=parent_item,
site=parent_item.site,
)
else:
site = get_site_from_request(request)
has_perm = page_permissions.user_can_add_page(request.user, site=site)
return has_perm
def has_add_permission(self, request):
"""
Return true if the current user has permission to add a new page.
"""
return self._has_add_permission_from_request(request)
def has_change_permission(self, request, obj=None):
"""
Return true if the current user has permission on the page.
Return the string 'All' if the user has all rights.
"""
if obj:
return page_permissions.user_can_change_page(request.user, page=obj.page, site=obj.page.site)
return page_permissions.user_can_change_at_least_one_page(
user=request.user,
site=get_site_from_request(request),
)
def has_view_permission(self, request, obj=None):
"""
Return true if the current user has permission on the page.
Return the string 'All' if the user has all rights.
"""
# Identical to has_change_permission, but will remain untouched by any subclassing
# as done, e.g., by djangocms-versioning
if obj:
return page_permissions.user_can_change_page(request.user, page=obj.page, site=obj.page.site)
return page_permissions.user_can_change_at_least_one_page(
user=request.user,
site=get_site_from_request(request),
use_cache=False,
)
def has_delete_permission(self, request, obj=None):
"""
Returns True if the current user has permission to delete the page.
"""
if not obj:
return False
return page_permissions.user_can_delete_page(request.user, page=obj.page, site=obj.page.site)
def has_change_advanced_settings_permission(self, request, obj=None):
if not obj:
return False
return page_permissions.user_can_change_page_advanced_settings(request.user, page=obj.page, site=obj.page.site)
def has_delete_translation_permission(self, request, language, obj=None):
if not obj:
return False
has_perm = page_permissions.user_can_delete_page_translation(
user=request.user,
page=obj,
language=language,
site=obj.site,
)
return has_perm
def get_sites_for_user(self, user):
from django.contrib.sites.models import Site
if hasattr(user, "_cms_user_sites"):
return user._cms_user_sites
sites = Site.objects.order_by("name")
if not get_cms_setting("PERMISSION") or user.is_superuser:
user._cms_user_sites = sites
return sites
_has_perm = page_permissions.user_can_change_at_least_one_page
user_sites = [site for site in sites if _has_perm(user, site)]
user._cms_user_sites = user_sites
return user_sites
def user_can_access_site(self, request):
"""
.. deprecated:: 5.2
Unused. Site isolation is enforced by ``has_change_permission``,
which checks ``user_can_change_at_least_one_page`` against the site
of the request.
"""
import warnings
warnings.warn(
"PageContentAdmin.user_can_access_site() is deprecated and unused. "
"Use has_change_permission(request) instead, which already scopes the check to the "
"site of the request.",
RemovedInDjangoCMS60Warning,
stacklevel=2,
)
site = get_site_from_request(request)
user_sites = self.get_sites_for_user(request.user)
return site in user_sites
def raise_site_permission_denied(self):
"""
.. deprecated:: 5.2
Unused. Raise ``django.core.exceptions.PermissionDenied`` directly.
"""
import warnings
warnings.warn(
"PageContentAdmin.raise_site_permission_denied() is deprecated and unused. "
"Raise django.core.exceptions.PermissionDenied directly instead.",
RemovedInDjangoCMS60Warning,
stacklevel=2,
)
raise PermissionDenied(_("You do not have permission to access this site. Please contact your administrator."))
def changelist_view(self, request, extra_context=None):
from django.contrib.admin.views.main import ERROR_FLAG
if not self.has_change_permission(request, obj=None):
raise PermissionDenied
site = get_site_from_request(request)
language = get_site_language_from_request(request, site_id=site.pk)
query = request.GET.get("q", "")
page_contents = self.get_queryset(request)
page_contents = self.get_search_results(request, page_contents, query)[0]
changelist_form = self.changelist_form(request.GET)
try:
changelist_form.full_clean()
page_contents = changelist_form.run_filters(page_contents)
except (ValueError, ValidationError):
# Wacky lookup parameters were given, so redirect to the main
# changelist page, without parameters, and pass an 'invalid=1'
# parameter via the query string. If wacky parameters were given
# and the 'invalid=1' parameter was already in the query string,
# something is screwed up with the database, so display an error
# page.
if ERROR_FLAG in request.GET.keys():
return SimpleTemplateResponse(
"admin/invalid_setup.html",
{
"title": _("Database error"),
},
)
return HttpResponseRedirect(request.path + "?" + ERROR_FLAG + "=1")
pages = Page.objects.on_site(site).filter(pagecontent_set__in=page_contents).distinct().order_by("path")
pages = pages.prefetch_related(
Prefetch(
"pagecontent_set",
to_attr="filtered_translations",
queryset=page_contents,
),
"urls", # rendering a tree row resolves the page's URLs
)
if changelist_form.is_filtered():
# Evaluates the queryset
has_items = len(pages) >= 1
else:
has_items = pages.exists()
context = self.admin_site.each_context(request)
context.update(
{
"opts": self.model._meta,
"media": self.media,
"CMS_MEDIA_URL": get_cms_setting("MEDIA_URL"),
"CMS_PERMISSION": get_cms_setting("PERMISSION"),
"site_languages": get_language_tuple(site.pk),
"preview_language": language,
"changelist_form": changelist_form,
"cms_current_site": site,
"has_add_permission": self.has_add_permission(request),
"module_name": force_str(self.model._meta.verbose_name_plural),
"admin": self,
"tree": {
"site": site,
"sites": self.get_sites_for_user(request.user),
"query": query,
"is_filtered": changelist_form.is_filtered(),
"items": pages,
"has_items": has_items,
},
}
)
context.update(extra_context or {})
request.current_app = self.admin_site.name
return TemplateResponse(request, self.change_list_template, context)
@require_POST
def change_template(self, request, object_id):
page_content = self.get_object(request, object_id=object_id)
if not self.has_change_permission(request, obj=page_content):
raise PermissionDenied("No permissions to change the template")
if page_content is None:
raise self._get_404_exception(object_id)
to_template = request.POST.get("template", None)
if get_cms_setting("TEMPLATES"):
if to_template not in dict(get_cms_setting("TEMPLATES")):
return HttpResponseBadRequest(_("Template not valid"))
else:
if to_template not in (placeholder_set[0] for placeholder_set in get_cms_setting("PLACEHOLDERS")):
return HttpResponseBadRequest(_("Placeholder selection not valid"))
page_content.template = to_template
page_content.save()
return HttpResponse(_("The template was successfully changed"))
@require_POST
@transaction.atomic
def copy_language(self, request, object_id):
target_language = request.POST.get("target_language")
source_page_content = self.get_object(request, object_id=object_id)
if not self.has_change_permission(request, obj=source_page_content):
raise PermissionDenied
if source_page_content is None:
raise self._get_404_exception(object_id)
page = source_page_content.page
if not target_language or target_language not in get_language_list(site_id=page.site_id):
return HttpResponseBadRequest(_("Language must be set to a supported language!"))
target_page_content = page.get_content_obj(target_language, fallback=False)
source_placeholders = list(source_page_content.get_placeholders())
target_placeholders = {
placeholder.slot: placeholder
for placeholder in target_page_content.get_placeholders()
}
source_plugins = downcast_plugins(
CMSPlugin.objects.filter(
placeholder_id__in=[placeholder.pk for placeholder in source_placeholders],
language=source_page_content.language,
).order_by("position")
)
plugins_by_placeholder = defaultdict(list)
for plugin in source_plugins:
plugins_by_placeholder[plugin.placeholder_id].append(plugin)
for placeholder in source_placeholders:
target = target_placeholders.get(placeholder.slot)
if target is None:
messages.warning(request, _("Placeholder '%s' does not exist in target language") % placeholder.slot)
continue
plugins = plugins_by_placeholder[placeholder.pk]
if not target.has_add_plugins_permission(request.user, plugins):
return HttpResponseForbidden(_("You do not have permission to copy these plugins."))
copy_plugins_to_placeholder(
plugins,
target,
language=target_language,
plugins_are_downcast=True,
)
return HttpResponse("ok")
def delete_view(self, request, object_id, extra_context=None):
page_content = self.get_object(request, object_id=object_id)
if page_content is None:
raise self._get_404_exception(object_id)
page = page_content.page
if not self.has_delete_translation_permission(request, page_content.language, page):
return HttpResponseForbidden(_("You do not have permission to delete this page"))
if not len(list(page.get_languages())) > 1:
return HttpResponseBadRequest("There only exists one translation for this page")
return super().delete_view(request, object_id, extra_context)
def delete_model(self, request, obj):
ct_page_content = ContentType.objects.get_for_model(PageContent)
page_contents = PageContent.admin_manager.filter(page=obj.page, language=obj.language)
placeholders = Placeholder.objects.filter(
content_type=ct_page_content,
object_id__in=page_contents.values("pk"),
)
saved_plugins = CMSPlugin.objects.filter(placeholder__in=placeholders)
# A translation that has never been published may have no PageUrl
page_urls = obj.page.urls.filter(language=obj.language)
operation_token = send_pre_page_operation(
request=request,
operation=operations.DELETE_PAGE_TRANSLATION,
obj=obj.page,
translation=obj,
sender=self.model,
)
message = _("Title and plugins with language %(language)s was deleted") % {
"language": force_str(get_language_object(obj.language, site_id=obj.page.site_id)["name"])
}
messages.success(request, message)
if obj.page.admin_content_cache and obj.language in obj.page.admin_content_cache:
del obj.page.admin_content_cache[obj.language]
if obj.language in obj.page.page_content_cache:
del obj.page.page_content_cache[obj.language]
page_urls.delete()
page_contents.delete()
saved_plugins.delete()
send_post_page_operation(
request=request,
operation=operations.DELETE_PAGE_TRANSLATION,
token=operation_token,
obj=obj.page,
translation=obj,
sender=self.model,
)
@require_POST
def change_innavigation(self, request, object_id):
"""
Switch the in_navigation of a page
"""
page_content = self.get_object(request, object_id=object_id)
if not self.has_change_permission(request, obj=page_content):
if self.has_change_permission(request):
# General (permission) problem
message = "You do not have permission to change a page's navigation status"
else:
# Only this page? Can be permissions or versioning, or ...
message = "You cannot change this page's navigation status"
return HttpResponseForbidden(_(message))
if page_content is None:
raise self._get_404_exception(object_id)
page_content.toggle_in_navigation()
# 204 -> request was successful but no response returned.
return HttpResponse(status=204)
def get_tree(self, request):
"""
Get html for the descendants (only) of given page or if no page_id is
provided, all the root nodes.
Used for lazy loading pages in cms.pagetree.js
"""
if not self.has_change_permission(request, obj=None):
raise PermissionDenied
site = get_site_from_request(request)
pages = Page.objects.on_site(site).order_by("path")
node_id = re.sub(r"[^\d]", "", request.GET.get("nodeId", "")) or None
open_page_ids = [int(id) for id in request.GET.getlist("openNodes[]") if id.isdigit()]
if node_id:
page = get_object_or_404(pages, id=node_id)
pages = page.get_descendant_pages().filter(Q(id__in=open_page_ids) | Q(parent__in=open_page_ids))
else:
page = None
pages = pages.filter(
# get all root pages or children which were previously open or children of the open descendants
Q(depth=1) | Q(depth=2, id__in=open_page_ids) | Q(parent__in=open_page_ids)
)
pages = pages.prefetch_related(
Prefetch(
"pagecontent_set",
to_attr="filtered_translations",
queryset=PageContent.admin_manager.get_queryset().latest_content(),
),
"urls", # rendering a tree row resolves the page's URLs
)
rows = self.get_tree_rows(
request,
pages=pages,
language=get_site_language_from_request(request, site_id=site.pk),
depth=(page.depth + 1 if page else 1),
follow_descendants=True,
)
return HttpResponse("".join(rows))
def get_tree_rows(self, request, pages, language, depth=1, follow_descendants=True):
"""
Used for rendering the page tree, inserts into context everything what
we need for single item
"""
if not self.has_change_permission(request, obj=None):
raise PermissionDenied
site = get_site_from_request(request)
permissions_on = get_cms_setting("PERMISSION")
template = get_template(self.page_tree_row_template)
is_popup = IS_POPUP_VAR in request.POST or IS_POPUP_VAR in request.GET
languages = get_language_list(site.pk)
user_can_add = page_permissions.user_can_add_subpage
user_can_change = page_permissions.user_can_change_page
user_can_change_advanced = page_permissions.user_can_change_page_advanced_settings
user_can_change_permissions = page_permissions.user_can_change_page_permissions
def render_page_row(page):
page.admin_content_cache = AdminCacheDict(
(trans.language, trans) for trans in page.filtered_translations
)
has_move_page_permission = page_permissions.user_can_move_page(request.user, page, site=site)
if permissions_on and not has_move_page_permission:
# TODO: check if this is really needed
metadata = '{"valid_children": False, "draggable": False}'
else:
metadata = ""
page_content = page.get_admin_content(language)
context = {
"admin": self,
"opts": self.opts,
"site": site,
"page": page,
"page_content": page_content,
"ancestors": [page for page in page.get_cached_ancestors()],
"descendants": [page for page in page.get_cached_descendants()],
"request": request,
"lang": language,
"metadata": metadata,
"page_languages": page.get_languages(),
"preview_language": language,
"follow_descendants": follow_descendants,
"site_languages": languages,
"is_popup": is_popup,
"has_add_page_permission": user_can_add(request.user, target=page),
"has_change_permission": user_can_change(request.user, page, site),
"has_change_advanced_settings_permission": (
user_can_change_advanced(request.user, page, site)
or user_can_change_permissions(request.user, page, site)
),
"has_move_page_permission": has_move_page_permission,
"can_change": page_content.is_editable(request),
}
context["is_concrete"] = context["page_content"].language == language
return template.render(context)
if follow_descendants:
root_pages = (page for page in pages if page.depth == depth)
else:
# When the tree is filtered, it's displayed as a flat structure
root_pages = pages
if depth == 1:
for page in root_pages:
page._set_hierarchy(list(pages))
yield render_page_row(page)
else:
for page in root_pages:
yield render_page_row(page)
# Indicators in the page tree
@property
def indicator_descriptions(self):
return {
"public": _("Public content"),
"empty": _("Empty"),
}
@classmethod
def get_indicator_menu(cls, request, page_content):
menu_template = "admin/cms/page/tree/indicator_menu.html"
if not page_content:
return menu_template, [
(
_("Create Content"), # Entry
"cms-icon-edit-new", # Optional icon
admin_reverse("cms_pagecontent_add")
+ f"?cms_page={page_content.page.pk}&language={page_content.language}", # url
None, # Optional add classes for <a>
),
]
return "", []