diff --git a/README.rst b/README.rst index 35587038..b2fd783f 100644 --- a/README.rst +++ b/README.rst @@ -150,7 +150,7 @@ You can run tests by executing:: .. |djangocms| image:: https://img.shields.io/badge/django%20CMS-3.7%2B-blue.svg :target: https://www.django-cms.org/ -Updating from version 4 or lower +Upgrading from version 4 or lower -------------------------------- django CMS Link 5 is a rewrite of the plugin. If you are updating from @@ -172,6 +172,6 @@ fields. .. warning:: Migration has worked for some people seamlessly. We strongly recommend to - backup your database before updating to version 5. If you encounter any - issues, please report them on - `GitHub `_. + backup your database before updating to version 5. If you encounter any + issues, please report them on + `GitHub `_. diff --git a/djangocms_link/admin.py b/djangocms_link/admin.py index f2d83464..0f890acf 100644 --- a/djangocms_link/admin.py +++ b/djangocms_link/admin.py @@ -38,7 +38,7 @@ def get(self, request, *args, **kwargs): # Get name of a reference return self.get_reference(request) - self.term, self.language = self.process_request(request) + self.term, self.language, self.site = self.process_request(request) if not self.has_perm(request): raise PermissionDenied @@ -87,23 +87,32 @@ def get_queryset(self): if _version >= 4: try: # django CMS 4.2+ - qs = list( + qs = ( PageContent.admin_manager.filter(language=self.language, title__icontains=self.term) .current_content() .order_by("page__path") ) + if self.site: + qs = qs.filter(page__site_id=self.site) + qs = list(qs) except FieldError: # django CMS 4.0 - 4.1 - qs = list( + qs = ( PageContent.admin_manager.filter(language=self.language, title__icontains=self.term) .current_content() .order_by("page__node__path") ) + if self.site: + qs = qs.filter(page__site_id=self.site) + qs = list(qs) else: # django CMS 3 - qs = list(PageContent.objects.filter( + qs = (PageContent.objects.filter( language=self.language, title__icontains=self.term ).order_by("page__node__path")) + if self.site: + qs = qs.filter(page__node_site_id=self.site) + qs = list(qs) for page_content in qs: # Patch the missing get_absolute_url method page_content.get_absolute_url = lambda: page_content.page.get_absolute_url() @@ -114,8 +123,13 @@ def process_request(self, request): Validate request integrity, extract and return request parameters. """ term = request.GET.get("term", "").strip("  ").lower() + site = request.GET.get("app_label", "") # Django admin's app_label is abused as site id + try: + site = int(site) + except ValueError: + site = None language = get_language_from_request(request) - return term, language + return term, language, site def has_perm(self, request, obj=None): """Check if user has permission to access the related model.""" diff --git a/djangocms_link/fields.py b/djangocms_link/fields.py index b531b9e5..ee6e20ec 100644 --- a/djangocms_link/fields.py +++ b/djangocms_link/fields.py @@ -4,8 +4,9 @@ from django.conf import settings from django.contrib.admin import site from django.contrib.admin.widgets import SELECT2_TRANSLATIONS, AutocompleteSelect -from django.db.models import JSONField, ManyToOneRel -from django.forms import Field, MultiWidget, Select, TextInput, URLInput +from django.contrib.sites.models import Site +from django.db.models import JSONField, ManyToOneRel, ForeignKey, SET_NULL +from django.forms import Field, MultiWidget, Select, TextInput, URLInput, CheckboxInput from django.utils.translation import get_language from django.utils.translation import gettext_lazy as _ @@ -35,6 +36,9 @@ class LinkAutoCompleteWidget(AutocompleteSelect): + def __init__(self, attrs=None): + super().__init__(None, None, attrs) + def get_internal_obj(self, values): internal_obj = [] for value in values: @@ -105,15 +109,57 @@ def build_attrs(self, base_attrs, extra_attrs=None): return attrs +class SiteAutocompleteSelect(AutocompleteSelect): + no_sites = None + + def __init__(self, attrs=None): + try: + from cms.models.pagemodel import TreeNode + + field = TreeNode._meta.get_field("site") + except ImportError: + from cms.models import Page + + field = Page._meta.get_field("site") + super().__init__(field, site, attrs) + + def optgroups(self, name, value, attr=None): + default = (None, [], 0) + groups = [default] + has_selected = False + selected_choices = set(value) + default[1].append(self.create_option(name, "", "", False, 0)) + + site = Site.objects.get_current() + option_value, option_label = site.pk, str(site) + + selected = str(option_value) in value and ( + has_selected is False or self.allow_multiple_selected + ) + has_selected |= selected + index = len(default[1]) + subgroup = default[1] + subgroup.append( + self.create_option( + name, option_value, option_label, selected_choices, index + ) + ) + return groups + + class LinkWidget(MultiWidget): template_name = "djangocms_link/admin/link_widget.html" data_pos = {} - + number_sites = None class Media: js = ("djangocms_link/link-widget.js",) css = {"all": ("djangocms_link/link-widget.css",)} def __init__(self): + # Get the number of sites only once + if LinkWidget.number_sites is None: + LinkWidget.number_sites = Site.objects.count() + widgets = [ Select( choices=list(link_types.items()), @@ -122,14 +168,20 @@ def __init__(self): "data-help": _("No destination selected. Use the dropdown to select a destination.") }, ), # Link type selector + SiteAutocompleteSelect( + attrs={ + "class": "js-link-site-widget", + "widget": "site", + "data-placeholder": "XXX", #_("Select site"), + }, + ), # Site selector LinkAutoCompleteWidget( - field=None, - admin_site=None, 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 URLInput( @@ -161,6 +213,7 @@ def __init__(self): }, ), ) + # Remember which widget expets its content at which position self.data_pos = { widget.attrs.get("widget"): i for i, widget in enumerate(widgets) diff --git a/djangocms_link/static/djangocms_link/link-widget.css b/djangocms_link/static/djangocms_link/link-widget.css index 19fc4f69..9a70b123 100644 --- a/djangocms_link/static/djangocms_link/link-widget.css +++ b/djangocms_link/static/djangocms_link/link-widget.css @@ -1,58 +1,75 @@ .link-widget { width: 100%; - display: flex; + display: block; margin-bottom: 0.5em; .link-type-selector { margin-inline-end: 1em; - display: block; - flex-shrink: 1; + display: inline-block; + width: calc(25% - 1em); + flex-shrink: 2; + select { + width: 100%; + min-width: unset; + } } - .external_link, .internal_link, .file_link, .anchor { + .external_link, .internal_link, .file_link, .anchor, .site { display: none; - width: 100%; - margin-inline-end: 1em; padding: 0; select, input { width: 100%; } span.select2 { - display: block; + display: inline-block; + width: 100% !important; } } + .external_link { + width: 75%; + } + .internal_link { + width: calc(60% - 1em); + margin-inline-end: 1em; + } + .anchor { + width: 15%; /* end of line, no 1em margin to remove */ + } .file_link { margin-top: 0.5em; } &[data-type="external_link"] .external_link, &[data-type="internal_link"] .internal_link, - &[data-type="internal_link"] .anchor, - &[data-type="file_link"], &[data-type="file_link"] .file_link + &[data-type="internal_link"] .site, + &[data-type="internal_link"] .anchor { - display: block + display: inline-block; + } + &[data-type="file_link"] .file_link { + display: block; + width: 100%; } &[data-type="file_link"] .link-type-selector, &[data-type="empty"] .link-type-selector{ margin-inline-end: 0; - width: 33%; - } - .link-settings-menu { - margin-inline-start: 1em; - float: right; - position: relative; - border-width: 1px; - border-style: solid; - border-radius: 3px; - border-color: var(--dca-gray-light, var(--border-color)); - padding: 1rem; - box-sizing: border-box; - height: 1px; - width: 1px; - svg { - position: absolute; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); + } + + &:has(.site) { + /* if site subwidget is present, arrange widgets in two lines */ + .site { + width: 75%; + margin-inline-end: 0; + margin-bottom: 0.5em; + } + .internal_link { + width: calc(60% - 1em); + margin-inline-start: 25%; + margin-inline-end: 1em; } + .anchor ( + margin-top: -3em; + ) + ) } + .select2-container .select2-selection--single { height: 2.55em; } diff --git a/djangocms_link/static/djangocms_link/link-widget.js b/djangocms_link/static/djangocms_link/link-widget.js index 86ca4da8..47e737fa 100644 --- a/djangocms_link/static/djangocms_link/link-widget.js +++ b/djangocms_link/static/djangocms_link/link-widget.js @@ -1,6 +1,6 @@ /* eslint-env es11 */ /* jshint esversion: 11 */ -/* global document */ +/* global document django */ document.addEventListener('DOMContentLoaded' , () => { 'use strict'; @@ -25,4 +25,18 @@ document.addEventListener('DOMContentLoaded' , () => { e.target.closest('.link-widget').querySelector('input[widget="anchor"]').value = ''; }); } + + // If site widget changes, clear internal link widget + for (let item of document.querySelectorAll('.js-link-site-widget')) { + console.warn(item); + django.jQuery(item).on('change', e => { + const site_select2 = django.jQuery(e.target); + const internal_link_select2 = site_select2.closest('.link-widget').find('[widget="internal_link"]'); + internal_link_select2.attr('data-app-label', site_select2.val()); + internal_link_select2.val(null).trigger('change'); + }); + item.addEventListener("change", (e) => { + console.warn(e.target.closest('.link-widget').querySelector('[widget="internal_link"]')); + }); + } }); diff --git a/djangocms_link/validators.py b/djangocms_link/validators.py index 7f182a7c..eb2bac5a 100644 --- a/djangocms_link/validators.py +++ b/djangocms_link/validators.py @@ -63,10 +63,10 @@ def __call__(self, value): return value -class ExtendedURLValidator(URLValidator): +class ExtendedURLValidator(IntranetURLValidator): # Phone numbers don't match the host regex in Django's validator, # so we test for a simple alternative. - tel_re = r'^tel\:[0-9\#\*\-\.\(\)\+]+$' + tel_re = r'^tel\:[0-9 \#\*\-\.\(\)\+]+$' def __call__(self, value): if not isinstance(value, str) or len(value) > self.max_length: diff --git a/tests/test_plugins.py b/tests/test_plugins.py index b64bdd05..8bd7f4ff 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -176,3 +176,29 @@ def test_file(self): ) self.assertIn("test_file.pdf", plugin.get_link()) self.assertIn("/media/filer_public/", plugin.get_link()) + + def test_rendering(self): + plugin = add_plugin( + self.get_placeholders(self.page, self.language).get(slot="content"), + "LinkPlugin", + "en", + name="Link", + link={"internal_link": f"cms.page:{self.page.pk}"}, + ) + self.publish(self.page, self.language) + + response = self.client.get(self.page.get_absolute_url(self.language)) + self.assertContains(response, 'Link') + + def test_rendering_fallback(self): + plugin = add_plugin( + self.get_placeholders(self.page, self.language).get(slot="content"), + "LinkPlugin", + "en", + name="Link", + link={"internal_link": f"cms.page:0"}, + ) + self.publish(self.page, self.language) + + response = self.client.get(self.page.get_absolute_url(self.language)) + self.assertContains(response, 'Link') diff --git a/tests/test_validators.py b/tests/test_validators.py index 1d278c5b..3ee3ee7e 100644 --- a/tests/test_validators.py +++ b/tests/test_validators.py @@ -1,21 +1,52 @@ from django.test import TestCase from djangocms_link.models import HOSTNAME -from djangocms_link.validators import IntranetURLValidator +from djangocms_link.validators import ExtendedURLValidator class LinkValidatorTestCase(TestCase): + def assertValidates(self, validator, value): + try: + validator(value) + except Exception as e: + self.fail(f"Validation of {value} failed with {e}") + + def assertDoesNotValidate(self, validator, value): + try: + validator(value) + self.fail(f"Validation of {value} unexpectedly did not fail") + except Exception as e: + pass def test_intranet_host_re(self): host = r'[a-z,0-9,-]{1,15}' host_re = ( - '(' + IntranetURLValidator.hostname_re - + IntranetURLValidator.domain_re - + IntranetURLValidator.tld_re + + '(' + ExtendedURLValidator.hostname_re + + ExtendedURLValidator.domain_re + + ExtendedURLValidator.tld_re + '|' + host + '|localhost)' ) - validator = IntranetURLValidator( + validator = ExtendedURLValidator( intranet_host_re=host, ) self.assertEqual(validator.host_re, host_re) self.assertIsNone(HOSTNAME) + + def test_tel_validation(self): + validator = ExtendedURLValidator() + + self.assertValidates(validator, "tel:0123456789") + self.assertValidates(validator, "tel:01 234 567 89") + self.assertValidates(validator, "tel:+01 234 567 89") + self.assertDoesNotValidate(validator, "tel:") + self.assertDoesNotValidate(validator, "tel:0800-django-cms") + self.assertDoesNotValidate(validator, "tel:info@django-cms.org") + + def test_mailto_validation(self): + validator = ExtendedURLValidator() + + self.assertValidates(validator, "mailto:info@django-cms.org") + self.assertValidates(validator, "mailto:test@long.subdomain.path.email.com") + self.assertDoesNotValidate(validator, "mailto:info@localhost") + self.assertDoesNotValidate(validator, "mailto:") + self.assertDoesNotValidate(validator, "mailto: info@django-cms.org")