Skip to content

Conversation

wfehr
Copy link

@wfehr wfehr commented Sep 18, 2025

Description

Provide functionality to link internal pages not directly accessible via 'internal_link', e.g. apphook-urls.

Checklist

  • I have opened this pull request against master
  • I have added or modified the tests when changing logic
  • I have followed the conventional commits guidelines to add meaningful information into the changelog
  • I have read the contribution guidelines and I have joined #workgroup-pr-review on
    Slack to find a “pr review buddy” who is going to review my pull request.

Summary by Sourcery

Add full support for a new "relative_link" link type by extending the link widget, form field, validators, models, helpers, and tests to handle relative URLs.

New Features:

  • Introduce support for a "relative_link" option in the link field widget and form field
  • Add RelativeURLValidator to enforce valid relative URL inputs
  • Extend LinkDict and get_link/to_link helpers to handle relative links

Enhancements:

  • Include "relative_link" in the default allowed link types and widget optgroups
  • Update link-widget CSS to display and style the relative_link input

Tests:

  • Add tests for rendering, initial values, validation, model persistence, template tags, and LinkDict behavior for relative_link

Provide functionality to link internal pages not directly accessible via
'internal_link', e.g. apphook-urls.
Copy link
Contributor

sourcery-ai bot commented Sep 18, 2025

Reviewer's Guide

This PR adds support for a new “relative_link” type, extending the existing link widget, form field, validators, model helpers, and tests to handle relative URL paths as a first-class link option.

Entity relationship diagram for link_types and allowed_link_types

erDiagram
    LINK_TYPE {
        internal_link varchar
        relative_link varchar
        external_link varchar
        file_link varchar
        anchor varchar
        mailto varchar
        tel varchar
    }
    ALLOWED_LINK_TYPE {
        internal_link varchar
        relative_link varchar
        external_link varchar
        file_link varchar
        anchor varchar
        mailto varchar
        tel varchar
    }
    LINK_TYPE ||--|| ALLOWED_LINK_TYPE : "is allowed"
Loading

Class diagram for LinkFormField and RelativeURLValidator changes

classDiagram
    class LinkFormField {
        +external_link_validators: list
        +relative_link_validators: list
        +internal_link_validators: list
        +file_link_validators: list
        +anchor_validators: list
        +prepare_value(value: dict): list[str | None]
    }
    class RelativeURLValidator {
        +message: str
        +code: str
        +allowed_link_types: list
        +__call__(value: str)
    }
    LinkFormField --> RelativeURLValidator : uses
Loading

Class diagram for LinkDict changes

classDiagram
    class LinkDict {
        +__init__(initial=None, **kwargs)
        +url: str
        +type: str
    }
    LinkDict : +relative_link
    LinkDict : +external_link
    LinkDict : +internal_link
    LinkDict : +file_link
    LinkDict : +anchor
Loading

File-Level Changes

Change Details Files
Introduce RelativeURLValidator and integrate into field validations
  • Import and register RelativeURLValidator in fields
  • Define RelativeURLValidator with init and call logic
  • Add relative_link_validators to LinkFormField
djangocms_link/validators.py
djangocms_link/fields.py
Extend LinkWidget and form field to support relative_link
  • Add 'relative_link' to link_types and allowed_link_types
  • Define TextInput widget for relative_link in optgroups
  • Handle 'relative_link' branch in prepare_value mapping
djangocms_link/fields.py
Augment helper logic and LinkDict for relative_link
  • Return relative_link in get_link helper
  • Initialize LinkDict with relative_link when string starts with '/'
  • Include 'relative_link' in LinkDict.type property ordering
djangocms_link/helpers.py
Update tests to cover relative_link behavior
  • Add widget rendering assertions for relative_link
  • Test form cleaning, initial value mapping, and validation of relative_link
  • Extend model and template-tag tests (test_models.py) for relative_link
  • Add LinkDict tests for relative_link
tests/test_fields.py
tests/test_models.py
tests/test_link_dict.py
Include relative_link in CSS styling rules
  • Add .relative_link selectors alongside other link types
  • Adjust width styling for relative_link input
djangocms_link/static/djangocms_link/link-widget.css

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey there - I've reviewed your changes - here's some feedback:

  • Fix the typo in RelativeURLValidator’s message (change 'realtive' to 'relative').
  • The .format(example_uri_scheme) call in the relative link widget help text is redundant (there’s no placeholder) and can be removed.
  • Consider extracting the list of link types into a shared constant to avoid having to update fields, widgets, helpers, and CSS in multiple places when adding new types.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- Fix the typo in RelativeURLValidator’s message (change 'realtive' to 'relative').
- The .format(example_uri_scheme) call in the relative link widget help text is redundant (there’s no placeholder) and can be removed.
- Consider extracting the list of link types into a shared constant to avoid having to update fields, widgets, helpers, and CSS in multiple places when adding new types.

## Individual Comments

### Comment 1
<location> `djangocms_link/validators.py:117` </location>
<code_context>
+
+@deconstructible
+class RelativeURLValidator:
+    message = _("Enter a valid realtive link")
+    code = "invalid"
+
</code_context>

<issue_to_address>
**issue (typo):** Typo in validator message: 'realtive' should be 'relative'.

Fixing the typo ensures error messages are clear and understandable.

```suggestion
    message = _("Enter a valid relative link")
```
</issue_to_address>

### Comment 2
<location> `djangocms_link/helpers.py:52-53` </location>
<code_context>
         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

</code_context>

<issue_to_address>
**suggestion:** Relative links are now returned directly; consider normalization for leading/trailing whitespace.

Strip whitespace from relative links before returning to prevent subtle bugs.

```suggestion
    elif "relative_link" in link_field_value:
        relative_link = link_field_value["relative_link"]
        if relative_link is not None:
            relative_link = relative_link.strip()
        return relative_link or None
```
</issue_to_address>

### Comment 3
<location> `tests/test_fields.py:104` </location>
<code_context>
             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"})
</code_context>

<issue_to_address>
**suggestion (testing):** Consider adding tests for invalid relative links and edge cases.

Adding tests for invalid relative links, such as those missing a leading slash, containing unsafe characters, or being excessively long, will help verify the validator's error handling.

```suggestion
        check_value({"relative_link": "/some/path"})

        # Invalid relative link: missing leading slash
        with self.assertRaises(forms.ValidationError):
            check_value({"relative_link": "some/path"})

        # Invalid relative link: unsafe characters
        with self.assertRaises(forms.ValidationError):
            check_value({"relative_link": "/some/<path>"})

        # Invalid relative link: excessively long path
        long_path = "/" + "a" * 2048
        with self.assertRaises(forms.ValidationError):
            check_value({"relative_link": long_path})
```
</issue_to_address>

### Comment 4
<location> `djangocms_link/fields.py:153` </location>
<code_context>
 # Configure the LinkWidget
 link_types = {
     "internal_link": _("Internal link"),
+    "relative_link": _("Relative link"),
     "external_link": _("External link/anchor"),
 }
</code_context>

<issue_to_address>
**issue (complexity):** Consider refactoring link type handling into a single configuration dictionary to centralize logic and reduce duplication.

```markdown
Rather than sprinkling `relative_link` (and any future link types) through `link_types`, `allowed_link_types`, `widgets`, `validators` and `prepare_value`, you can collapse all of that into a single data‐driven mapping.  

1) Define a top‐level config dict:  
```python
LINK_TYPE_CONFIG = {
    'internal_link': {
        'label': _('Internal link'),
        'widget_cls': LinkAutoCompleteWidget,
        'widget_attrs': {
            'widget': 'internal_link',
            'data-help': _('Select from available internal destinations…'),
            'data-placeholder': _('Select internal destination'),
        },
        'validator_cls': None,
    },
    'external_link': {
        'label': _('External link/anchor'),
        'widget_cls': URLInput,
        'widget_attrs': {
            'widget': 'external_link',
            'placeholder': _('https://example.com or #anchor'),
            'data-help': _('Provide a link to an external URL…'),
        },
        'validator_cls': ExtendedURLValidator,
    },
    'relative_link': {
        'label': _('Relative link'),
        'widget_cls': TextInput,
        'widget_attrs': {
            'widget': 'relative_link',
            'placeholder': _('/some/path - optionally append #anchor'),
            'data-help': _('Provide a relative link…'),
        },
        'validator_cls': RelativeURLValidator,
    },
    # file_link, anchor, mailto, tel, etc.
}
```

2) Generate `link_types`, `widgets` and `validators` by iterating that dict:  
```python
allowed = set(getattr(settings, 'DJANGOCMS_LINK_ALLOWED_LINK_TYPES', LINK_TYPE_CONFIG.keys()))

link_types = {key: cfg['label'] for key, cfg in LINK_TYPE_CONFIG.items() if key in allowed}

# in LinkWidget.__init__:
self.widgets = [
    cfg['widget_cls'](attrs=cfg['widget_attrs'])
    for key, cfg in LINK_TYPE_CONFIG.items()
    if key in allowed
]

# in LinkFormField:
self.validators = [
    cfg['validator_cls'](allowed_link_types=allowed)
    for key, cfg in LINK_TYPE_CONFIG.items()
    if key in allowed and cfg['validator_cls']
]
```

3) Simplify `prepare_value` to a loop instead of multiple `elif`s:  
```python
def prepare_value(self, value: dict) -> list[str | None]:
    if isinstance(value, list):
        return value
    buckets = [None] * len(self.widget.widgets)
    for key in LINK_TYPE_CONFIG:
        if key in value:
            buckets[0] = key
            buckets[self._get_pos(key)] = value[key]
            break
    return buckets
```

This removes all of the duplicated boilerplate whenever you add a new link type (just one entry in `LINK_TYPE_CONFIG`).
</issue_to_address>

### Comment 5
<location> `djangocms_link/validators.py:115` </location>
<code_context>
         return super().__call__(value)
+
+
+@deconstructible
+class RelativeURLValidator:
+    message = _("Enter a valid realtive link")
</code_context>

<issue_to_address>
**issue (complexity):** Consider refactoring the validation logic to use early returns and a helper function for error handling to improve readability.

Here’s one way to collapse the nested logic into a straight‐line of early checks plus a single helper for raising errors. This keeps every check, but it’s easier to scan and maintains the same behavior:

```python
@deconstructible
class RelativeURLValidator:
    message = _("Enter a valid relative 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):
        # simple helper to reduce repetition
        def _fail():
            raise ValidationError(self.message, code=self.code, params={"value": value})

        if not isinstance(value, str):
            _fail()
        if len(value) > URLValidator.max_length:
            _fail()
        if URLValidator.unsafe_chars.intersection(value):
            _fail()

        # must start with “/”
        if not value.startswith("/"):
            _fail()
        # if caller has restricted link types, enforce “relative_link”
        if self.allowed_link_types and "relative_link" not in self.allowed_link_types:
            _fail()

        return value
```

Steps to apply:

1. Extract a small `_fail()` inner function so you don’t repeat the `ValidationError` call.
2. Turn each condition into an early return (`_fail()`), so you avoid nested `and`/`or` logic.
3. Split the “must start with `/`” check from the “allowed_link_types” check to keep each test atomic.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +52 to +53
elif "relative_link" in link_field_value:
return link_field_value["relative_link"] or None
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion: Relative links are now returned directly; consider normalization for leading/trailing whitespace.

Strip whitespace from relative links before returning to prevent subtle bugs.

Suggested change
elif "relative_link" in link_field_value:
return link_field_value["relative_link"] or None
elif "relative_link" in link_field_value:
relative_link = link_field_value["relative_link"]
if relative_link is not None:
relative_link = relative_link.strip()
return relative_link or None

return super().__call__(value)


@deconstructible
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (complexity): Consider refactoring the validation logic to use early returns and a helper function for error handling to improve readability.

Here’s one way to collapse the nested logic into a straight‐line of early checks plus a single helper for raising errors. This keeps every check, but it’s easier to scan and maintains the same behavior:

@deconstructible
class RelativeURLValidator:
    message = _("Enter a valid relative 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):
        # simple helper to reduce repetition
        def _fail():
            raise ValidationError(self.message, code=self.code, params={"value": value})

        if not isinstance(value, str):
            _fail()
        if len(value) > URLValidator.max_length:
            _fail()
        if URLValidator.unsafe_chars.intersection(value):
            _fail()

        # must start with “/”
        if not value.startswith("/"):
            _fail()
        # if caller has restricted link types, enforce “relative_link”
        if self.allowed_link_types and "relative_link" not in self.allowed_link_types:
            _fail()

        return value

Steps to apply:

  1. Extract a small _fail() inner function so you don’t repeat the ValidationError call.
  2. Turn each condition into an early return (_fail()), so you avoid nested and/or logic.
  3. Split the “must start with /” check from the “allowed_link_types” check to keep each test atomic.

Co-authored-by: sourcery-ai[bot] <58596630+sourcery-ai[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

1 participant