Skip to content

Commit a850f2a

Browse files
feat: Indent page titles according to tree structure (#246)
* feat: Add indentation for the Page model to reflect tree structure in link target dropdown * Fix tests * Fix type annotations * Update readme * Update trove classifiers * Show page titles in current language * Add tests * Bump verision * Update tests/test_endpoint.py Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> * Fix tests * Fix 3.11 compat * Update tests/test_endpoint.py Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com> * Fix tests * Fix condition in tests * Depth calculation for djagnio CMS < 5 * Fix for 3.11 * Fix 4.1 * Minor simplification * Fix for 3.11 * fix: Consistent internal link * Fix site treatment * Fix linting * Fix merge conflict * fix coverage * fix: permission tests --------- Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
1 parent 269a790 commit a850f2a

File tree

9 files changed

+202
-63
lines changed

9 files changed

+202
-63
lines changed

README.rst

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -305,4 +305,3 @@ the database. If you encounter any issues, please report them on
305305
.. |djangocms| image:: https://img.shields.io/pypi/frameworkversions/django-cms/djangocms-link
306306
:alt: PyPI - django CMS Versions from Framework Classifiers
307307
:target: https://www.django-cms.org/
308-

conftest.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,23 @@
6969
] + CMS_MIDDLEWARE
7070
SITE_ID = 1
7171
LANGUAGE_CODE = "en"
72-
LANGUAGES = (("en", "English"),)
72+
LANGUAGES = (("en", "English"), ("fr", "French"))
73+
CMS_LANGUAGES = {
74+
1: [
75+
{
76+
"code": "en",
77+
"name": "English",
78+
"public": True,
79+
},
80+
{
81+
"code": "fr",
82+
"name": "French",
83+
"public": True,
84+
"fallbacks": ["en"],
85+
"hide_untranslated": False,
86+
},
87+
]
88+
}
7389
STATIC_URL = "/static/"
7490
MEDIA_URL = "/media/"
7591
MIGRATION_MODULES = {}

djangocms_link/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
__version__ = "5.0.1"
1+
__version__ = "5.1.0"

djangocms_link/admin.py

Lines changed: 74 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1+
from __future__ import annotations
2+
13
from django.apps import apps
24
from django.conf import settings
35
from django.contrib import admin
4-
from django.core.exceptions import FieldError, PermissionDenied
5-
from django.db.models import OuterRef, Q, Subquery
6-
from django.http import Http404, JsonResponse
6+
from django.core.exceptions import PermissionDenied
7+
from django.db.models import F, Model, Prefetch, Q, QuerySet
8+
from django.http import Http404, HttpRequest, JsonResponse
79
from django.urls import path
810
from django.utils.translation import gettext as _
911
from django.views.generic.list import BaseListView
@@ -17,6 +19,9 @@
1719
from .helpers import get_manager
1820

1921

22+
UNICODE_SPACE = "\u3000" # This is a full-width space character (U+3000)
23+
24+
2025
_version = int(__version__.split(".")[0])
2126
if _version >= 4:
2227
from cms.admin.utils import GrouperModelAdmin
@@ -37,7 +42,7 @@ class AdminUrlsView(BaseListView):
3742
paginate_by = getattr(settings, "DJANGOCMS_LINK_PAGINATE_BY", 50)
3843
admin_site = None
3944

40-
def get(self, request, *args, **kwargs):
45+
def get(self, request: HttpRequest, *args, **kwargs) -> JsonResponse:
4146
"""
4247
Return a JsonResponse with search results (query parameter "q") usable by
4348
Django admin's autocomplete view. Each item is returned as defined in
@@ -90,7 +95,7 @@ def get(self, request, *args, **kwargs):
9095
}
9196
)
9297

93-
def get_page(self):
98+
def get_page(self) -> int:
9499
page_kwarg = self.page_kwarg
95100
page = self.kwargs.get(page_kwarg) or self.request.GET.get(page_kwarg) or 1
96101
try:
@@ -101,7 +106,7 @@ def get_page(self):
101106
)
102107
return page_number
103108

104-
def get_paginated_multi_qs(self, qs_list):
109+
def get_paginated_multi_qs(self, qs_list: list[QuerySet]) -> list[Model] | QuerySet:
105110
"""
106111
Paginate multiple querysets and return a result list.
107112
"""
@@ -110,19 +115,34 @@ def get_paginated_multi_qs(self, qs_list):
110115
return qs_list[0]
111116
# Slize all querysets, evaluate and join them into a list
112117
max_items = self.get_page() * self.paginate_by
113-
return sum((list(qs[:max_items]) for qs in qs_list), start=[])
114-
115-
def get_reference(self, request):
118+
objects = []
119+
for qs in qs_list:
120+
for item in qs:
121+
if self.has_perm(self.request, item):
122+
objects.append(item)
123+
124+
if len(objects) >= max_items:
125+
# No need to touch the rest of the querysets
126+
# as we have enough items already
127+
break
128+
return objects
129+
130+
def get_reference(self, request: HttpRequest) -> JsonResponse:
116131
try:
117132
model_str, pk = request.GET.get("g").split(":")
118133
app, model = model_str.split(".")
119134
model = apps.get_model(app, model)
120135
model_admin = self.admin_site._registry.get(model)
136+
language = get_language_from_request(request)
137+
121138
if model_str == "cms.page" and _version >= 4 or model_admin is None:
122139
obj = get_manager(model).get(pk=pk)
123140
if model_str == "cms.page":
124-
language = get_language_from_request(request)
125-
obj.__link_text__ = obj.get_admin_content(language).title
141+
obj.__link_text__ = obj.get_admin_content(language, fallback=True).title
142+
return JsonResponse(self.serialize_result(obj))
143+
elif model_str == "cms.page":
144+
obj = get_manager(model).get(pk=pk)
145+
obj.__link_text__ = obj.get_title(language, fallback=True)
126146
return JsonResponse(self.serialize_result(obj))
127147

128148
if hasattr(model_admin, "get_link_queryset"):
@@ -151,43 +171,54 @@ def get_optgroups(self, context):
151171
results.append(model)
152172
return results
153173

154-
def serialize_result(self, obj):
174+
def serialize_result(self, obj: Model) -> dict:
155175
"""
156176
Convert the provided model object to a dictionary that is added to the
157177
results list.
158178
"""
179+
if isinstance(obj, Page) and hasattr(obj, "prefetched_content") and hasattr(obj, "get_admin_content"):
180+
obj.admin_content_cache = {trans.language: trans for trans in obj.prefetched_content}
181+
obj.__link_text__ = obj.get_admin_content(self.language).title
182+
183+
indentation = UNICODE_SPACE * (max(getattr(obj, "__depth__", 1), 1) - 1)
159184
return {
160185
"id": f"{obj._meta.app_label}.{obj._meta.model_name}:{obj.pk}",
161-
"text": getattr(obj, "__link_text__", str(obj)) or str(obj),
186+
"text": indentation + (getattr(obj, "__link_text__", str(obj)) or str(obj)),
162187
"url": obj.get_absolute_url(),
163188
"verbose_name": str(obj._meta.verbose_name).capitalize(),
164189
}
165190

166-
def get_queryset(self):
191+
def get_queryset(self) -> QuerySet:
167192
"""Return queryset based on ModelAdmin.get_search_results()."""
168193
languages = get_language_list()
169194
try:
170-
# django CMS 5.0+
171-
qs = (
195+
# django CMS 4.1/5.0+
196+
content_qs = (
172197
PageContent.admin_manager.filter(language__in=languages)
173198
.filter(
174199
Q(title__icontains=self.term) | Q(menu_title__icontains=self.term)
175200
)
176201
.current_content()
177202
)
178203
qs = (
179-
Page.objects.filter(pk__in=qs.values_list("page_id", flat=True))
180-
.order_by("path")
181-
.annotate(
182-
__link_text__=Subquery(
183-
qs.filter(page_id=OuterRef("pk")).values("title")[:1]
184-
)
204+
Page.objects.filter(pk__in=content_qs.values_list("page_id", flat=True))
205+
.order_by("path" if _version >= 5 else "node__path")
206+
.prefetch_related(
207+
Prefetch(
208+
"pagecontent_set",
209+
to_attr="prefetched_content",
210+
queryset=PageContent.admin_manager.current_content(),
211+
),
185212
)
186213
)
214+
if not self.term:
215+
qs = qs.annotate(
216+
__depth__=F("depth" if _version >= 5 else "node__depth")
217+
)
187218
if self.site:
188-
qs = qs.filter(site_id=self.site)
189-
except (AttributeError, FieldError):
190-
# django CMS 3.11 - 4.1
219+
qs = qs.filter(site_id=self.site) if _version >= 5 else qs.filter(node__site_id=self.site)
220+
except (AttributeError,):
221+
# django CMS 3.11
191222
qs = (
192223
get_manager(PageContent, current_content=True)
193224
.filter(language__in=languages)
@@ -198,20 +229,27 @@ def get_queryset(self):
198229
qs = (
199230
Page.objects.filter(pk__in=qs.values_list("page_id", flat=True))
200231
.order_by("node__path")
201-
.annotate(
202-
__link_text__=Subquery(
203-
qs.filter(page_id=OuterRef("pk")).values("title")[:1]
204-
)
232+
.prefetch_related(
233+
Prefetch(
234+
"title_set",
235+
to_attr="prefetched_content",
236+
queryset=get_manager(PageContent, current_content=True).all(),
237+
),
205238
)
206239
)
207240
if "publisher_draft" in Page._meta.fields_map:
208241
# django CMS 3.11
209242
qs = qs.filter(publisher_is_draft=True)
243+
if not self.term:
244+
qs = qs.annotate(
245+
__depth__=F("node__depth")
246+
)
247+
210248
if self.site:
211249
qs = qs.filter(node__site_id=self.site)
212250
return qs
213251

214-
def add_admin_querysets(self, qs):
252+
def add_admin_querysets(self, qs: list[QuerySet]) -> None:
215253
for model_admin in REGISTERED_ADMIN:
216254
try:
217255
# hack: GrouperModelAdmin expects a language to be temporarily set
@@ -229,20 +267,17 @@ def add_admin_querysets(self, qs):
229267
)
230268
elif hasattr(model_admin.model, "sites") and self.site:
231269
new_qs = new_qs.filter(sites__id=self.site)
232-
new_qs, search_use_distinct = model_admin.get_search_results(
233-
self.request, new_qs, self.term
234-
)
270+
new_qs, search_use_distinct = model_admin.get_search_results(self.request, new_qs, self.term)
235271
if search_use_distinct: # pragma: no cover
236272
new_qs = new_qs.distinct()
237-
238273
qs.append(new_qs)
239274
except Exception: # pragma: no cover
240275
# Still report back remaining urls even if one model fails
241276
pass
242277

243278
return qs
244279

245-
def process_request(self, request):
280+
def process_request(self, request: HttpRequest) -> tuple[str, str, int | None]:
246281
"""
247282
Validate request integrity, extract and return request parameters.
248283
"""
@@ -257,7 +292,7 @@ def process_request(self, request):
257292
language = get_language_from_request(request)
258293
return term, language, site
259294

260-
def has_perm(self, request, obj=None):
295+
def has_perm(self, request: HttpRequest, obj=None) -> bool:
261296
"""Check if user has permission to access the related model."""
262297
if obj is None:
263298
return True
@@ -279,11 +314,11 @@ def __init__(self, *args, **kwargs):
279314
super().__init__(*args, **kwargs)
280315
self.global_link_url_name = f"{self.opts.app_label}_{self.opts.model_name}_urls"
281316

282-
def has_module_permission(self, request): # pragma: no cover
317+
def has_module_permission(self, request: HttpRequest) -> bool: # pragma: no cover
283318
# Remove from admin
284319
return False
285320

286-
def get_urls(self):
321+
def get_urls(self) -> list:
287322
# Only url endpoint public, do not call super().get_urls()
288323
return [
289324
path(
@@ -293,7 +328,7 @@ def get_urls(self):
293328
),
294329
]
295330

296-
def url_view(self, request):
331+
def url_view(self, request: HttpRequest) -> JsonResponse:
297332
return AdminUrlsView.as_view(admin_site=self.admin_site)(request)
298333

299334

djangocms_link/helpers.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -89,17 +89,17 @@ def __init__(self, initial=None, **kwargs):
8989
self["internal_link"] = (
9090
f"{initial._meta.app_label}.{initial._meta.model_name}:{initial.pk}"
9191
)
92+
# Prepopulate cache since we have to object to get the URL
9293
self["__cache__"] = initial.get_absolute_url()
93-
if anchor:
94-
self["anchor"] = anchor
94+
if self["__cache__"] and anchor:
9595
self["__cache__"] += anchor
9696

9797
@property
98-
def url(self):
98+
def url(self) -> str:
9999
return get_link(self) or ""
100100

101101
@property
102-
def type(self):
102+
def type(self) -> str:
103103
for key in ("internal_link", "file_link"):
104104
if key in self:
105105
return key

pyproject.toml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,10 +23,12 @@ classifiers = [
2323
"Framework :: Django :: 4.2",
2424
"Framework :: Django :: 5.0",
2525
"Framework :: Django :: 5.1",
26+
"Framework :: Django :: 5.2",
2627
"Framework :: Django CMS",
2728
"Framework :: Django CMS :: 3.11",
2829
"Framework :: Django CMS :: 4.0",
2930
"Framework :: Django CMS :: 4.1",
31+
"Framework :: Django CMS :: 5.0",
3032
"Intended Audience :: Developers",
3133
"License :: OSI Approved :: BSD License",
3234
"Operating System :: OS Independent",
@@ -58,6 +60,11 @@ version = { attr = "djangocms_link.__version__" }
5860
[tool.setuptools.package-data]
5961
djangocms_link = [ "static/**/*", "templates/**/*", "locale/**/*", "LICENSE", "README.rst" ]
6062

63+
[tool.ruff]
64+
exclude = [
65+
"djangocms_link/migrations/*",
66+
]
67+
6168
[tool.isort]
6269
line_length = 119
6370
skip = [

tests/settings.py

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,6 @@
77
"filer",
88
"tests.utils",
99
],
10-
"CMS_LANGUAGES": {
11-
1: [
12-
{
13-
"code": "en",
14-
"name": "English",
15-
}
16-
]
17-
},
18-
"LANGUAGE_CODE": "en",
1910
"THUMBNAIL_PROCESSORS": (
2011
"easy_thumbnails.processors.colorspace",
2112
"easy_thumbnails.processors.autocrop",

0 commit comments

Comments
 (0)