From 925be837dc5b4ff35adabb4fb29b2092349929fe Mon Sep 17 00:00:00 2001
From: Wolfgang Fehr <24782511+wfehr@users.noreply.github.com>
Date: Thu, 18 Sep 2025 06:49:31 +0200
Subject: [PATCH 1/2] add relative_link to link field
Provide functionality to link internal pages not directly accessible via
'internal_link', e.g. apphook-urls.
---
djangocms_link/fields.py | 34 +++++++++++++++++--
djangocms_link/helpers.py | 9 +++--
.../static/djangocms_link/link-widget.css | 6 ++--
djangocms_link/validators.py | 29 ++++++++++++++++
tests/test_fields.py | 33 ++++++++++++++----
tests/test_link_dict.py | 11 ++++++
tests/test_models.py | 19 ++++++++++-
7 files changed, 127 insertions(+), 14 deletions(-)
diff --git a/djangocms_link/fields.py b/djangocms_link/fields.py
index a2ed7ee8..c5ade42c 100644
--- a/djangocms_link/fields.py
+++ b/djangocms_link/fields.py
@@ -24,7 +24,11 @@
except (ModuleNotFoundError, ImportError): # pragma: no cover
File = None
-from djangocms_link.validators import AnchorValidator, ExtendedURLValidator
+from djangocms_link.validators import (
+ AnchorValidator,
+ ExtendedURLValidator,
+ RelativeURLValidator,
+)
MINIMUM_INPUT_LENGTH = getattr(settings, "DJANGOCMS_LINK_MINIMUM_INPUT_LENGTH", 0)
@@ -146,6 +150,7 @@ def optgroups(self, name: str, value: str, attr: dict | None = None):
# Configure the LinkWidget
link_types = {
"internal_link": _("Internal link"),
+ "relative_link": _("Relative link"),
"external_link": _("External link/anchor"),
}
if File:
@@ -155,7 +160,15 @@ def optgroups(self, name: str, value: str, attr: dict | None = None):
allowed_link_types = getattr(
settings,
"DJANGOCMS_LINK_ALLOWED_LINK_TYPES",
- ("internal_link", "external_link", "file_link", "anchor", "mailto", "tel"),
+ (
+ "internal_link",
+ "relative_link",
+ "external_link",
+ "file_link",
+ "anchor",
+ "mailto",
+ "tel",
+ ),
)
# Adjust example uri schemes to allowed link types
@@ -195,6 +208,16 @@ def optgroups(self, name: str, value: str, attr: dict | None = None):
).format(example_uri_scheme),
},
), # External link input
+ "relative_link": TextInput(
+ attrs={
+ "widget": "relative_link",
+ "placeholder": _("/some/path - optionally append #anchor"),
+ "data-help": _(
+ "Provide a relative link. Optionally, add an #anchor "
+ "(including the #) to scroll to."
+ ).format(example_uri_scheme),
+ },
+ ), # Relative link input
"internal_link": LinkAutoCompleteWidget(
attrs={
"widget": "internal_link",
@@ -291,6 +314,9 @@ class LinkFormField(Field):
external_link_validators = [
ExtendedURLValidator(allowed_link_types=allowed_link_types)
]
+ relative_link_validators = [
+ RelativeURLValidator(allowed_link_types=allowed_link_types)
+ ]
internal_link_validators = []
file_link_validators = []
anchor_validators = [AnchorValidator()]
@@ -314,6 +340,10 @@ def prepare_value(self, value: dict) -> list[str | None]:
pos = self._get_pos("external_link")
multi_value[0] = "external_link"
multi_value[pos] = value["external_link"]
+ elif "relative_link" in value:
+ pos = self._get_pos("relative_link")
+ multi_value[0] = "relative_link"
+ multi_value[pos] = value["relative_link"]
elif "internal_link" in value:
pos = self._get_pos("internal_link")
anchor_pos = self._get_pos("anchor")
diff --git a/djangocms_link/helpers.py b/djangocms_link/helpers.py
index 8b088572..b7123165 100644
--- a/djangocms_link/helpers.py
+++ b/djangocms_link/helpers.py
@@ -49,6 +49,8 @@ def get_link(link_field_value: dict, site_id: int | None = None) -> str | None:
if link_field_value["external_link"].startswith("tel:"):
return link_field_value["external_link"].replace(" ", "")
return link_field_value["external_link"] or None
+ elif "relative_link" in link_field_value:
+ return link_field_value["relative_link"] or None
if "__cache__" in link_field_value:
return link_field_value["__cache__"] or None
@@ -82,7 +84,10 @@ def __init__(self, initial=None, **kwargs):
if isinstance(initial, dict):
self.update(initial)
elif isinstance(initial, str):
- self["external_link"] = initial
+ if initial.startswith("/"):
+ self["relative_link"] = initial
+ else:
+ self["external_link"] = initial
elif isinstance(initial, File):
self["file_link"] = initial.pk
elif isinstance(initial, models.Model):
@@ -100,7 +105,7 @@ def url(self) -> str:
@property
def type(self) -> str:
- for key in ("internal_link", "file_link"):
+ for key in ("relative_link", "internal_link", "file_link"):
if key in self:
return key
if "external_link" in self:
diff --git a/djangocms_link/static/djangocms_link/link-widget.css b/djangocms_link/static/djangocms_link/link-widget.css
index 9a70b123..080ea765 100644
--- a/djangocms_link/static/djangocms_link/link-widget.css
+++ b/djangocms_link/static/djangocms_link/link-widget.css
@@ -12,7 +12,7 @@
min-width: unset;
}
}
- .external_link, .internal_link, .file_link, .anchor, .site {
+ .external_link, .relative_link, .internal_link, .file_link, .anchor, .site {
display: none;
padding: 0;
select, input {
@@ -23,7 +23,8 @@
width: 100% !important;
}
}
- .external_link {
+ .external_link,
+ .relative_link {
width: 75%;
}
.internal_link {
@@ -37,6 +38,7 @@
margin-top: 0.5em;
}
&[data-type="external_link"] .external_link,
+ &[data-type="relative_link"] .relative_link,
&[data-type="internal_link"] .internal_link,
&[data-type="internal_link"] .site,
&[data-type="internal_link"] .anchor
diff --git a/djangocms_link/validators.py b/djangocms_link/validators.py
index 4fed5ff9..9aa73f12 100644
--- a/djangocms_link/validators.py
+++ b/djangocms_link/validators.py
@@ -110,3 +110,32 @@ def __call__(self, value: str):
):
return EmailValidator()(value[7:])
return super().__call__(value)
+
+
+@deconstructible
+class RelativeURLValidator:
+ message = _("Enter a valid realtive link")
+ code = "invalid"
+
+ def __init__(self, allowed_link_types: list = None, **kwargs):
+ self.allowed_link_types = allowed_link_types
+ super().__init__(**kwargs)
+
+ def __call__(self, value: str):
+ if not isinstance(value, str) or len(value) > URLValidator.max_length:
+ raise ValidationError(self.message, code=self.code, params={"value": value})
+ if URLValidator.unsafe_chars.intersection(value):
+ raise ValidationError(self.message, code=self.code, params={"value": value})
+ if (
+ value.startswith("/") and (
+ self.allowed_link_types is not None
+ and "relative_link" not in self.allowed_link_types
+ )
+ or not value.startswith("/")
+ ):
+ raise ValidationError(
+ self.message,
+ code=self.code,
+ params={"value": value},
+ )
+ return value
diff --git a/tests/test_fields.py b/tests/test_fields.py
index 8577ee54..35644dcf 100644
--- a/tests/test_fields.py
+++ b/tests/test_fields.py
@@ -33,15 +33,16 @@ class LinkForm(forms.Form):
'',
form_html,
)
# Render internal URL field
self.assertIn(
- '",
form_html,
)
+ # Render relative URL field
+ self.assertIn(
+ '',
+ form_html,
+ )
# Render external URL field
self.assertIn(
''
''
''
+ ''
''
'',
str(LinkNotRequiredForm()),
@@ -91,6 +101,7 @@ def check_value(value):
self.assertEqual(form.cleaned_data["link_field"], value)
check_value({"internal_link": f"cms.page:{self.page.id}", "anchor": "#anchor"})
+ check_value({"relative_link": "/some/path"})
check_value({"external_link": "https://example.com"})
check_value({"external_link": "#anchor"})
check_value({"file_link": str(self.file.id)})
@@ -101,7 +112,15 @@ class LinkForm(forms.Form):
link_field = LinkFormField(required=False)
form = LinkForm(initial={"link_field": {"internal_link": f"cms.page:{self.page.id}"}})
- self.assertEqual(form["link_field"].value(), ["internal_link", None, f"cms.page:{self.page.id}", "", None])
+ self.assertEqual(form["link_field"].value(), ["internal_link", None, None, f"cms.page:{self.page.id}", "", None])
+
+ def test_form_field_initial_works_relative(self):
+ class LinkForm(forms.Form):
+ link_field = LinkFormField(required=False)
+
+ some_string = "/some/path"
+ form = LinkForm(initial={"link_field": {"relative_link": some_string}})
+ self.assertEqual(form["link_field"].value(), ["relative_link", None, some_string, None, None, None])
def test_form_field_initial_works_external(self):
class LinkForm(forms.Form):
@@ -109,13 +128,13 @@ class LinkForm(forms.Form):
some_string = "https://example.com"
form = LinkForm(initial={"link_field": {"external_link": some_string}})
- self.assertEqual(form["link_field"].value(), ["external_link", some_string, None, None, None])
+ self.assertEqual(form["link_field"].value(), ["external_link", some_string, None, None, None, None])
def test_widget_renders_selection(self):
widget = LinkWidget()
pre_select_page = len(widget.widgets) * [None]
pre_select_page[0] = "internal_link"
- pre_select_page[2] = f"cms.page:{self.page.id}"
+ pre_select_page[3] = f"cms.page:{self.page.id}"
rendered_widget = widget.render(
"link_field", pre_select_page, attrs={"id": "id_link_field"}
)
@@ -129,14 +148,14 @@ def test_widget_renders_site_selector(self):
widget = LinkWidget(site_selector=True)
pre_select_page = len(widget.widgets) * [None]
pre_select_page[0] = "internal_link"
- pre_select_page[2] = f"cms.page:{self.page.id}"
+ pre_select_page[3] = f"cms.page:{self.page.id}"
rendered_widget = widget.render(
"link_field", pre_select_page, attrs={"id": "id_link_field"}
)
# Subwidget is present
self.assertIn(
- '