Skip to content

Commit

Permalink
Add DJANGOCMS_LINK_ALLOWED_LINK_TYPES config
Browse files Browse the repository at this point in the history
  • Loading branch information
fsbraun committed Oct 28, 2024
1 parent 3da1fd8 commit c7c31fb
Show file tree
Hide file tree
Showing 3 changed files with 110 additions and 65 deletions.
20 changes: 20 additions & 0 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,26 @@ otherwise you will get a *template does not exist* error. You can do this by
copying the ``default`` folder inside that directory and renaming it to
``feature``.

Link types
...........

By default, django CMS Link provides three major link types: internal, external,
and file link (if django-filer is installed).

Phone links or email links can be entered by using the ``tel:`` or ``mailto:``
scheme, respectively, in the external link field.

By changing the ``DJANGOCMS_LINK_ALLOWED_LINK_TYPES`` setting you can limit
the type of links accepted. The default is::

DJANGOCMS_LINK_ALLOWED_LINK_TYPES = [
'internal_link', # Pages and other models
'external_link', # Hand-typed URLs
'file_link', # Files from django-filer
'tel', # Phone numbers as external links using the tel: scheme
'mailto', # Email addresses as external links using the mailto: scheme
'anchor', # Anchors in the current page as external links using #
]

Linkable models
...............
Expand Down
145 changes: 83 additions & 62 deletions djangocms_link/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,6 @@
from djangocms_link.validators import AnchorValidator, ExtendedURLValidator


link_types = {
"internal_link": _("Internal link"),
"external_link": _("External link/anchor"),
}
if File:
link_types["file_link"] = _("File link")


MINIMUM_INPUT_LENGTH = getattr(
settings, "DJANGOCMS_LINK_SELECT2_MINIMUM_INPUT_LENGTH", 0
)
Expand Down Expand Up @@ -149,6 +141,78 @@ def optgroups(self, name, value, attr=None):
return groups


# Configure the LinkWidget
link_types = {
"internal_link": _("Internal link"),
"external_link": _("External link/anchor"),
}
if File:
link_types["file_link"] = _("File link")

# Get the allowed link types from the settings
allowed_link_types = getattr(
settings, "DJANGOCMS_LINK_ALLOWED_LINK_TYPES",
("internal_link", "external_link", "file_link", "anchor", "mailto", "tel")
)

# Adjust example uri schemes to allowed link types
example_uri_scheme = "'https://'" + (", 'tel:'" if "tel" in allowed_link_types else "") + \
(", or 'mailto:'" if 'mailto' in allowed_link_types else "")

# Show anchor sub-widget only for internal_link
_mapping = {key: key for key in link_types.keys()}
_mapping["anchor"] = "internal_link"

# Remove disallowed link types
link_types = {key: value for key, value in link_types.items() if key in allowed_link_types}

# Create the available widgets
_available_widgets = {
"always": Select(
choices=list(link_types.items()),
attrs={
"class": "js-link-widget-selector",
"data-help": _("No destination selected. Use the dropdown to select a destination.")
},
), # Link type selector
"external_link": URLInput(
attrs={
"widget": "external_link",
"placeholder": _("https://example.com or #anchor"),
"data-help": _(
"Provide a link to an external URL, including the schema such as {}. "
"Optionally, add an #anchor (including the #) to scroll to."
).format(example_uri_scheme),
},
), # External link input
"internal_link": LinkAutoCompleteWidget(
attrs={
"widget": "internal_link",
"data-help": _(
"Select from available internal destinations. Optionally, add an anchor to scroll to."
),
"data-placeholder": _("Select internal destination"),
},
), # Internal link selector
"anchor": TextInput(
attrs={
"widget": "anchor",
"placeholder": _("#anchor"),
"data-help": _("Provide an anchor to scroll to."),
}
),
}
if File:
_available_widgets["file_link"] = AdminFileWidget(
rel=ManyToOneRel(FilerFileField, File, "id"),
admin_site=site,
attrs={
"widget": "file_link",
"data-help": _("Select a file as destination."),
},
)


class LinkWidget(MultiWidget):
template_name = "djangocms_link/admin/link_widget.html"
data_pos = {}
Expand All @@ -160,58 +224,14 @@ class Media:
css = {"all": ("djangocms_link/link-widget.css",)}

def __init__(self, site_selector=None):

if site_selector is None:
site_selector = LinkWidget.default_site_selector

widgets = [
Select(
choices=list(link_types.items()),
attrs={
"class": "js-link-widget-selector",
"data-help": _("No destination selected. Use the dropdown to select a destination.")
},
), # Link type selector
URLInput(
attrs={
"widget": "external_link",
"placeholder": _("https://example.com or #anchor"),
"data-help": _(
"Provide a link to an external URL, including the schema such as 'https://', 'tel:', "
"or 'mailto:'. Optionally, add an #anchor (including the #) to scroll to."
),
},
), # External link input
LinkAutoCompleteWidget(
attrs={
"widget": "internal_link",
"data-help": _(
"Select from available internal destinations. Optionally, add an anchor to scroll to."
),
"data-placeholder": _("Select internal destination"),
},
), # Internal link selector
TextInput(
attrs={
"widget": "anchor",
"placeholder": _("#anchor"),
"data-help": _("Provide an anchor to scroll to."),
}
),
]
if File:
widgets.append(
AdminFileWidget(
rel=ManyToOneRel(FilerFileField, File, "id"),
admin_site=site,
attrs={
"widget": "file_link",
"data-help": _("Select a file as destination."),
},
),
)
if site_selector:
widgets.insert(2, SiteAutocompleteSelect(
widgets = [widget for key, widget in _available_widgets.items()
if key == "always" or _mapping[key] in link_types]
if site_selector and "internal_link" in allowed_link_types:
index = next(i for i, widget in enumerate(widgets) if widget.attrs.get("widget") == "internal_link")
widgets.insert(index, SiteAutocompleteSelect(
attrs={
"class": "js-link-site-widget",
"widget": "site",
Expand All @@ -233,15 +253,16 @@ def get_context(self, name, value, attrs):
widget["attrs"].get("widget", "link-type-selector"): widget
for widget in context["widget"]["subwidgets"]
}
if File:
if File and "file_link" in allowed_link_types:
del context["widget"]["subwidgets"]["file_link"]
context["filer_widget"] = self.widgets[-1].render(name + "_4", value[4], attrs)
index = next(i for i, widget in enumerate(self.widgets) if widget.attrs.get("widget") == "file_link")
context["filer_widget"] = self.widgets[index].render(name + f"_{index}", value[index], attrs)
return context


class LinkFormField(Field):
widget = LinkWidget
external_link_validators = [ExtendedURLValidator()]
external_link_validators = [ExtendedURLValidator(allowed_link_types=allowed_link_types)]
internal_link_validators = []
file_link_validators = []
anchor_validators = [AnchorValidator()]
Expand All @@ -256,8 +277,8 @@ def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)

def prepare_value(self, value):
# if isinstance(value, list):
# return value
if isinstance(value, list):
return value

Check warning on line 281 in djangocms_link/fields.py

View check run for this annotation

Codecov / codecov/patch

djangocms_link/fields.py#L281

Added line #L281 was not covered by tests
if value is None:
value = {}

Check warning on line 283 in djangocms_link/fields.py

View check run for this annotation

Codecov / codecov/patch

djangocms_link/fields.py#L283

Added line #L283 was not covered by tests
multi_value = len(self.widget.widgets) * [None]
Expand Down
10 changes: 7 additions & 3 deletions djangocms_link/validators.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,21 +67,25 @@ class ExtendedURLValidator(IntranetURLValidator):
# so we test for a simple alternative.
tel_re = r'^tel\:[0-9 \#\*\-\.\(\)\+]+$'

def __init__(self, allowed_link_types: list = None, **kwargs):
self.allowed_link_types = allowed_link_types
super().__init__(**kwargs)

def __call__(self, value):
if not isinstance(value, str) or len(value) > self.max_length:
raise ValidationError(self.message, code=self.code, params={"value": value})
if self.unsafe_chars.intersection(value):
raise ValidationError(self.message, code=self.code, params={"value": value})
# Check if just an anchor
if value.startswith("#"):
if value.startswith("#") and (self.allowed_link_types is None or "anchor" in self.allowed_link_types):
return AnchorValidator()(value)
# Check if the scheme is valid.
scheme = value.split(":")[0].lower()
if scheme == "tel":
if scheme == "tel" and (self.allowed_link_types is None or "tel" in self.allowed_link_types):
if re.match(self.tel_re, value):
return
else:
raise ValidationError(_("Enter a valid phone number"), code=self.code, params={"value": value})
if scheme == "mailto":
if scheme == "mailto" and (self.allowed_link_types is None or "mailto" in self.allowed_link_types):
return EmailValidator()(value[7:])
return super().__call__(value)

0 comments on commit c7c31fb

Please sign in to comment.