diff --git a/docs/installation/4-ldap.md b/docs/installation/4-ldap.md index 32623439a..a41400808 100644 --- a/docs/installation/4-ldap.md +++ b/docs/installation/4-ldap.md @@ -80,6 +80,7 @@ AUTH_LDAP_USER_ATTR_MAP = { ``` # User Groups for Permissions + !!! info When using Microsoft Active Directory, support for nested groups can be activated by using `NestedGroupOfNamesType()` instead of `GroupOfNamesType()` for `AUTH_LDAP_GROUP_TYPE`. You will also need to modify the import line to use `NestedGroupOfNamesType` instead of `GroupOfNamesType` . @@ -117,6 +118,9 @@ AUTH_LDAP_GROUP_CACHE_TIMEOUT = 3600 * `is_staff` - Users mapped to this group are enabled for access to the administration tools; this is the equivalent of checking the "staff status" box on a manually created user. This doesn't grant any specific permissions. * `is_superuser` - Users mapped to this group will be granted superuser status. Superusers are implicitly granted all permissions. +!!! warning + Authentication will fail if the groups (the distinguished names) do not exist in the LDAP directory. + # Troubleshooting LDAP `supervisorctl restart netbox` restarts the Netbox service, and initiates any changes made to `ldap_config.py`. If there are syntax errors present, the NetBox process will not spawn an instance, and errors should be logged to `/var/log/supervisor/`. diff --git a/docs/release-notes/version-2.6.md b/docs/release-notes/version-2.6.md index c6a509ab0..77c2f42b7 100644 --- a/docs/release-notes/version-2.6.md +++ b/docs/release-notes/version-2.6.md @@ -1,10 +1,25 @@ -# v2.6.10 (FUTURE) +# v2.6.11 (2020-01-03) + +## Bug Fixes + +* [#3831](https://github.com/netbox-community/netbox/issues/3831) - Fix API-driven filter field rendering (#3812 regression) +* [#3833](https://github.com/netbox-community/netbox/issues/3833) - Add missing region filters for multiple objects + +--- + +# v2.6.10 (2020-01-02) ## Enhancements +* [#2233](https://github.com/netbox-community/netbox/issues/2233) - Add ability to move inventory items between devices +* [#2892](https://github.com/netbox-community/netbox/issues/2892) - Extend admin UI to allow deleting old report results +* [#3062](https://github.com/netbox-community/netbox/issues/3062) - Add `assigned_to_interface` filter for IP addresses +* [#3461](https://github.com/netbox-community/netbox/issues/3461) - Fail gracefully on custom link rendering exception * [#3705](https://github.com/netbox-community/netbox/issues/3705) - Provide request context when executing custom scripts * [#3762](https://github.com/netbox-community/netbox/issues/3762) - Add date/time picker widgets * [#3788](https://github.com/netbox-community/netbox/issues/3788) - Enable partial search for inventory items +* [#3812](https://github.com/netbox-community/netbox/issues/3812) - Optimize size of pages containing a dynamic selection field +* [#3827](https://github.com/netbox-community/netbox/issues/3827) - Allow filtering console/power/interface connections by device ID ## Bug Fixes @@ -15,6 +30,7 @@ * [#3780](https://github.com/netbox-community/netbox/issues/3780) - Fix AttributeError exception in API docs * [#3809](https://github.com/netbox-community/netbox/issues/3809) - Filter platform by manufacturer when editing devices * [#3811](https://github.com/netbox-community/netbox/issues/3811) - Fix filtering of racks by group on device list +* [#3822](https://github.com/netbox-community/netbox/issues/3822) - Fix exception when editing a device bay (regression from #3596) --- diff --git a/netbox/circuits/filters.py b/netbox/circuits/filters.py index 502d2d103..0ac5ec170 100644 --- a/netbox/circuits/filters.py +++ b/netbox/circuits/filters.py @@ -18,6 +18,17 @@ class ProviderFilter(CustomFieldFilterSet, CreatedUpdatedFilterSet): method='search', label='Search', ) + region_id = TreeNodeMultipleChoiceFilter( + queryset=Region.objects.all(), + field_name='circuits__terminations__site__region__in', + label='Region (ID)', + ) + region = TreeNodeMultipleChoiceFilter( + queryset=Region.objects.all(), + field_name='circuits__terminations__site__region__in', + to_field_name='slug', + label='Region (slug)', + ) site_id = django_filters.ModelMultipleChoiceFilter( field_name='circuits__terminations__site', queryset=Site.objects.all(), diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index 8ff6a0718..4a5c06a6e 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -104,6 +104,18 @@ class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm): required=False, label='Search' ) + region = FilterChoiceField( + queryset=Region.objects.all(), + to_field_name='slug', + required=False, + widget=APISelectMultiple( + api_url="/api/dcim/regions/", + value_field="slug", + filter_for={ + 'site': 'region' + } + ) + ) site = FilterChoiceField( queryset=Site.objects.all(), to_field_name='slug', @@ -302,6 +314,9 @@ class CircuitFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm widget=APISelectMultiple( api_url="/api/dcim/regions/", value_field="slug", + filter_for={ + 'site': 'region' + } ) ) site = FilterChoiceField( diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index 111469d98..638313507 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -93,6 +93,17 @@ class SiteFilter(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet class RackGroupFilter(NameSlugSearchFilterSet): + region_id = TreeNodeMultipleChoiceFilter( + queryset=Region.objects.all(), + field_name='site__region__in', + label='Region (ID)', + ) + region = TreeNodeMultipleChoiceFilter( + queryset=Region.objects.all(), + field_name='site__region__in', + to_field_name='slug', + label='Region (slug)', + ) site_id = django_filters.ModelMultipleChoiceFilter( queryset=Site.objects.all(), label='Site (ID)', @@ -125,6 +136,17 @@ class RackFilter(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet method='search', label='Search', ) + region_id = TreeNodeMultipleChoiceFilter( + queryset=Region.objects.all(), + field_name='site__region__in', + label='Region (ID)', + ) + region = TreeNodeMultipleChoiceFilter( + queryset=Region.objects.all(), + field_name='site__region__in', + to_field_name='slug', + label='Region (slug)', + ) site_id = django_filters.ModelMultipleChoiceFilter( queryset=Site.objects.all(), label='Site (ID)', @@ -831,6 +853,28 @@ class InventoryItemFilter(DeviceComponentFilterSet): method='search', label='Search', ) + region_id = TreeNodeMultipleChoiceFilter( + queryset=Region.objects.all(), + field_name='device__site__region__in', + label='Region (ID)', + ) + region = TreeNodeMultipleChoiceFilter( + queryset=Region.objects.all(), + field_name='device__site__region__in', + to_field_name='slug', + label='Region (slug)', + ) + site_id = django_filters.ModelMultipleChoiceFilter( + field_name='device__site', + queryset=Site.objects.all(), + label='Site (ID)', + ) + site = django_filters.ModelMultipleChoiceFilter( + field_name='device__site__slug', + queryset=Site.objects.all(), + to_field_name='slug', + label='Site name (slug)', + ) device_id = django_filters.ModelChoiceFilter( queryset=Device.objects.all(), label='Device (ID)', @@ -880,6 +924,17 @@ class VirtualChassisFilter(django_filters.FilterSet): method='search', label='Search', ) + region_id = TreeNodeMultipleChoiceFilter( + queryset=Region.objects.all(), + field_name='master__site__region__in', + label='Region (ID)', + ) + region = TreeNodeMultipleChoiceFilter( + queryset=Region.objects.all(), + field_name='master__site__region__in', + to_field_name='slug', + label='Region (slug)', + ) site_id = django_filters.ModelMultipleChoiceFilter( field_name='master__site', queryset=Site.objects.all(), @@ -935,7 +990,7 @@ class CableFilter(django_filters.FilterSet): device_id = MultiValueNumberFilter( method='filter_device' ) - device = MultiValueNumberFilter( + device = MultiValueCharFilter( method='filter_device', field_name='device__name' ) @@ -978,9 +1033,12 @@ class ConsoleConnectionFilter(django_filters.FilterSet): method='filter_site', label='Site (slug)', ) - device = django_filters.CharFilter( + device_id = MultiValueNumberFilter( + method='filter_device' + ) + device = MultiValueCharFilter( method='filter_device', - label='Device', + field_name='device__name' ) class Meta: @@ -993,11 +1051,11 @@ class ConsoleConnectionFilter(django_filters.FilterSet): return queryset.filter(connected_endpoint__device__site__slug=value) def filter_device(self, queryset, name, value): - if not value.strip(): + if not value: return queryset return queryset.filter( - Q(device__name__icontains=value) | - Q(connected_endpoint__device__name__icontains=value) + Q(**{'{}__in'.format(name): value}) | + Q(**{'connected_endpoint__{}__in'.format(name): value}) ) @@ -1006,9 +1064,12 @@ class PowerConnectionFilter(django_filters.FilterSet): method='filter_site', label='Site (slug)', ) - device = django_filters.CharFilter( + device_id = MultiValueNumberFilter( + method='filter_device' + ) + device = MultiValueCharFilter( method='filter_device', - label='Device', + field_name='device__name' ) class Meta: @@ -1021,11 +1082,11 @@ class PowerConnectionFilter(django_filters.FilterSet): return queryset.filter(_connected_poweroutlet__device__site__slug=value) def filter_device(self, queryset, name, value): - if not value.strip(): + if not value: return queryset return queryset.filter( - Q(device__name__icontains=value) | - Q(_connected_poweroutlet__device__name__icontains=value) + Q(**{'{}__in'.format(name): value}) | + Q(**{'_connected_poweroutlet__{}__in'.format(name): value}) ) @@ -1034,9 +1095,12 @@ class InterfaceConnectionFilter(django_filters.FilterSet): method='filter_site', label='Site (slug)', ) - device = django_filters.CharFilter( + device_id = MultiValueNumberFilter( + method='filter_device' + ) + device = MultiValueCharFilter( method='filter_device', - label='Device', + field_name='device__name' ) class Meta: @@ -1052,11 +1116,11 @@ class InterfaceConnectionFilter(django_filters.FilterSet): ) def filter_device(self, queryset, name, value): - if not value.strip(): + if not value: return queryset return queryset.filter( - Q(device__name__icontains=value) | - Q(_connected_interface__device__name__icontains=value) + Q(**{'{}__in'.format(name): value}) | + Q(**{'_connected_interface__{}__in'.format(name): value}) ) @@ -1069,6 +1133,17 @@ class PowerPanelFilter(django_filters.FilterSet): method='search', label='Search', ) + region_id = TreeNodeMultipleChoiceFilter( + queryset=Region.objects.all(), + field_name='site__region__in', + label='Region (ID)', + ) + region = TreeNodeMultipleChoiceFilter( + queryset=Region.objects.all(), + field_name='site__region__in', + to_field_name='slug', + label='Region (slug)', + ) site_id = django_filters.ModelMultipleChoiceFilter( queryset=Site.objects.all(), label='Site (ID)', @@ -1107,6 +1182,17 @@ class PowerFeedFilter(CustomFieldFilterSet, CreatedUpdatedFilterSet): method='search', label='Search', ) + region_id = TreeNodeMultipleChoiceFilter( + queryset=Region.objects.all(), + field_name='power_panel__site__region__in', + label='Region (ID)', + ) + region = TreeNodeMultipleChoiceFilter( + queryset=Region.objects.all(), + field_name='power_panel__site__region__in', + to_field_name='slug', + label='Region (slug)', + ) site_id = django_filters.ModelMultipleChoiceFilter( field_name='power_panel__site', queryset=Site.objects.all(), diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 902541353..932e9695a 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -375,6 +375,18 @@ class RackGroupCSVForm(forms.ModelForm): class RackGroupFilterForm(BootstrapMixin, forms.Form): + region = FilterChoiceField( + queryset=Region.objects.all(), + to_field_name='slug', + required=False, + widget=APISelectMultiple( + api_url="/api/dcim/regions/", + value_field="slug", + filter_for={ + 'site': 'region' + } + ) + ) site = FilterChoiceField( queryset=Site.objects.all(), to_field_name='slug', @@ -646,11 +658,23 @@ class RackBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditFor class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): model = Rack - field_order = ['q', 'site', 'group_id', 'status', 'role', 'tenant_group', 'tenant'] + field_order = ['q', 'region', 'site', 'group_id', 'status', 'role', 'tenant_group', 'tenant'] q = forms.CharField( required=False, label='Search' ) + region = FilterChoiceField( + queryset=Region.objects.all(), + to_field_name='slug', + required=False, + widget=APISelectMultiple( + api_url="/api/dcim/regions/", + value_field="slug", + filter_for={ + 'site': 'region' + } + ) + ) site = FilterChoiceField( queryset=Site.objects.all(), to_field_name='slug', @@ -662,16 +686,15 @@ class RackFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm): } ) ) - group_id = ChainedModelChoiceField( - label='Rack group', - queryset=RackGroup.objects.prefetch_related('site'), - chains=( - ('site', 'site'), + group_id = FilterChoiceField( + queryset=RackGroup.objects.prefetch_related( + 'site' ), - required=False, + label='Rack group', + null_label='-- None --', widget=APISelectMultiple( api_url="/api/dcim/rack-groups/", - null_option=True, + null_option=True ) ) status = forms.MultipleChoiceField( @@ -3122,9 +3145,13 @@ class CableFilterForm(BootstrapMixin, forms.Form): required=False, widget=ColorSelect() ) - device = forms.CharField( + device_id = FilterChoiceField( + queryset=Device.objects.all(), required=False, - label='Device name' + label='Device', + widget=APISelectMultiple( + api_url='/api/dcim/devices/', + ) ) @@ -3189,38 +3216,59 @@ class DeviceBayBulkRenameForm(BulkRenameForm): # class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form): - site = forms.ModelChoiceField( + site = FilterChoiceField( queryset=Site.objects.all(), - required=False, - to_field_name='slug' + to_field_name='slug', + widget=APISelectMultiple( + api_url="/api/dcim/sites/", + value_field="slug", + ) ) - device = forms.CharField( + device_id = FilterChoiceField( + queryset=Device.objects.all(), required=False, - label='Device name' + label='Device', + widget=APISelectMultiple( + api_url='/api/dcim/devices/', + ) ) class PowerConnectionFilterForm(BootstrapMixin, forms.Form): - site = forms.ModelChoiceField( + site = FilterChoiceField( queryset=Site.objects.all(), - required=False, - to_field_name='slug' + to_field_name='slug', + widget=APISelectMultiple( + api_url="/api/dcim/sites/", + value_field="slug", + ) ) - device = forms.CharField( + device_id = FilterChoiceField( + queryset=Device.objects.all(), required=False, - label='Device name' + label='Device', + widget=APISelectMultiple( + api_url='/api/dcim/devices/', + ) ) class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form): - site = forms.ModelChoiceField( + site = FilterChoiceField( queryset=Site.objects.all(), - required=False, - to_field_name='slug' + to_field_name='slug', + widget=APISelectMultiple( + api_url="/api/dcim/sites/", + value_field="slug", + ) ) - device = forms.CharField( + device_id = FilterChoiceField( + queryset=Device.objects.all(), required=False, - label='Device name' + label='Device', + widget=APISelectMultiple( + api_url='/api/dcim/devices/', + ) ) @@ -3236,9 +3284,12 @@ class InventoryItemForm(BootstrapMixin, forms.ModelForm): class Meta: model = InventoryItem fields = [ - 'name', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'tags', + 'name', 'device', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'tags', ] widgets = { + 'device': APISelect( + api_url="/api/dcim/devices/" + ), 'manufacturer': APISelect( api_url="/api/dcim/manufacturers/" ) @@ -3274,9 +3325,19 @@ class InventoryItemBulkEditForm(BootstrapMixin, BulkEditForm): queryset=InventoryItem.objects.all(), widget=forms.MultipleHiddenInput() ) + device = forms.ModelChoiceField( + queryset=Device.objects.all(), + required=False, + widget=APISelect( + api_url="/api/dcim/devices/" + ) + ) manufacturer = forms.ModelChoiceField( queryset=Manufacturer.objects.all(), - required=False + required=False, + widget=APISelect( + api_url="/api/dcim/manufacturers/" + ) ) part_id = forms.CharField( max_length=50, @@ -3300,18 +3361,48 @@ class InventoryItemFilterForm(BootstrapMixin, forms.Form): required=False, label='Search' ) - device = forms.CharField( + region = FilterChoiceField( + queryset=Region.objects.all(), + to_field_name='slug', required=False, - label='Device name' + widget=APISelectMultiple( + api_url="/api/dcim/regions/", + value_field="slug", + filter_for={ + 'site': 'region' + } + ) + ) + site = FilterChoiceField( + queryset=Site.objects.all(), + to_field_name='slug', + widget=APISelectMultiple( + api_url="/api/dcim/sites/", + value_field="slug", + filter_for={ + 'device_id': 'site' + } + ) + ) + device_id = FilterChoiceField( + queryset=Device.objects.all(), + required=False, + label='Device', + widget=APISelect( + api_url='/api/dcim/devices/', + ) ) manufacturer = FilterChoiceField( queryset=Manufacturer.objects.all(), to_field_name='slug', - null_label='-- None --' + widget=APISelect( + api_url="/api/dcim/manufacturers/", + value_field="slug", + ) ) discovered = forms.NullBooleanField( required=False, - widget=forms.Select( + widget=StaticSelect2( choices=BOOLEAN_WITH_BLANK_CHOICES ) ) @@ -3458,6 +3549,18 @@ class VirtualChassisFilterForm(BootstrapMixin, CustomFieldFilterForm): required=False, label='Search' ) + region = FilterChoiceField( + queryset=Region.objects.all(), + to_field_name='slug', + required=False, + widget=APISelectMultiple( + api_url="/api/dcim/regions/", + value_field="slug", + filter_for={ + 'site': 'region' + } + ) + ) site = FilterChoiceField( queryset=Site.objects.all(), to_field_name='slug', @@ -3563,6 +3666,18 @@ class PowerPanelFilterForm(BootstrapMixin, CustomFieldFilterForm): required=False, label='Search' ) + region = FilterChoiceField( + queryset=Region.objects.all(), + to_field_name='slug', + required=False, + widget=APISelectMultiple( + api_url="/api/dcim/regions/", + value_field="slug", + filter_for={ + 'site': 'region' + } + ) + ) site = FilterChoiceField( queryset=Site.objects.all(), to_field_name='slug', @@ -3783,6 +3898,18 @@ class PowerFeedFilterForm(BootstrapMixin, CustomFieldFilterForm): required=False, label='Search' ) + region = FilterChoiceField( + queryset=Region.objects.all(), + to_field_name='slug', + required=False, + widget=APISelectMultiple( + api_url="/api/dcim/regions/", + value_field="slug", + filter_for={ + 'site': 'region' + } + ) + ) site = FilterChoiceField( queryset=Site.objects.all(), to_field_name='slug', diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index db88901b6..8f95fa19a 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -2597,7 +2597,7 @@ class DeviceBay(ComponentModel): # Check that the installed device is not already installed elsewhere if self.installed_device: current_bay = DeviceBay.objects.filter(installed_device=self.installed_device).first() - if current_bay: + if current_bay and current_bay != self: raise ValidationError({ 'installed_device': "Cannot install the specified device; device is already installed in {}".format( current_bay diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index f99848b1b..ee21b4f5d 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -3,7 +3,10 @@ from django.contrib import admin from netbox.admin import admin_site from utilities.forms import LaxURLField -from .models import CustomField, CustomFieldChoice, CustomLink, Graph, ExportTemplate, TopologyMap, Webhook +from .models import ( + CustomField, CustomFieldChoice, CustomLink, Graph, ExportTemplate, ReportResult, TopologyMap, Webhook, +) +from .reports import get_report def order_content_types(field): @@ -166,6 +169,36 @@ class ExportTemplateAdmin(admin.ModelAdmin): form = ExportTemplateForm +# +# Reports +# + +@admin.register(ReportResult, site=admin_site) +class ReportResultAdmin(admin.ModelAdmin): + list_display = [ + 'report', 'active', 'created', 'user', 'passing', + ] + fields = [ + 'report', 'user', 'passing', 'data', + ] + list_filter = [ + 'failed', + ] + readonly_fields = fields + + def has_add_permission(self, request): + return False + + def active(self, obj): + module, report_name = obj.report.split('.') + return True if get_report(module, report_name) else False + active.boolean = True + + def passing(self, obj): + return not obj.failed + passing.boolean = True + + # # Topology maps # diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index 5223c3c05..4f7f57fff 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -52,7 +52,7 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F else: initial = None field = forms.NullBooleanField( - required=cf.required, initial=initial, widget=forms.Select(choices=choices) + required=cf.required, initial=initial, widget=StaticSelect2(choices=choices) ) # Date @@ -71,7 +71,9 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F default_choice = cf.choices.get(value=initial).pk except ObjectDoesNotExist: pass - field = forms.TypedChoiceField(choices=choices, coerce=int, required=cf.required, initial=default_choice) + field = forms.TypedChoiceField( + choices=choices, coerce=int, required=cf.required, initial=default_choice, widget=StaticSelect2() + ) # URL elif cf.type == CF_TYPE_URL: diff --git a/netbox/extras/models.py b/netbox/extras/models.py index e9ca396cc..038576b63 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -915,6 +915,13 @@ class ReportResult(models.Model): class Meta: ordering = ['report'] + def __str__(self): + return "{} {} at {}".format( + self.report, + "passed" if not self.failed else "failed", + self.created + ) + # # Change logging diff --git a/netbox/extras/templatetags/custom_links.py b/netbox/extras/templatetags/custom_links.py index 0d2239ecc..8c927a0ae 100644 --- a/netbox/extras/templatetags/custom_links.py +++ b/netbox/extras/templatetags/custom_links.py @@ -46,12 +46,17 @@ def custom_links(obj): # Add non-grouped links else: - text_rendered = render_jinja2(cl.text, context) - if text_rendered: - link_target = ' target="_blank"' if cl.new_window else '' - template_code += LINK_BUTTON.format( - cl.url, link_target, cl.button_class, text_rendered - ) + try: + text_rendered = render_jinja2(cl.text, context) + if text_rendered: + link_rendered = render_jinja2(cl.url, context) + link_target = ' target="_blank"' if cl.new_window else '' + template_code += LINK_BUTTON.format( + link_rendered, link_target, cl.button_class, text_rendered + ) + except Exception as e: + template_code += '' \ + ' {}\n'.format(e, cl.name) # Add grouped links to template for group, links in group_names.items(): @@ -59,11 +64,17 @@ def custom_links(obj): links_rendered = [] for cl in links: - text_rendered = render_jinja2(cl.text, context) - if text_rendered: - link_target = ' target="_blank"' if cl.new_window else '' + try: + text_rendered = render_jinja2(cl.text, context) + if text_rendered: + link_target = ' target="_blank"' if cl.new_window else '' + links_rendered.append( + GROUP_LINK.format(cl.url, link_target, cl.text) + ) + except Exception as e: links_rendered.append( - GROUP_LINK.format(cl.url, link_target, cl.text) + '