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( - '