diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 5e936c5ec..974527bd3 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -10,16 +10,25 @@ body: installation. If you're having trouble with installation or just looking for assistance with using NetBox, please visit our [discussion forum](https://github.com/netbox-community/netbox/discussions) instead. + - type: dropdown + attributes: + label: Deployment Type + description: How are you running NetBox? + options: + - Self-hosted + - NetBox Cloud + validations: + required: true - type: input attributes: - label: NetBox version + label: NetBox Version description: What version of NetBox are you currently running? - placeholder: v3.6.6 + placeholder: v3.6.7 validations: required: true - type: dropdown attributes: - label: Python version + label: Python Version description: What version of Python are you currently running? options: - "3.8" diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index 34103e616..9fb14742a 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.6.6 + placeholder: v3.6.7 validations: required: true - type: dropdown diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 301fac079..471846427 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -36,6 +36,8 @@ NetBox users are welcome to participate in either role, on stage or in the crowd ## :bug: Reporting Bugs +:warning: Bug reports are used to call attention to some unintended or unexpected behavior in NetBox, such as when an error occurs or when the result of taking some action is inconsistent with the documentation. **Bug reports may not be used to suggest new functionality**; please see "feature requests" below if that is your goal. + * First, ensure that you're running the [latest stable version](https://github.com/netbox-community/netbox/releases) of NetBox. If you're running an older version, it's likely that the bug has already been fixed. * Next, search our [issues list](https://github.com/netbox-community/netbox/issues?q=is%3Aissue) to see if the bug you've found has already been reported. If you come across a bug report that seems to match, please click "add a reaction" in the top right corner of the issue and add a thumbs up (:thumbsup:). This will help draw more attention to it. Any comments you can add to provide additional information or context would also be much appreciated. diff --git a/docs/features/synchronized-data.md b/docs/features/synchronized-data.md index f266519b6..a070d0ce1 100644 --- a/docs/features/synchronized-data.md +++ b/docs/features/synchronized-data.md @@ -10,7 +10,6 @@ To enable remote data synchronization, the NetBox administrator first designates (Local disk paths are considered "remote" in this context as they exist outside NetBox's database. These paths could also be mapped to external network shares.) - !!! info Data backends which connect to external sources typically require the installation of one or more supporting Python libraries. The Git backend requires the [`dulwich`](https://www.dulwich.io/) package, and the S3 backend requires the [`boto3`](https://boto3.amazonaws.com/v1/documentation/api/latest/index.html) package. These must be installed within NetBox's environment to enable these backends. @@ -23,3 +22,6 @@ The following NetBox models can be associated with replicated data files: * Export templates Once a data has been designated for a local instance, its data will be replaced with the content of the replicated file. When the replicated file is updated in the future (via synchronization jobs), the local instance will be flagged as having out-of-date data. A user can then synchronize these objects individually or in bulk to effect the update. This two-stage process ensures that automated synchronization tasks do not immediately affect production data. + +!!! note "Permissions" + A user must be assigned the `core.sync_datasource` permission in order to synchronize local files from a remote data source. diff --git a/docs/integrations/synchronized-data.md b/docs/integrations/synchronized-data.md index 805cbe15b..d72501fd5 100644 --- a/docs/integrations/synchronized-data.md +++ b/docs/integrations/synchronized-data.md @@ -2,6 +2,9 @@ Some NetBox models support automatic synchronization of certain attributes from remote [data sources](../models/core/datasource.md), such as a git repository hosted on GitHub or GitLab. Data from the authoritative remote source is synchronized locally in NetBox as [data files](../models/core/datafile.md). +!!! note "Permissions" + A user must be assigned the `core.sync_datasource` permission in order to synchronize local files from a remote data source. This is accomplished by creating a permission for the "Core > Data Source" object type with the `sync` action, and assigning it to the desired user and/or group. + The following features support the use of synchronized data: * [Configuration templates](../features/configuration-rendering.md) diff --git a/docs/release-notes/version-3.6.md b/docs/release-notes/version-3.6.md index 10e93be1e..fc2328897 100644 --- a/docs/release-notes/version-3.6.md +++ b/docs/release-notes/version-3.6.md @@ -1,6 +1,33 @@ # NetBox v3.6 -## v3.6.7 (FUTURE) +## v3.6.8 (FUTURE) + +--- + +## v3.6.7 (2023-12-15) + +### Enhancements + +* [#12751](https://github.com/netbox-community/netbox/issues/12751) - Designate fields to expand by default for object selector widget +* [#14148](https://github.com/netbox-community/netbox/issues/14148) - Add tags column to L2VPN terminations column +* [#14390](https://github.com/netbox-community/netbox/issues/14390) - Add `classes` parameter to `copy_content` template tag +* [#14467](https://github.com/netbox-community/netbox/issues/14467) - Change custom field choice delimiter from comma to colon + +### Bug Fixes + +* [#13983](https://github.com/netbox-community/netbox/issues/13983) - Fix bulk import support for custom field choices +* [#14081](https://github.com/netbox-community/netbox/issues/14081) - Ensure accuracy of parent object counters when deleting related objects +* [#14249](https://github.com/netbox-community/netbox/issues/14249) - Fix server error when authenticating via IP-restricted API tokens using IPv6 +* [#14392](https://github.com/netbox-community/netbox/issues/14392) - Fix bulk operations for plugin models under admin UI +* [#14397](https://github.com/netbox-community/netbox/issues/14397) - Fix exception on non-JSON request to `/available-ips/` API endpoints +* [#14401](https://github.com/netbox-community/netbox/issues/14401) - Rack `starting_unit` cannot be zero +* [#14432](https://github.com/netbox-community/netbox/issues/14432) - Populate custom field default values for components when creating a device +* [#14448](https://github.com/netbox-community/netbox/issues/14448) - Fix exception when creating a power feed with rack and panel in different sites +* [#14505](https://github.com/netbox-community/netbox/issues/14505) - Fix the assignment of tags to L2VPN terminations +* [#14512](https://github.com/netbox-community/netbox/issues/14512) - Remove unneeded annotations from queries when using REST API brief mode +* [#14515](https://github.com/netbox-community/netbox/issues/14515) - Ensure user config is created automatically for all user accounts +* [#14522](https://github.com/netbox-community/netbox/issues/14522) - Fix filtering contact assignments by group +* [#14533](https://github.com/netbox-community/netbox/issues/14533) - Fix quick search under VLAN group VLANs list --- diff --git a/netbox/circuits/forms/filtersets.py b/netbox/circuits/forms/filtersets.py index a82ec1726..1e1abd068 100644 --- a/netbox/circuits/forms/filtersets.py +++ b/netbox/circuits/forms/filtersets.py @@ -119,6 +119,7 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi (_('Tenant'), ('tenant_group_id', 'tenant_id')), (_('Contacts'), ('contact', 'contact_role', 'contact_group')), ) + selector_fields = ('filter_id', 'q', 'region_id', 'site_group_id', 'site_id', 'provider_id', 'provider_network_id') type_id = DynamicModelMultipleChoiceField( queryset=CircuitType.objects.all(), required=False, diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 1c8713a28..95c441381 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -165,6 +165,7 @@ class SiteFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte (_('Tenant'), ('tenant_group_id', 'tenant_id')), (_('Contacts'), ('contact', 'contact_role', 'contact_group')), ) + selector_fields = ('filter_id', 'q', 'region_id', 'group_id') status = forms.MultipleChoiceField( label=_('Status'), choices=SiteStatusChoices, @@ -248,6 +249,7 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte (_('Contacts'), ('contact', 'contact_role', 'contact_group')), (_('Weight'), ('weight', 'max_weight', 'weight_unit')), ) + selector_fields = ('filter_id', 'q', 'region_id', 'site_group_id', 'site_id', 'location_id') region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, @@ -420,6 +422,7 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm): )), (_('Weight'), ('weight', 'weight_unit')), ) + selector_fields = ('filter_id', 'q', 'manufacturer_id') manufacturer_id = DynamicModelMultipleChoiceField( queryset=Manufacturer.objects.all(), required=False, @@ -544,6 +547,7 @@ class ModuleTypeFilterForm(NetBoxModelFilterSetForm): )), (_('Weight'), ('weight', 'weight_unit')), ) + selector_fields = ('filter_id', 'q', 'manufacturer_id') manufacturer_id = DynamicModelMultipleChoiceField( queryset=Manufacturer.objects.all(), required=False, @@ -620,6 +624,7 @@ class DeviceRoleFilterForm(NetBoxModelFilterSetForm): class PlatformFilterForm(NetBoxModelFilterSetForm): model = Platform + selector_fields = ('filter_id', 'q', 'manufacturer_id') manufacturer_id = DynamicModelMultipleChoiceField( queryset=Manufacturer.objects.all(), required=False, @@ -654,6 +659,7 @@ class DeviceFilterForm( 'has_primary_ip', 'has_oob_ip', 'virtual_chassis_member', 'config_template_id', 'local_context_data', )) ) + selector_fields = ('filter_id', 'q', 'region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id') region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, @@ -997,6 +1003,7 @@ class PowerPanelFilterForm(ContactModelFilterForm, NetBoxModelFilterSetForm): (_('Location'), ('region_id', 'site_group_id', 'site_id', 'location_id')), (_('Contacts'), ('contact', 'contact_role', 'contact_group')), ) + selector_fields = ('filter_id', 'q', 'site_id', 'location_id') region_id = DynamicModelMultipleChoiceField( queryset=Region.objects.all(), required=False, @@ -1228,6 +1235,7 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): (_('Device'), ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', 'vdc_id')), (_('Connection'), ('cabled', 'connected', 'occupied')), ) + selector_fields = ('filter_id', 'q', 'device_id') vdc_id = DynamicModelMultipleChoiceField( queryset=VirtualDeviceContext.objects.all(), required=False, diff --git a/netbox/dcim/migrations/0174_rack_starting_unit.py b/netbox/dcim/migrations/0174_rack_starting_unit.py index e32738660..2d2b5f826 100644 --- a/netbox/dcim/migrations/0174_rack_starting_unit.py +++ b/netbox/dcim/migrations/0174_rack_starting_unit.py @@ -1,5 +1,6 @@ # Generated by Django 4.1.9 on 2023-05-31 15:47 +import django.core.validators from django.db import migrations, models @@ -12,6 +13,6 @@ class Migration(migrations.Migration): migrations.AddField( model_name='rack', name='starting_unit', - field=models.PositiveSmallIntegerField(default=1), + field=models.PositiveSmallIntegerField(default=1, validators=[django.core.validators.MinValueValidator(1)]), ), ] diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 8ed8336cd..4b9689a22 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -16,7 +16,7 @@ from django.utils.translation import gettext_lazy as _ from dcim.choices import * from dcim.constants import * -from extras.models import ConfigContextModel +from extras.models import ConfigContextModel, CustomField from extras.querysets import ConfigContextModelQuerySet from netbox.config import ConfigItem from netbox.models import OrganizationalModel, PrimaryModel @@ -994,11 +994,17 @@ class Device( bulk_create: If True, bulk_create() will be called to create all components in a single query (default). Otherwise, save() will be called on each instance individually. """ + components = [obj.instantiate(device=self) for obj in queryset] + if not components: + return + + # Set default values for any applicable custom fields + model = queryset.model.component_model + if cf_defaults := CustomField.objects.get_defaults_for_model(model): + for component in components: + component.custom_field_data = cf_defaults + if bulk_create: - components = [obj.instantiate(device=self) for obj in queryset] - if not components: - return - model = components[0]._meta.model model.objects.bulk_create(components) # Manually send the post_save signal for each of the newly created components for component in components: @@ -1011,8 +1017,7 @@ class Device( update_fields=None ) else: - for obj in queryset: - component = obj.instantiate(device=self) + for component in components: component.save() def save(self, *args, **kwargs): diff --git a/netbox/dcim/models/power.py b/netbox/dcim/models/power.py index a852ea5cd..62578d6c4 100644 --- a/netbox/dcim/models/power.py +++ b/netbox/dcim/models/power.py @@ -175,7 +175,7 @@ class PowerFeed(PrimaryModel, PathEndpoint, CabledObjectModel): # Rack must belong to same Site as PowerPanel if self.rack and self.rack.site != self.power_panel.site: raise ValidationError(_( - "Rack {rack} ({site}) and power panel {powerpanel} ({powerpanel_site}) are in different sites" + "Rack {rack} ({rack_site}) and power panel {powerpanel} ({powerpanel_site}) are in different sites." ).format( rack=self.rack, rack_site=self.rack.site, diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index 0d4b844f9..3cb4e0225 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -141,6 +141,7 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, PrimaryModel, WeightMixin): starting_unit = models.PositiveSmallIntegerField( default=RACK_STARTING_UNIT_DEFAULT, verbose_name=_('starting unit'), + validators=[MinValueValidator(1),], help_text=_('Starting unit for rack') ) desc_units = models.BooleanField( diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index 741a615d4..d56bf0741 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -1,9 +1,11 @@ +from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.test import TestCase from circuits.models import * from dcim.choices import * from dcim.models import * +from extras.models import CustomField from tenancy.models import Tenant from utilities.utils import drange @@ -289,6 +291,23 @@ class DeviceTestCase(TestCase): ) DeviceRole.objects.bulk_create(roles) + # Create a CustomField with a default value & assign it to all component models + cf1 = CustomField.objects.create(name='cf1', default='foo') + cf1.content_types.set( + ContentType.objects.filter(app_label='dcim', model__in=[ + 'consoleport', + 'consoleserverport', + 'powerport', + 'poweroutlet', + 'interface', + 'rearport', + 'frontport', + 'modulebay', + 'devicebay', + 'inventoryitem', + ]) + ) + # Create DeviceType components ConsolePortTemplate( device_type=device_type, @@ -300,18 +319,18 @@ class DeviceTestCase(TestCase): name='Console Server Port 1' ).save() - ppt = PowerPortTemplate( + powerport = PowerPortTemplate( device_type=device_type, name='Power Port 1', maximum_draw=1000, allocated_draw=500 ) - ppt.save() + powerport.save() PowerOutletTemplate( device_type=device_type, name='Power Outlet 1', - power_port=ppt, + power_port=powerport, feed_leg=PowerOutletFeedLegChoices.FEED_LEG_A ).save() @@ -322,19 +341,19 @@ class DeviceTestCase(TestCase): mgmt_only=True ).save() - rpt = RearPortTemplate( + rearport = RearPortTemplate( device_type=device_type, name='Rear Port 1', type=PortTypeChoices.TYPE_8P8C, positions=8 ) - rpt.save() + rearport.save() FrontPortTemplate( device_type=device_type, name='Front Port 1', type=PortTypeChoices.TYPE_8P8C, - rear_port=rpt, + rear_port=rearport, rear_port_position=2 ).save() @@ -348,73 +367,93 @@ class DeviceTestCase(TestCase): name='Device Bay 1' ).save() + InventoryItemTemplate( + device_type=device_type, + name='Inventory Item 1' + ).save() + def test_device_creation(self): """ Ensure that all Device components are copied automatically from the DeviceType. """ - d = Device( + device = Device( site=Site.objects.first(), device_type=DeviceType.objects.first(), role=DeviceRole.objects.first(), name='Test Device 1' ) - d.save() + device.save() - ConsolePort.objects.get( - device=d, + consoleport = ConsolePort.objects.get( + device=device, name='Console Port 1' ) + self.assertEqual(consoleport.cf['cf1'], 'foo') - ConsoleServerPort.objects.get( - device=d, + consoleserverport = ConsoleServerPort.objects.get( + device=device, name='Console Server Port 1' ) + self.assertEqual(consoleserverport.cf['cf1'], 'foo') - pp = PowerPort.objects.get( - device=d, + powerport = PowerPort.objects.get( + device=device, name='Power Port 1', maximum_draw=1000, allocated_draw=500 ) + self.assertEqual(powerport.cf['cf1'], 'foo') - PowerOutlet.objects.get( - device=d, + poweroutlet = PowerOutlet.objects.get( + device=device, name='Power Outlet 1', - power_port=pp, + power_port=powerport, feed_leg=PowerOutletFeedLegChoices.FEED_LEG_A ) + self.assertEqual(poweroutlet.cf['cf1'], 'foo') - Interface.objects.get( - device=d, + interface = Interface.objects.get( + device=device, name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED, mgmt_only=True ) + self.assertEqual(interface.cf['cf1'], 'foo') - rp = RearPort.objects.get( - device=d, + rearport = RearPort.objects.get( + device=device, name='Rear Port 1', type=PortTypeChoices.TYPE_8P8C, positions=8 ) + self.assertEqual(rearport.cf['cf1'], 'foo') - FrontPort.objects.get( - device=d, + frontport = FrontPort.objects.get( + device=device, name='Front Port 1', type=PortTypeChoices.TYPE_8P8C, - rear_port=rp, + rear_port=rearport, rear_port_position=2 ) + self.assertEqual(frontport.cf['cf1'], 'foo') - ModuleBay.objects.get( - device=d, + modulebay = ModuleBay.objects.get( + device=device, name='Module Bay 1' ) + self.assertEqual(modulebay.cf['cf1'], 'foo') - DeviceBay.objects.get( - device=d, + devicebay = DeviceBay.objects.get( + device=device, name='Device Bay 1' ) + self.assertEqual(devicebay.cf['cf1'], 'foo') + + inventoryitem = InventoryItem.objects.get( + device=device, + name='Inventory Item 1' + ) + self.assertEqual(inventoryitem.cf['cf1'], 'foo') def test_multiple_unnamed_devices(self): diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py index 243d8fa4c..f8d3ffb7f 100644 --- a/netbox/extras/forms/bulk_import.py +++ b/netbox/extras/forms/bulk_import.py @@ -1,3 +1,5 @@ +import re + from django import forms from django.contrib.postgres.forms import SimpleArrayField from django.core.exceptions import ObjectDoesNotExist @@ -82,7 +84,10 @@ class CustomFieldChoiceSetImportForm(CSVModelForm): extra_choices = SimpleArrayField( base_field=forms.CharField(), required=False, - help_text=_('Comma-separated list of field choices') + help_text=_( + 'Quoted string of comma-separated field choices with optional labels separated by colon: ' + '"choice1:First Choice,choice2:Second Choice"' + ) ) class Meta: @@ -91,6 +96,19 @@ class CustomFieldChoiceSetImportForm(CSVModelForm): 'name', 'description', 'extra_choices', 'order_alphabetically', ) + def clean_extra_choices(self): + if isinstance(self.cleaned_data['extra_choices'], list): + data = [] + for line in self.cleaned_data['extra_choices']: + try: + value, label = re.split(r'(?choice1,First Choice') + 'colon. Example:' + ) + ' choice1:First Choice') ) class Meta: model = CustomFieldChoiceSet fields = ('name', 'description', 'base_choices', 'extra_choices', 'order_alphabetically') + def __init__(self, *args, initial=None, **kwargs): + super().__init__(*args, initial=initial, **kwargs) + + # Escape colons in extra_choices + if 'extra_choices' in self.initial and self.initial['extra_choices']: + choices = [] + for choice in self.initial['extra_choices']: + choice = (choice[0].replace(':', '\\:'), choice[1].replace(':', '\\:')) + choices.append(choice) + + self.initial['extra_choices'] = choices + def clean_extra_choices(self): data = [] for line in self.cleaned_data['extra_choices'].splitlines(): try: - value, label = line.split(',', maxsplit=1) + value, label = re.split(r'(? +{% if widget.name != '_selected_action' %}{% endif %} {% include "django/forms/widgets/input.html" %} diff --git a/netbox/templates/htmx/object_selector.html b/netbox/templates/htmx/object_selector.html index 0febb1069..280102ada 100644 --- a/netbox/templates/htmx/object_selector.html +++ b/netbox/templates/htmx/object_selector.html @@ -10,18 +10,18 @@
{% for field in form.visible_fields %} - + {{ field.label }} {% endfor %}
-
+
{% for field in form.visible_fields %} -
{% render_field field %}
+
{% render_field field %}
{% endfor %}
diff --git a/netbox/tenancy/filtersets.py b/netbox/tenancy/filtersets.py index 72f03e98a..bebcecb09 100644 --- a/netbox/tenancy/filtersets.py +++ b/netbox/tenancy/filtersets.py @@ -90,6 +90,19 @@ class ContactAssignmentFilterSet(NetBoxModelFilterSet): queryset=Contact.objects.all(), label=_('Contact (ID)'), ) + group_id = TreeNodeMultipleChoiceFilter( + queryset=ContactGroup.objects.all(), + field_name='contact__group', + lookup_expr='in', + label=_('Contact group (ID)'), + ) + group = TreeNodeMultipleChoiceFilter( + queryset=ContactGroup.objects.all(), + field_name='contact__group', + lookup_expr='in', + to_field_name='slug', + label=_('Contact group (slug)'), + ) role_id = django_filters.ModelMultipleChoiceFilter( queryset=ContactRole.objects.all(), label=_('Contact role (ID)'), diff --git a/netbox/tenancy/tests/test_filtersets.py b/netbox/tenancy/tests/test_filtersets.py index e427c90ce..d7337396e 100644 --- a/netbox/tenancy/tests/test_filtersets.py +++ b/netbox/tenancy/tests/test_filtersets.py @@ -1,5 +1,7 @@ +from django.contrib.contenttypes.models import ContentType from django.test import TestCase +from dcim.models import Manufacturer, Site from tenancy.filtersets import * from tenancy.models import * from utilities.testing import ChangeLoggedFilterSetTests @@ -192,3 +194,72 @@ class ContactTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) params = {'group': [group[0].slug, group[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + +class ContactAssignmentTestCase(TestCase, ChangeLoggedFilterSetTests): + queryset = ContactAssignment.objects.all() + filterset = ContactAssignmentFilterSet + + @classmethod + def setUpTestData(cls): + + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + Site(name='Site 3', slug='site-3'), + ) + Site.objects.bulk_create(sites) + + contact_groups = ( + ContactGroup(name='Contact Group 1', slug='contact-group-1'), + ContactGroup(name='Contact Group 2', slug='contact-group-2'), + ContactGroup(name='Contact Group 3', slug='contact-group-3'), + ) + for contactgroup in contact_groups: + contactgroup.save() + + contact_roles = ( + ContactRole(name='Contact Role 1', slug='contact-role-1'), + ContactRole(name='Contact Role 2', slug='contact-role-2'), + ContactRole(name='Contact Role 3', slug='contact-role-3'), + ) + ContactRole.objects.bulk_create(contact_roles) + + contacts = ( + Contact(name='Contact 1', group=contact_groups[0]), + Contact(name='Contact 2', group=contact_groups[1]), + Contact(name='Contact 3', group=contact_groups[2]), + ) + Contact.objects.bulk_create(contacts) + + assignments = ( + ContactAssignment(object=sites[0], contact=contacts[0], role=contact_roles[0]), + ContactAssignment(object=sites[1], contact=contacts[1], role=contact_roles[1]), + ContactAssignment(object=sites[2], contact=contacts[2], role=contact_roles[2]), + ContactAssignment(object=manufacturer, contact=contacts[2], role=contact_roles[2]), + ) + ContactAssignment.objects.bulk_create(assignments) + + def test_content_type(self): + params = {'content_type_id': ContentType.objects.get_by_natural_key('dcim', 'site')} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + + def test_contact(self): + contacts = Contact.objects.all()[:2] + params = {'contact_id': [contacts[0].pk, contacts[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_group(self): + group = ContactGroup.objects.all()[:2] + params = {'group_id': [group[0].pk, group[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'group': [group[0].slug, group[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_role(self): + role = ContactRole.objects.all()[:2] + params = {'role_id': [role[0].pk, role[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'role': [role[0].slug, role[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/users/models.py b/netbox/users/models.py index d77d4932c..52ce55e6c 100644 --- a/netbox/users/models.py +++ b/netbox/users/models.py @@ -220,6 +220,7 @@ class UserConfig(models.Model): @receiver(post_save, sender=User) +@receiver(post_save, sender=NetBoxUser) def create_userconfig(instance, created, raw=False, **kwargs): """ Automatically create a new UserConfig when a new User is created. Skip this if importing a user from a fixture. diff --git a/netbox/utilities/counters.py b/netbox/utilities/counters.py index 0ee2606db..589dacbdb 100644 --- a/netbox/utilities/counters.py +++ b/netbox/utilities/counters.py @@ -1,6 +1,6 @@ from django.apps import apps from django.db.models import F, Count, OuterRef, Subquery -from django.db.models.signals import post_delete, post_save +from django.db.models.signals import post_delete, post_save, pre_delete from netbox.registry import registry from .fields import CounterCacheField @@ -62,6 +62,12 @@ def post_save_receiver(sender, instance, created, **kwargs): update_counter(parent_model, new_pk, counter_name, 1) +def pre_delete_receiver(sender, instance, origin, **kwargs): + model = instance._meta.model + if not model.objects.filter(pk=instance.pk).exists(): + instance._previously_removed = True + + def post_delete_receiver(sender, instance, origin, **kwargs): """ Update counter fields on related objects when a TrackingModelMixin subclass is deleted. @@ -71,10 +77,8 @@ def post_delete_receiver(sender, instance, origin, **kwargs): parent_pk = getattr(instance, field_name, None) # Decrement the parent's counter by one - if parent_pk is not None: - # MPTT sends two delete signals for child elements so guard against multiple decrements - if not origin or origin == instance: - update_counter(parent_model, parent_pk, counter_name, -1) + if parent_pk is not None and not hasattr(instance, "_previously_removed"): + update_counter(parent_model, parent_pk, counter_name, -1) # @@ -106,6 +110,12 @@ def connect_counters(*models): weak=False, dispatch_uid=f'{model._meta.label}.{field.name}' ) + pre_delete.connect( + pre_delete_receiver, + sender=to_model, + weak=False, + dispatch_uid=f'{model._meta.label}.{field.name}' + ) post_delete.connect( post_delete_receiver, sender=to_model, diff --git a/netbox/utilities/forms/widgets/misc.py b/netbox/utilities/forms/widgets/misc.py index 307031bd8..158b0e67e 100644 --- a/netbox/utilities/forms/widgets/misc.py +++ b/netbox/utilities/forms/widgets/misc.py @@ -65,5 +65,5 @@ class ChoicesWidget(forms.Textarea): if not value: return None if type(value) is list: - return '\n'.join([f'{k},{v}' for k, v in value]) + return '\n'.join([f'{k}:{v}' for k, v in value]) return value diff --git a/netbox/utilities/request.py b/netbox/utilities/request.py index 0f8ee9cae..a5ca145e9 100644 --- a/netbox/utilities/request.py +++ b/netbox/utilities/request.py @@ -1,4 +1,5 @@ -from netaddr import IPAddress +from netaddr import AddrFormatError, IPAddress +from urllib.parse import urlparse __all__ = ( 'get_client_ip', @@ -17,11 +18,18 @@ def get_client_ip(request, additional_headers=()): ) for header in HTTP_HEADERS: if header in request.META: - client_ip = request.META[header].split(',')[0].partition(':')[0] + ip = request.META[header].split(',')[0].strip() try: - return IPAddress(client_ip) - except ValueError: - raise ValueError(f"Invalid IP address set for {header}: {client_ip}") + return IPAddress(ip) + except AddrFormatError: + # Parse the string with urlparse() to remove port number or any other cruft + ip = urlparse(f'//{ip}').hostname + + try: + return IPAddress(ip) + except AddrFormatError: + # We did our best + raise ValueError(f"Invalid IP address set for {header}: {ip}") # Could not determine the client IP address from request headers return None diff --git a/netbox/utilities/templates/builtins/copy_content.html b/netbox/utilities/templates/builtins/copy_content.html index 9025a71a1..4d9ad9431 100644 --- a/netbox/utilities/templates/builtins/copy_content.html +++ b/netbox/utilities/templates/builtins/copy_content.html @@ -1,3 +1,3 @@ - + diff --git a/netbox/utilities/templatetags/builtins/tags.py b/netbox/utilities/templatetags/builtins/tags.py index 68541ae5a..dc5d75f48 100644 --- a/netbox/utilities/templatetags/builtins/tags.py +++ b/netbox/utilities/templatetags/builtins/tags.py @@ -87,13 +87,14 @@ def checkmark(value, show_false=True, true='Yes', false='No'): @register.inclusion_tag('builtins/copy_content.html') -def copy_content(target, prefix=None, color='primary'): +def copy_content(target, prefix=None, color='primary', classes=None): """ Display a copy button to copy the content of a field. """ return { 'target': f'#{prefix or ""}{target}', - 'color': f'btn-{color}' + 'color': f'btn-{color}', + 'classes': classes or '', } diff --git a/netbox/utilities/tests/test_request.py b/netbox/utilities/tests/test_request.py new file mode 100644 index 000000000..69f677323 --- /dev/null +++ b/netbox/utilities/tests/test_request.py @@ -0,0 +1,28 @@ +from django.test import TestCase, RequestFactory + +from netaddr import IPAddress +from utilities.request import get_client_ip + + +class GetClientIPTests(TestCase): + def setUp(self): + self.factory = RequestFactory() + + def test_ipv4_address(self): + request = self.factory.get('/', HTTP_X_FORWARDED_FOR='192.168.1.1') + self.assertEqual(get_client_ip(request), IPAddress('192.168.1.1')) + request = self.factory.get('/', HTTP_X_FORWARDED_FOR='192.168.1.1:8080') + self.assertEqual(get_client_ip(request), IPAddress('192.168.1.1')) + + def test_ipv6_address(self): + request = self.factory.get('/', HTTP_X_FORWARDED_FOR='2001:db8::8a2e:370:7334') + self.assertEqual(get_client_ip(request), IPAddress('2001:db8::8a2e:370:7334')) + request = self.factory.get('/', HTTP_X_FORWARDED_FOR='[2001:db8::8a2e:370:7334]') + self.assertEqual(get_client_ip(request), IPAddress('2001:db8::8a2e:370:7334')) + request = self.factory.get('/', HTTP_X_FORWARDED_FOR='[2001:db8::8a2e:370:7334]:8080') + self.assertEqual(get_client_ip(request), IPAddress('2001:db8::8a2e:370:7334')) + + def test_invalid_ip_address(self): + request = self.factory.get('/', HTTP_X_FORWARDED_FOR='invalid_ip') + with self.assertRaises(ValueError): + get_client_ip(request) diff --git a/netbox/virtualization/forms/filtersets.py b/netbox/virtualization/forms/filtersets.py index ba0c4cc6d..5b0d097f8 100644 --- a/netbox/virtualization/forms/filtersets.py +++ b/netbox/virtualization/forms/filtersets.py @@ -46,6 +46,7 @@ class ClusterFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi (_('Tenant'), ('tenant_group_id', 'tenant_id')), (_('Contacts'), ('contact', 'contact_role', 'contact_group')), ) + selector_fields = ('filter_id', 'q', 'group_id') type_id = DynamicModelMultipleChoiceField( queryset=ClusterType.objects.all(), required=False, @@ -188,6 +189,7 @@ class VMInterfaceFilterForm(NetBoxModelFilterSetForm): (_('Virtual Machine'), ('cluster_id', 'virtual_machine_id')), (_('Attributes'), ('enabled', 'mac_address', 'vrf_id', 'l2vpn_id')), ) + selector_fields = ('filter_id', 'q', 'virtual_machine_id') cluster_id = DynamicModelMultipleChoiceField( queryset=Cluster.objects.all(), required=False, diff --git a/netbox/vpn/forms/model_forms.py b/netbox/vpn/forms/model_forms.py index 5b71c24aa..3068bfac2 100644 --- a/netbox/vpn/forms/model_forms.py +++ b/netbox/vpn/forms/model_forms.py @@ -441,7 +441,7 @@ class L2VPNTerminationForm(NetBoxModelForm): class Meta: model = L2VPNTermination - fields = ('l2vpn', ) + fields = ('l2vpn', 'tags') def __init__(self, *args, **kwargs): instance = kwargs.get('instance') diff --git a/netbox/vpn/tables/l2vpn.py b/netbox/vpn/tables/l2vpn.py index 1f8b2c0d7..91fddbd66 100644 --- a/netbox/vpn/tables/l2vpn.py +++ b/netbox/vpn/tables/l2vpn.py @@ -73,12 +73,15 @@ class L2VPNTerminationTable(NetBoxTable): orderable=False, verbose_name=_('Object Site') ) + tags = columns.TagColumn( + url_name='ipam:l2vpntermination_list' + ) class Meta(NetBoxTable.Meta): model = L2VPNTermination fields = ( 'pk', 'l2vpn', 'assigned_object_type', 'assigned_object', 'assigned_object_parent', 'assigned_object_site', - 'actions', + 'tags', 'actions', ) default_columns = ( 'pk', 'l2vpn', 'assigned_object_type', 'assigned_object_parent', 'assigned_object', 'actions', diff --git a/requirements.txt b/requirements.txt index a9d7e710c..2c49d5322 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,8 @@ bleach==6.1.0 -Django==4.2.7 +Django==4.2.8 django-cors-headers==4.3.1 django-debug-toolbar==4.2.0 -django-filter==23.4 +django-filter==23.5 django-graphiql-debug-toolbar==0.2.0 django-mptt==0.14.0 django-pglocks==1.0.4 @@ -10,22 +10,22 @@ django-prometheus==2.3.1 django-redis==5.4.0 django-rich==1.8.0 django-rq==2.9.0 -django-tables2==2.6.0 django-taggit==5.0.1 +django-tables2==2.7.0 django-timezone-field==6.1.0 djangorestframework==3.14.0 -drf-spectacular==0.26.5 -drf-spectacular-sidecar==2023.10.1 -feedparser==6.0.10 +drf-spectacular==0.27.0 +drf-spectacular-sidecar==2023.12.1 +feedparser==6.0.11 graphene-django==3.0.0 gunicorn==21.2.0 Jinja2==3.1.2 Markdown==3.5.1 -mkdocs-material==9.4.14 +mkdocs-material==9.5.2 mkdocstrings[python-legacy]==0.24.0 netaddr==0.9.0 Pillow==10.1.0 -psycopg[binary,pool]==3.1.13 +psycopg[binary,pool]==3.1.15 PyYAML==6.0.1 requests==2.31.0 social-auth-app-django==5.4.0