Skip to content

fix: Refactor page admin site handling #8303

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
fix: Use explicit site query parameter in page admin
In order to not rely on the 'current site' or an opaque site set in the session,
this relies whenever possible on the page's site, falling back to a site query
parameter when a page is not available. This query parameter needs to be
passed around similar to the language parameter but can fall back to current
site if not present initially. Permission to access the chosen site is checked.
The default site filter on get_queryset is removed, as it can break get_object
by filtering the wrong site. Other listing views do their own site filtering.
A graceful transition from the site in session is available by redirecting to a
site query parameter when necessary.
  • Loading branch information
stefanw committed Aug 5, 2025
commit 2f8a896ddf912f8850349abc9170b7033a7926aa
2 changes: 1 addition & 1 deletion cms/admin/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -905,7 +905,7 @@ class PageTreeForm(forms.Form):

def __init__(self, *args, **kwargs):
self.page = kwargs.pop("page")
self._site = kwargs.pop("site", Site.objects.get_current())
self._site = kwargs.pop("site", self.page.site)
super().__init__(*args, **kwargs)
self.fields["target"].queryset = Page.objects.filter(
site=self._site,
Expand Down
109 changes: 37 additions & 72 deletions cms/admin/pageadmin.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
MovePageForm,
)
from cms.admin.permissionadmin import PERMISSION_ADMIN_INLINES
from cms.admin.site_utils import get_site, get_site_from_request, get_sites_for_user, needs_site_redirect
from cms.cache.permissions import clear_permission_cache
from cms.constants import MODAL_HTML_REDIRECT
from cms.models import (
Expand Down Expand Up @@ -86,19 +87,6 @@
require_POST = method_decorator(require_POST)


def get_site(request):
site_id = request.session.get("cms_admin_site")

if not site_id:
return get_current_site()

try:
site = Site.objects._get_site_by_id(site_id)
except Site.DoesNotExist:
site = get_current_site()
return site


class PageDeleteMessageMixin:
"""Expressive and simplified delete confirmation message for pages and translations."""

Expand Down Expand Up @@ -169,20 +157,19 @@ 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.
"""
site = get_site(request)
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=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
site = get_site(request)
return page_permissions.user_can_change_page_advanced_settings(request.user, page=obj, site=site)
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
Expand Down Expand Up @@ -211,12 +198,6 @@ def get_preserved_filters(self, request):
preserved_filters["language"] = lang
return preserved_filters.urlencode()

def get_queryset(self, request):
site = get_site(request)
queryset = super().get_queryset(request)
queryset = queryset.filter(site=site)
return queryset

def get_page_from_id(self, page_id):
page_id = self.model._meta.pk.to_python(page_id)

Expand Down Expand Up @@ -259,22 +240,15 @@ def get_inline_instances(self, request, obj=None):
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.
"""
form = super().get_form(request, obj, **kwargs)
form._site = get_site(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(request)
paste_enabled = request.GET.get("has_copy") or request.GET.get("has_cut")
context = {
Expand Down Expand Up @@ -363,11 +337,13 @@ def set_home(self, request, object_id):
set_restart_trigger()
return HttpResponse("ok")

def get_list(self, *args, **kwargs):
def get_list(self, request):
"""
This view is used by the PageSmartLinkWidget as the user type to feed the autocomplete drop-down.
"""
request = args[0]

if not self.has_view_permission(request):
raise PermissionDenied("No permission for page list view")

if request.headers.get("x-requested-with") == "XMLHttpRequest":
query_term = request.GET.get("q", "").strip("/")
Expand Down Expand Up @@ -786,21 +762,19 @@ 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(request)
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 != settings.SITE_ID:
preserved_filters["site"] = site.pk
return preserved_filters.urlencode()

def get_queryset(self, request):
site = get_site(request)
languages = get_language_list(site.pk)
queryset = super().get_queryset(request).select_related("page")
queryset = queryset.filter(language__in=languages, page__site=site)
return queryset
return super().get_queryset(request).select_related("page")

def get_urls(self):
"""Get the admin urls"""
Expand Down Expand Up @@ -845,6 +819,11 @@ def get_form(self, request, obj=None, **kwargs):
form._request = request
return form

# def get_changeform_initial_data(self, request):
# site = get_site(request)
# language = get_site_language_from_request(request, site_id=site.pk)
# return {"language": language, "site": site}

def slug(self, obj):
# For read-only views: Get slug from the page
if not hasattr(self, "url_obj"):
Expand Down Expand Up @@ -877,6 +856,8 @@ def duplicate(self, request, object_id):
return self.add_view(request)

def add_view(self, request, form_url="", extra_context=None):
if request.method == "GET" and (redirect_response := needs_site_redirect(request)):
return redirect_response
site = get_site(request)
language = get_site_language_from_request(request, site_id=site.pk)

Expand Down Expand Up @@ -929,7 +910,7 @@ def change_view(self, request, object_id, form_url="", extra_context=None):
if obj is None:
raise self._get_404_exception(object_id)

site = get_site(request)
site = obj.page.site
context = {
"cms_page": obj.page,
"CMS_PERMISSION": get_cms_setting("PERMISSION"),
Expand Down Expand Up @@ -962,7 +943,7 @@ def response_add(self, request, obj):
return super().response_add(request, obj)

def get_filled_languages(self, request, page):
site_id = get_site(request).pk
site_id = page.site.pk
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]
Expand All @@ -978,7 +959,6 @@ def _get_404_exception(self, object_id):
return exception

def _has_add_permission_from_request(self, request):
site = get_site(request)
if parent_id := request.GET.get("parent_page"):
try:
parent_id = IntegerField().clean(parent_id)
Expand All @@ -992,9 +972,10 @@ def _has_add_permission_from_request(self, request):
has_perm = page_permissions.user_can_add_subpage(
request.user,
target=parent_item,
site=site,
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

Expand All @@ -1009,13 +990,12 @@ 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.
"""
site = get_site(request)

if obj:
return page_permissions.user_can_change_page(request.user, page=obj.page, site=site)
return page_permissions.user_can_change_page(request.user, page=obj.page, site=obj.page.site)
can_change_page = page_permissions.user_can_change_at_least_one_page(
user=request.user,
site=site,
site=get_site_from_request(request),
)
Comment on lines 996 to 999
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (code-quality): Inline variable that is immediately returned (inline-immediately-returned-variable)

return can_change_page

Expand All @@ -1026,13 +1006,12 @@ def has_view_permission(self, request, obj=None):
"""
# Identical to has_change_permission, but will remain untouched by any subclassing
# as done, e.g., by djangocms-versioning
site = get_site(request)

if obj:
return page_permissions.user_can_change_page(request.user, page=obj.page, site=site)
return page_permissions.user_can_change_page(request.user, page=obj.page, site=obj.page.site)
can_view_page = page_permissions.user_can_change_at_least_one_page(
user=request.user,
site=get_site(request),
site=get_site_from_request(request),
use_cache=False,
)
Comment on lines 1012 to 1016
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (code-quality): Inline variable that is immediately returned (inline-immediately-returned-variable)

return can_view_page
Expand All @@ -1043,48 +1022,34 @@ def has_delete_permission(self, request, obj=None):
"""
if not obj:
return False
site = get_site(request)
return page_permissions.user_can_delete_page(request.user, page=obj.page, site=site)
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
site = get_site(request)
return page_permissions.user_can_change_page_advanced_settings(request.user, page=obj.page, site=site)
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

site = get_site(request)
has_perm = page_permissions.user_can_delete_page_translation(
user=request.user,
page=obj,
language=language,
site=site,
site=obj.site,
)
Comment on lines 1036 to 1041
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (code-quality): Inline variable that is immediately returned (inline-immediately-returned-variable)

return has_perm

def get_sites_for_user(self, user):
sites = Site.objects.order_by("name")

if not get_cms_setting("PERMISSION") or user.is_superuser:
return sites
_has_perm = page_permissions.user_can_change_at_least_one_page
return [site for site in sites if _has_perm(user, site)]

def changelist_view(self, request, extra_context=None):
from django.contrib.admin.views.main import ERROR_FLAG

if redirect_response := needs_site_redirect(request):
return redirect_response

if not self.has_change_permission(request, obj=None):
raise PermissionDenied

if request.method == "POST" and "site" in request.POST:
site_id = request.POST["site"]

if site_id.isdigit() and Site.objects.filter(pk=site_id).exists():
request.session["cms_admin_site"] = site_id

site = get_site(request)
language = get_site_language_from_request(request, site_id=site.pk)
query = request.GET.get("q", "")
Expand Down Expand Up @@ -1142,7 +1107,7 @@ def changelist_view(self, request, extra_context=None):
"admin": self,
"tree": {
"site": site,
"sites": self.get_sites_for_user(request.user),
"sites": get_sites_for_user(request.user),
"query": query,
"is_filtered": changelist_form.is_filtered(),
"items": pages,
Expand Down
84 changes: 84 additions & 0 deletions cms/admin/site_utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
from django.contrib.sites.models import Site
from django.core.exceptions import (
PermissionDenied,
)
from django.http import (
Http404,
HttpResponseRedirect,
)
from django.utils.translation import gettext as _

from cms.utils import get_current_site, page_permissions
from cms.utils.conf import get_cms_setting
from cms.utils.urlutils import add_url_parameters


def get_site_id_from_request(request):
site_id = request.GET.get("site", request.session.get("cms_admin_site"))
if site_id is None:
return None

try:
return int(site_id)
except ValueError:
raise Http404(_("Invalid site ID."))
Comment on lines +23 to +24
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (code-quality): Explicitly raise from a previous error (raise-from-previous-error)

Suggested change
except ValueError:
raise Http404(_("Invalid site ID."))
except ValueError as e:
raise Http404(_("Invalid site ID.")) from e



def get_site_from_request(request):
site_id = get_site_id_from_request(request)
if site_id is None:
return get_current_site()
try:
return Site.objects._get_site_by_id(site_id)
except Site.DoesNotExist:
raise Http404(_("Site does not exist."))
Comment on lines +33 to +34
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (code-quality): Explicitly raise from a previous error (raise-from-previous-error)

Suggested change
except Site.DoesNotExist:
raise Http404(_("Site does not exist."))
except Site.DoesNotExist as e:
raise Http404(_("Site does not exist.")) from e



def needs_site_redirect(request):
user_sites = get_sites_for_user(request.user)
if not user_sites:
raise PermissionDenied(_("You do not have permission to view any sites. Please contact your administrator."))

current_site = get_current_site()
site_id = request.GET.get("site")
legacy_session_site_id = request.session.get("cms_admin_site")
if legacy_session_site_id and site_id is None:
# Remove legacy session and possibly redirect with site query parameter
del request.session["cms_admin_site"]
if legacy_session_site_id == current_site.pk:
return
redirect_url = add_url_parameters(request.path, request.GET, site=legacy_session_site_id)
return HttpResponseRedirect(redirect_url)

# TODO: handle more cases?


def validate_site(request, site):
user_sites = get_sites_for_user(request.user)
if site not in user_sites:
raise PermissionDenied(_("You do not have permission to view this site. Please contact your administrator."))


def get_site(request):
"""
Validates the site from the request and returns it.
If the user does not have permission to view any sites, raises PermissionDenied.
"""
site = get_site_from_request(request)
validate_site(request, site)
return site


def get_sites_for_user(user):
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
11 changes: 0 additions & 11 deletions cms/static/cms/js/modules/cms.pagetree.js
Original file line number Diff line number Diff line change
Expand Up @@ -440,17 +440,6 @@ var PageTree = new Class({
that._reloadHelper();
});

// propagate the sites dropdown "li > a" entries to the hidden sites form
this.ui.container.find('.js-cms-pagetree-site-trigger').on(this.click, function(e) {
e.preventDefault();
var el = $(this);

// prevent if parent is active
if (el.parent().hasClass('active')) {
return false;
}
that.ui.siteForm.find('select').val(el.data().id).end().submit();
});

// additional event handlers
this._setupDropdowns();
Expand Down
Loading