Skip to content

Commit 47ffd16

Browse files
fsbraungithub-advanced-security[bot]pre-commit-ci[bot]sourcery-ai[bot]
authored
fix: CSP-compliant data bridge for Django CMS 5 (#285)
* fix: By default, do not register category creation wizard * refactor views * Fix tests * Potential fix for code scanning alert no. 36: Unused global variable Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * ci: auto fixes from pre-commit hooks for more information, see https://pre-commit.ci * feat: Add usage info to changelist * fix: Improved labels * Fix typo in labels * Update tests * Update djangocms_alias/cms_plugins.py Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> * ci: auto fixes from pre-commit hooks for more information, see https://pre-commit.ci * Update djangocms_alias/cms_plugins.py Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> * remove unused template --------- Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
1 parent ecd27af commit 47ffd16

File tree

23 files changed

+511
-508
lines changed

23 files changed

+511
-508
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,3 +15,4 @@ htmlcov
1515
venv*
1616
node_modules
1717
.editorconfig
18+
.vscode/

djangocms_alias/admin.py

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from cms.utils.urlutils import admin_reverse
44
from django import forms
55
from django.contrib import admin, messages
6+
from django.db import models
67
from django.http import (
78
Http404,
89
HttpRequest,
@@ -20,10 +21,8 @@
2021
LIST_ALIAS_URL_NAME,
2122
USAGE_ALIAS_URL_NAME,
2223
)
23-
from .filters import CategoryFilter, SiteFilter
24-
from .forms import AliasGrouperAdminForm
24+
from .filters import CategoryFilter, SiteFilter, UsedFilter
2525
from .models import Alias, AliasContent, Category
26-
from .urls import urlpatterns
2726
from .utils import (
2827
emit_content_change,
2928
emit_content_delete,
@@ -36,8 +35,7 @@
3635
"AliasContentAdmin",
3736
]
3837

39-
alias_admin_classes = [GrouperModelAdmin]
40-
alias_admin_list_display = ["content__name", "category", "admin_list_actions"]
38+
alias_admin_list_display = ["content__name", "category", "used", "admin_list_actions"]
4139
djangocms_versioning_enabled = AliasCMSConfig.djangocms_versioning_enabled
4240

4341
if djangocms_versioning_enabled:
@@ -49,6 +47,7 @@
4947
@admin.register(Category)
5048
class CategoryAdmin(TranslatableAdmin):
5149
list_display = ["name"]
50+
search_fields = ["translations__name"]
5251

5352
def save_model(self, request, obj, form, change):
5453
change = not obj._state.adding
@@ -63,26 +62,33 @@ def save_model(self, request, obj, form, change):
6362

6463

6564
@admin.register(Alias)
66-
class AliasAdmin(*alias_admin_classes):
65+
class AliasAdmin(GrouperModelAdmin):
6766
list_display = alias_admin_list_display
6867
list_display_links = None
6968
list_filter = (
7069
SiteFilter,
7170
CategoryFilter,
71+
UsedFilter,
7272
)
7373
fields = ("content__name", "category", "site", "content__language")
7474
readonly_fields = ("static_code",)
7575
search_fields = ["content__name"]
76-
form = AliasGrouperAdminForm
76+
autocomplete_fields = ["category", "site"]
7777
extra_grouping_fields = ("language",)
7878
EMPTY_CONTENT_VALUE = mark_safe(_("<i>Missing language</i>"))
7979

80-
def get_urls(self) -> list:
81-
return urlpatterns + super().get_urls()
82-
8380
def get_actions_list(self) -> list:
8481
"""Add alias usage list actions"""
85-
return super().get_actions_list() + [self._get_alias_usage_link]
82+
return super().get_actions_list() + [self._get_alias_usage_link, self._get_alias_delete_link]
83+
84+
def get_queryset(self, request):
85+
qs = super().get_queryset(request)
86+
# Annotate each Alias with a boolean indicating if related cmsplugins exist
87+
return qs.annotate(cmsplugins_count=models.Count("cms_plugins"))
88+
89+
@admin.display(description=_("Used"), boolean=True, ordering="cmsplugins_count")
90+
def used(self, obj: Alias) -> bool:
91+
return obj.cmsplugins_count > 0
8692

8793
def has_delete_permission(self, request: HttpRequest, obj: Alias = None) -> bool:
8894
# Alias can be deleted by users who can add aliases,
@@ -93,7 +99,7 @@ def has_delete_permission(self, request: HttpRequest, obj: Alias = None) -> bool
9399
get_model_permission_codename(self.model, "add"),
94100
)
95101
return request.user.is_superuser
96-
return False
102+
return True
97103

98104
def save_model(self, request: HttpRequest, obj: Alias, form: forms.Form, change: bool) -> None:
99105
super().save_model(request, obj, form, change)
@@ -129,9 +135,9 @@ def delete_model(self, request: HttpRequest, obj: Alias):
129135
sender=self.model,
130136
)
131137

132-
def _get_alias_usage_link(self, obj: Alias, request: HttpRequest, disabled: bool = False) -> str:
138+
def _get_alias_usage_link(self, obj: Alias, request: HttpRequest) -> str:
133139
url = admin_reverse(USAGE_ALIAS_URL_NAME, args=[obj.pk])
134-
return self.admin_action_button(url, "info", _("View usage"), disabled=disabled)
140+
return self.admin_action_button(url, "info", _("View usage"))
135141

136142
def _get_alias_delete_link(self, obj: Alias, request: HttpRequest) -> str:
137143
url = admin_reverse(DELETE_ALIAS_URL_NAME, args=[obj.pk])

djangocms_alias/cms_config.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,7 @@
44
from django.conf import settings
55
from packaging.version import Version as PackageVersion
66

7-
from .cms_wizards import (
8-
create_alias_category_wizard,
9-
create_alias_wizard,
10-
)
7+
from .cms_wizards import create_alias_wizard
118
from .models import AliasContent, AliasPlugin, copy_alias_content
129
from .rendering import add_static_alias_js, render_alias_content
1310

@@ -24,7 +21,7 @@ class AliasCMSConfig(CMSAppConfig):
2421
cms_enabled = True
2522
cms_toolbar_enabled_models = [(AliasContent, render_alias_content, "alias")]
2623
moderated_models = [AliasContent]
27-
cms_wizards = [create_alias_wizard, create_alias_category_wizard]
24+
cms_wizards = [create_alias_wizard]
2825

2926
djangocms_moderation_enabled = getattr(settings, "MODERATING_ALIAS_MODELS_ENABLED", True)
3027
djangocms_versioning_enabled = getattr(settings, "VERSIONING_ALIAS_MODELS_ENABLED", djangocms_versioning_installed)

djangocms_alias/cms_plugins.py

Lines changed: 184 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,29 @@
1-
from copy import copy
2-
1+
from cms.models import Page
32
from cms.plugin_base import CMSPluginBase, PluginMenuItem
43
from cms.plugin_pool import plugin_pool
54
from cms.toolbar.utils import get_object_edit_url
5+
from cms.utils import get_language_from_request
66
from cms.utils.permissions import (
77
get_model_permission_codename,
88
has_plugin_permission,
99
)
1010
from cms.utils.plugins import copy_plugins_to_placeholder
1111
from cms.utils.urlutils import add_url_parameters, admin_reverse
12-
from django.utils.translation import (
13-
get_language_from_request,
14-
)
12+
from django.core.exceptions import PermissionDenied
13+
from django.http import HttpResponseBadRequest
14+
from django.shortcuts import get_object_or_404
15+
from django.template.response import TemplateResponse
16+
from django.urls import path
1517
from django.utils.translation import (
1618
gettext_lazy as _,
1719
)
1820

21+
from djangocms_alias import constants
22+
from djangocms_alias.utils import emit_content_change
23+
24+
from . import views
1925
from .constants import CREATE_ALIAS_URL_NAME, DETACH_ALIAS_PLUGIN_URL_NAME
20-
from .forms import AliasPluginForm
26+
from .forms import AliasPluginForm, BaseCreateAliasForm, CreateAliasForm
2127
from .models import Alias as AliasModel
2228
from .models import AliasContent, AliasPlugin
2329

@@ -40,7 +46,7 @@ def get_render_template(self, context, instance, placeholder):
4046
@classmethod
4147
def get_extra_plugin_menu_items(cls, request, plugin):
4248
if plugin.plugin_type == cls.__name__:
43-
alias_content = plugin.alias.get_content()
49+
alias_content = plugin.alias.get_content(show_draft_content=True)
4450
detach_endpoint = admin_reverse(
4551
DETACH_ALIAS_PLUGIN_URL_NAME,
4652
args=[plugin.pk],
@@ -74,7 +80,7 @@ def get_extra_plugin_menu_items(cls, request, plugin):
7480

7581
data = {
7682
"plugin": plugin.pk,
77-
"language": get_language_from_request(request, check_path=True),
83+
"language": get_language_from_request(request),
7884
}
7985
endpoint = add_url_parameters(admin_reverse(CREATE_ALIAS_URL_NAME), **data)
8086
return [
@@ -90,19 +96,20 @@ def get_extra_plugin_menu_items(cls, request, plugin):
9096
def get_extra_placeholder_menu_items(cls, request, placeholder):
9197
data = {
9298
"placeholder": placeholder.pk,
93-
"language": get_language_from_request(request, check_path=True),
99+
"language": get_language_from_request(request),
94100
}
95101
endpoint = add_url_parameters(admin_reverse(CREATE_ALIAS_URL_NAME), **data)
96102

97-
menu_items = [
98-
PluginMenuItem(
99-
_("Create Alias"),
100-
endpoint,
101-
action="modal",
102-
attributes={"cms-icon": "alias"},
103-
),
104-
]
105-
return menu_items
103+
if placeholder.cmsplugin_set.exists():
104+
return [
105+
PluginMenuItem(
106+
_("Create Alias"),
107+
endpoint,
108+
action="modal",
109+
attributes={"cms-icon": "alias"},
110+
),
111+
]
112+
return []
106113

107114
@classmethod
108115
def can_create_alias(cls, user, plugins=None, replace=False):
@@ -140,26 +147,169 @@ def can_detach(cls, user, target_placeholder, plugins):
140147

141148
@classmethod
142149
def detach_alias_plugin(cls, plugin, language):
143-
source_placeholder = plugin.alias.get_placeholder(language, show_draft_content=True) # We're in edit mode
150+
source_plugins = plugin.alias.get_plugins(language, show_draft_content=True) # We're in edit mode
144151
target_placeholder = plugin.placeholder
152+
plugin_position = plugin.position
153+
target_placeholder.delete_plugin(plugin)
154+
if source_plugins:
155+
if target_last_plugin := target_placeholder.get_last_plugin(plugin.language):
156+
target_placeholder._shift_plugin_positions(
157+
language,
158+
start=plugin_position,
159+
offset=len(source_plugins) + target_last_plugin.position + 1, # enough space to shift back
160+
)
145161

146-
# Deleting uses a copy of a plugin to preserve pk on existing
147-
# ``plugin`` object. This is done due to
148-
# plugin.get_plugin_toolbar_info requiring a PK in a passed
149-
# instance.
150-
target_placeholder.delete_plugin(copy(plugin))
151-
target_placeholder._shift_plugin_positions(
152-
language,
153-
plugin.position,
154-
offset=target_placeholder.get_last_plugin_position(language),
155-
)
156-
if source_placeholder:
157-
source_plugins = source_placeholder.get_plugins_list()
158-
copied_plugins = copy_plugins_to_placeholder(
162+
return copy_plugins_to_placeholder(
159163
source_plugins,
160164
placeholder=target_placeholder,
161165
language=language,
162-
start_positions={language: plugin.position},
166+
start_positions={language: plugin_position},
163167
)
164-
return copied_plugins
165168
return []
169+
170+
def get_plugin_urls(self):
171+
return super().get_plugin_urls() + [
172+
path(
173+
"create-alias/",
174+
self.create_alias_view,
175+
name=constants.CREATE_ALIAS_URL_NAME,
176+
),
177+
path(
178+
"aliases/<int:pk>/usage/",
179+
self.alias_usage_view,
180+
name=constants.USAGE_ALIAS_URL_NAME,
181+
),
182+
path(
183+
"detach-alias/<int:plugin_pk>/",
184+
self.detach_alias_plugin_view,
185+
name=constants.DETACH_ALIAS_PLUGIN_URL_NAME,
186+
),
187+
path(
188+
"select2/",
189+
views.AliasSelect2View.as_view(),
190+
name=constants.SELECT2_ALIAS_URL_NAME,
191+
),
192+
path(
193+
"category-select2/",
194+
views.CategorySelect2View.as_view(),
195+
name=constants.CATEGORY_SELECT2_URL_NAME,
196+
),
197+
]
198+
199+
def create_alias_view(self, request):
200+
if not request.user.is_staff:
201+
raise PermissionDenied
202+
203+
form = BaseCreateAliasForm(request.GET or None)
204+
205+
initial_data = form.cleaned_data if form.is_valid() else None
206+
if request.method == "GET" and not form.is_valid():
207+
return HttpResponseBadRequest("Form received unexpected values")
208+
209+
user = request.user
210+
211+
create_form = CreateAliasForm(
212+
request.POST or None,
213+
initial=initial_data,
214+
user=user,
215+
)
216+
217+
if not create_form.is_valid():
218+
opts = self.model._meta
219+
context = {
220+
"form": create_form,
221+
"has_change_permission": True,
222+
"opts": opts,
223+
"root_path": admin_reverse("index"),
224+
"is_popup": True,
225+
"app_label": opts.app_label,
226+
"media": (Alias().media + create_form.media),
227+
}
228+
return TemplateResponse(request, "djangocms_alias/create_alias.html", context)
229+
230+
plugins = create_form.get_plugins()
231+
232+
if not plugins:
233+
return HttpResponseBadRequest(
234+
"Plugins are required to create an alias",
235+
)
236+
237+
replace = create_form.cleaned_data.get("replace")
238+
if not Alias.can_create_alias(user, plugins, replace):
239+
raise PermissionDenied
240+
241+
alias, alias_content, alias_plugin = create_form.save()
242+
emit_content_change([alias_content])
243+
244+
if replace:
245+
return self.render_close_frame(
246+
request,
247+
obj=alias_plugin,
248+
action="reload",
249+
)
250+
return TemplateResponse(request, "admin/cms/page/close_frame.html")
251+
252+
def detach_alias_plugin_view(self, request, plugin_pk):
253+
if not request.user.is_staff:
254+
raise PermissionDenied
255+
256+
instance = get_object_or_404(AliasPlugin, pk=plugin_pk)
257+
258+
if request.method == "GET":
259+
opts = self.model._meta
260+
context = {
261+
"has_change_permission": True,
262+
"opts": opts,
263+
"root_path": admin_reverse("index"),
264+
"is_popup": True,
265+
"app_label": opts.app_label,
266+
"object_name": _("Alias"),
267+
"object": instance.alias,
268+
}
269+
return TemplateResponse(request, "djangocms_alias/detach_alias.html", context)
270+
271+
language = get_language_from_request(request)
272+
273+
plugins = instance.alias.get_plugins(language, show_draft_content=True)
274+
can_detach = self.can_detach(request.user, instance.placeholder, plugins)
275+
276+
if not can_detach:
277+
raise PermissionDenied
278+
279+
self.detach_alias_plugin(
280+
plugin=instance,
281+
language=language,
282+
)
283+
284+
return self.render_close_frame(
285+
request,
286+
obj=instance,
287+
action="reload",
288+
)
289+
290+
def alias_usage_view(self, request, pk):
291+
if not request.user.is_staff:
292+
raise PermissionDenied
293+
294+
alias = get_object_or_404(AliasModel, pk=pk)
295+
opts = AliasModel._meta
296+
title = _(f"Objects using alias: {alias}")
297+
context = {
298+
"has_change_permission": True,
299+
"opts": opts,
300+
"root_path": admin_reverse("index"),
301+
"is_popup": True,
302+
"app_label": opts.app_label,
303+
"object_name": _("Alias"),
304+
"object": alias,
305+
"title": title,
306+
"original": title,
307+
"show_back_btn": request.GET.get("back"),
308+
"objects_list": sorted(
309+
alias.objects_using,
310+
# First show Pages on list
311+
key=lambda obj: isinstance(obj, Page),
312+
reverse=True,
313+
),
314+
}
315+
return TemplateResponse(request, "djangocms_alias/alias_usage.html", context)

0 commit comments

Comments
 (0)