diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index 244ffc120..651781c17 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -491,6 +491,14 @@ The file path to the location where custom reports will be kept. By default, thi --- +## RQ_DEFAULT_TIMEOUT + +Default: `300` + +The maximum execution time of a background task (such as running a custom script), in seconds. + +--- + ## SCRIPTS_ROOT Default: `$INSTALL_ROOT/netbox/scripts/` diff --git a/docs/configuration/required-settings.md b/docs/configuration/required-settings.md index d54b13e38..2edf6c7c7 100644 --- a/docs/configuration/required-settings.md +++ b/docs/configuration/required-settings.md @@ -65,7 +65,6 @@ Redis is configured using a configuration setting similar to `DATABASE` and thes * `PORT` - TCP port of the Redis service; leave blank for default port (6379) * `PASSWORD` - Redis password (if set) * `DATABASE` - Numeric database ID -* `DEFAULT_TIMEOUT` - Connection timeout in seconds * `SSL` - Use SSL connection to Redis An example configuration is provided below: @@ -77,7 +76,6 @@ REDIS = { 'PORT': 1234, 'PASSWORD': 'foobar', 'DATABASE': 0, - 'DEFAULT_TIMEOUT': 300, 'SSL': False, }, 'caching': { @@ -85,7 +83,6 @@ REDIS = { 'PORT': 6379, 'PASSWORD': '', 'DATABASE': 1, - 'DEFAULT_TIMEOUT': 300, 'SSL': False, } } @@ -109,6 +106,7 @@ above and the addition of two new keys. * `SENTINELS`: List of tuples or tuple of tuples with each inner tuple containing the name or IP address of the Redis server and port for each sentinel instance to connect to * `SENTINEL_SERVICE`: Name of the master / service to connect to +* `SENTINEL_TIMEOUT`: Connection timeout, in seconds Example: @@ -117,9 +115,9 @@ REDIS = { 'tasks': { 'SENTINELS': [('mysentinel.redis.example.com', 6379)], 'SENTINEL_SERVICE': 'netbox', + 'SENTINEL_TIMEOUT': 10, 'PASSWORD': '', 'DATABASE': 0, - 'DEFAULT_TIMEOUT': 300, 'SSL': False, }, 'caching': { @@ -130,7 +128,6 @@ REDIS = { 'SENTINEL_SERVICE': 'netbox', 'PASSWORD': '', 'DATABASE': 1, - 'DEFAULT_TIMEOUT': 300, 'SSL': False, } } diff --git a/docs/installation/3-netbox.md b/docs/installation/3-netbox.md index 235e39a8f..54ecd284a 100644 --- a/docs/installation/3-netbox.md +++ b/docs/installation/3-netbox.md @@ -163,7 +163,6 @@ REDIS = { 'PORT': 6379, # Redis port 'PASSWORD': '', # Redis password (optional) 'DATABASE': 0, # Database ID - 'DEFAULT_TIMEOUT': 300, # Timeout (seconds) 'SSL': False, # Use SSL (optional) }, 'caching': { @@ -171,7 +170,6 @@ REDIS = { 'PORT': 6379, 'PASSWORD': '', 'DATABASE': 1, # Unique ID for second database - 'DEFAULT_TIMEOUT': 300, 'SSL': False, } } diff --git a/docs/models/dcim/rearporttemplate.md b/docs/models/dcim/rearporttemplate.md index 71d9a200b..01ba02ac0 100644 --- a/docs/models/dcim/rearporttemplate.md +++ b/docs/models/dcim/rearporttemplate.md @@ -1,3 +1,3 @@ ## Rear Port Templates -A template for a rear-facing pass-through port that will be created on all instantiations of the parent device type. Each rear port may have a physical type and one or more front port templates assigned to it. The number of positions associated with a rear port determines how many front ports can be assigned to it (the maximum is 64). +A template for a rear-facing pass-through port that will be created on all instantiations of the parent device type. Each rear port may have a physical type and one or more front port templates assigned to it. The number of positions associated with a rear port determines how many front ports can be assigned to it (the maximum is 1024). diff --git a/docs/release-notes/version-2.9.md b/docs/release-notes/version-2.9.md index a3d500094..d0d34e1cc 100644 --- a/docs/release-notes/version-2.9.md +++ b/docs/release-notes/version-2.9.md @@ -1,5 +1,37 @@ # NetBox v2.9 +## v2.9.4 (2020-09-23) + +**NOTE:** This release removes support for the `DEFAULT_TIMEOUT` parameter under `REDIS` database configuration. Set `RQ_DEFAULT_TIMEOUT` as a global configuration parameter instead. + +**NOTE:** Any permissions referencing the legacy ReportResult model (e.g. `extras.view_reportresult`) should be updated to reference the Report model. + +### Enhancements + +* [#1755](https://github.com/netbox-community/netbox/issues/1755) - Toggle order in which rack elevations are displayed +* [#5128](https://github.com/netbox-community/netbox/issues/5128) - Increase maximum rear port positions from 64 to 1024 +* [#5134](https://github.com/netbox-community/netbox/issues/5134) - Display full hierarchy in breadcrumbs for sites/racks +* [#5149](https://github.com/netbox-community/netbox/issues/5149) - Add rack group field to device edit form +* [#5164](https://github.com/netbox-community/netbox/issues/5164) - Show total rack count per rack group under site view +* [#5171](https://github.com/netbox-community/netbox/issues/5171) - Introduce the `RQ_DEFAULT_TIMEOUT` configuration parameter + +### Bug Fixes + +* [#5050](https://github.com/netbox-community/netbox/issues/5050) - Fix potential failure on `0016_replicate_interfaces` schema migration from old release +* [#5066](https://github.com/netbox-community/netbox/issues/5066) - Update `view_reportresult` to `view_report` permission +* [#5075](https://github.com/netbox-community/netbox/issues/5075) - Include a VLAN membership view for VM interfaces +* [#5105](https://github.com/netbox-community/netbox/issues/5105) - Validation should fail when reassigning a primary IP from device to VM +* [#5109](https://github.com/netbox-community/netbox/issues/5109) - Fix representation of custom choice field values for webhook data +* [#5108](https://github.com/netbox-community/netbox/issues/5108) - Fix execution of reports via CLI +* [#5111](https://github.com/netbox-community/netbox/issues/5111) - Allow use of tuples when specifying ObjectVar `query_params` +* [#5118](https://github.com/netbox-community/netbox/issues/5118) - Specifying an empty list of tags should clear assigned tags (REST API) +* [#5133](https://github.com/netbox-community/netbox/issues/5133) - Fix disassociation of an IP address from a VM interface +* [#5136](https://github.com/netbox-community/netbox/issues/5136) - Fix exception when bulk editing interface 802.1Q mode +* [#5156](https://github.com/netbox-community/netbox/issues/5156) - Add missing "add" button to rack reservations list +* [#5167](https://github.com/netbox-community/netbox/issues/5167) - Support filtering ObjectChanges by multiple users + +--- + ## v2.9.3 (2020-09-04) ### Enhancements @@ -121,6 +153,7 @@ Two new REST API endpoints have been added to facilitate the retrieval and manip * If using NetBox's built-in remote authentication backend, update `REMOTE_AUTH_BACKEND` to `'netbox.authentication.RemoteUserBackend'`, as the authentication class has moved. * If using LDAP authentication, set `REMOTE_AUTH_BACKEND` to `'netbox.authentication.LDAPBackend'`. (LDAP configuration parameters in `ldap_config.py` remain unchanged.) * `REMOTE_AUTH_DEFAULT_PERMISSIONS` now takes a dictionary rather than a list. This is a mapping of permission names to a dictionary of constraining attributes, or `None`. For example, `['dcim.add_site', 'dcim.change_site']` would become `{'dcim.add_site': None, 'dcim.change_site': None}`. +* Backward compatibility for the old `webhooks` Redis queue name has been dropped. Ensure that your `REDIS` configuration parameter specifies both the `tasks` and `caching` databases. ### REST API Changes diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index 66768515c..961c458e0 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -18,7 +18,7 @@ RACK_ELEVATION_LEGEND_WIDTH_DEFAULT = 30 # REARPORT_POSITIONS_MIN = 1 -REARPORT_POSITIONS_MAX = 64 +REARPORT_POSITIONS_MAX = 1024 # diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 1096ae6a7..ce6aa7627 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -1680,12 +1680,21 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): 'region_id': '$region' } ) + rack_group = DynamicModelChoiceField( + queryset=RackGroup.objects.all(), + required=False, + display_field='display_name', + query_params={ + 'site_id': '$site' + } + ) rack = DynamicModelChoiceField( queryset=Rack.objects.all(), required=False, display_field='display_name', query_params={ - 'site_id': '$site' + 'site_id': '$site', + 'group_id': '$rack_group', } ) position = forms.TypedChoiceField( diff --git a/netbox/dcim/migrations/0116_rearport_max_positions.py b/netbox/dcim/migrations/0116_rearport_max_positions.py new file mode 100644 index 000000000..a03f4e3d5 --- /dev/null +++ b/netbox/dcim/migrations/0116_rearport_max_positions.py @@ -0,0 +1,34 @@ +# Generated by Django 3.1 on 2020-09-16 16:51 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0115_rackreservation_order'), + ] + + operations = [ + migrations.AlterField( + model_name='frontport', + name='rear_port_position', + field=models.PositiveSmallIntegerField(default=1, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(1024)]), + ), + migrations.AlterField( + model_name='frontporttemplate', + name='rear_port_position', + field=models.PositiveSmallIntegerField(default=1, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(1024)]), + ), + migrations.AlterField( + model_name='rearport', + name='positions', + field=models.PositiveSmallIntegerField(default=1, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(1024)]), + ), + migrations.AlterField( + model_name='rearporttemplate', + name='positions', + field=models.PositiveSmallIntegerField(default=1, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(1024)]), + ), + ] diff --git a/netbox/dcim/migrations/0116_custom_field_data.py b/netbox/dcim/migrations/0117_custom_field_data.py similarity index 97% rename from netbox/dcim/migrations/0116_custom_field_data.py rename to netbox/dcim/migrations/0117_custom_field_data.py index 34148bd0a..36933cc46 100644 --- a/netbox/dcim/migrations/0116_custom_field_data.py +++ b/netbox/dcim/migrations/0117_custom_field_data.py @@ -5,7 +5,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('dcim', '0115_rackreservation_order'), + ('dcim', '0116_rearport_max_positions'), ] operations = [ diff --git a/netbox/dcim/migrations/0117_inventoryitem_mptt.py b/netbox/dcim/migrations/0118_inventoryitem_mptt.py similarity index 97% rename from netbox/dcim/migrations/0117_inventoryitem_mptt.py rename to netbox/dcim/migrations/0118_inventoryitem_mptt.py index 2e7b34a8d..844e00136 100644 --- a/netbox/dcim/migrations/0117_inventoryitem_mptt.py +++ b/netbox/dcim/migrations/0118_inventoryitem_mptt.py @@ -6,7 +6,7 @@ import mptt.fields class Migration(migrations.Migration): dependencies = [ - ('dcim', '0116_custom_field_data'), + ('dcim', '0117_custom_field_data'), ] operations = [ diff --git a/netbox/dcim/migrations/0118_inventoryitem_mptt_rebuild.py b/netbox/dcim/migrations/0119_inventoryitem_mptt_rebuild.py similarity index 92% rename from netbox/dcim/migrations/0118_inventoryitem_mptt_rebuild.py rename to netbox/dcim/migrations/0119_inventoryitem_mptt_rebuild.py index 4bd0c770f..d3bdb3025 100644 --- a/netbox/dcim/migrations/0118_inventoryitem_mptt_rebuild.py +++ b/netbox/dcim/migrations/0119_inventoryitem_mptt_rebuild.py @@ -15,7 +15,7 @@ def rebuild_mptt(apps, schema_editor): class Migration(migrations.Migration): dependencies = [ - ('dcim', '0117_inventoryitem_mptt'), + ('dcim', '0118_inventoryitem_mptt'), ] operations = [ diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index 492fe3762..7a94b3e1b 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -264,7 +264,10 @@ class FrontPortTemplate(ComponentTemplateModel): ) rear_port_position = models.PositiveSmallIntegerField( default=1, - validators=[MinValueValidator(1), MaxValueValidator(64)] + validators=[ + MinValueValidator(REARPORT_POSITIONS_MIN), + MaxValueValidator(REARPORT_POSITIONS_MAX) + ] ) class Meta: @@ -315,7 +318,10 @@ class RearPortTemplate(ComponentTemplateModel): ) positions = models.PositiveSmallIntegerField( default=1, - validators=[MinValueValidator(1), MaxValueValidator(64)] + validators=[ + MinValueValidator(REARPORT_POSITIONS_MIN), + MaxValueValidator(REARPORT_POSITIONS_MAX) + ] ) class Meta: diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 57c611bc9..2cc4c8b60 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -811,7 +811,10 @@ class FrontPort(CableTermination, ComponentModel): ) rear_port_position = models.PositiveSmallIntegerField( default=1, - validators=[MinValueValidator(1), MaxValueValidator(64)] + validators=[ + MinValueValidator(REARPORT_POSITIONS_MIN), + MaxValueValidator(REARPORT_POSITIONS_MAX) + ] ) tags = TaggableManager(through=TaggedItem) @@ -866,7 +869,10 @@ class RearPort(CableTermination, ComponentModel): ) positions = models.PositiveSmallIntegerField( default=1, - validators=[MinValueValidator(1), MaxValueValidator(64)] + validators=[ + MinValueValidator(REARPORT_POSITIONS_MIN), + MaxValueValidator(REARPORT_POSITIONS_MAX) + ] ) tags = TaggableManager(through=TaggedItem) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 31cc66bde..ce204bee0 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -168,9 +168,13 @@ class SiteView(ObjectView): 'circuit_count': Circuit.objects.restrict(request.user, 'view').filter(terminations__site=site).count(), 'vm_count': VirtualMachine.objects.restrict(request.user, 'view').filter(cluster__site=site).count(), } - rack_groups = RackGroup.objects.restrict(request.user, 'view').filter(site=site).annotate( - rack_count=Count('racks') - ) + rack_groups = RackGroup.objects.add_related_count( + RackGroup.objects.all(), + Rack, + 'group', + 'rack_count', + cumulative=True + ).restrict(request.user, 'view').filter(site=site) return render(request, 'dcim/site.html', { 'site': site, @@ -307,6 +311,11 @@ class RackElevationListView(ObjectListView): racks = filters.RackFilterSet(request.GET, self.queryset).qs total_count = racks.count() + # Determine ordering + reverse = bool(request.GET.get('reverse', False)) + if reverse: + racks = racks.reverse() + # Pagination per_page = request.GET.get('per_page', settings.PAGINATE_COUNT) page_number = request.GET.get('page', 1) @@ -327,6 +336,7 @@ class RackElevationListView(ObjectListView): 'paginator': paginator, 'page': page, 'total_count': total_count, + 'reverse': reverse, 'rack_face': rack_face, 'filter_form': forms.RackElevationFilterForm(request.GET), }) @@ -405,7 +415,6 @@ class RackReservationListView(ObjectListView): filterset = filters.RackReservationFilterSet filterset_form = forms.RackReservationFilterForm table = tables.RackReservationTable - action_buttons = ('export',) class RackReservationView(ObjectView): diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 1942f6f25..c06d4b32d 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -57,24 +57,30 @@ class TaggedObjectSerializer(serializers.Serializer): tags = NestedTagSerializer(many=True, required=False) def create(self, validated_data): - tags = validated_data.pop('tags', []) + tags = validated_data.pop('tags', None) instance = super().create(validated_data) - return self._save_tags(instance, tags) + if tags is not None: + return self._save_tags(instance, tags) + return instance def update(self, instance, validated_data): - tags = validated_data.pop('tags', []) + tags = validated_data.pop('tags', None) # Cache tags on instance for change logging - instance._tags = tags + instance._tags = tags or [] instance = super().update(instance, validated_data) - return self._save_tags(instance, tags) + if tags is not None: + return self._save_tags(instance, tags) + return instance def _save_tags(self, instance, tags): if tags: instance.tags.set(*[t.name for t in tags]) + else: + instance.tags.clear() return instance diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index da2097f61..ad5884b7a 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -1,4 +1,5 @@ import django_filters +from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.db.models import Q @@ -236,6 +237,16 @@ class ObjectChangeFilterSet(BaseFilterSet): ) time = django_filters.DateTimeFromToRangeFilter() changed_object_type = ContentTypeFilter() + user_id = django_filters.ModelMultipleChoiceFilter( + queryset=User.objects.all(), + label='User (ID)', + ) + user = django_filters.ModelMultipleChoiceFilter( + field_name='user__username', + queryset=User.objects.all(), + to_field_name='username', + label='User name', + ) class Meta: model = ObjectChange diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index d7cbede69..7e7587901 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -353,10 +353,11 @@ class ObjectChangeFilterForm(BootstrapMixin, forms.Form): required=False, widget=StaticSelect2() ) - user = DynamicModelMultipleChoiceField( + user_id = DynamicModelMultipleChoiceField( queryset=User.objects.all(), required=False, display_field='username', + label='User', widget=APISelectMultiple( api_url='/api/users/users/', ) diff --git a/netbox/extras/management/commands/runreport.py b/netbox/extras/management/commands/runreport.py index efc789021..de7c5c91b 100644 --- a/netbox/extras/management/commands/runreport.py +++ b/netbox/extras/management/commands/runreport.py @@ -1,7 +1,12 @@ +import time + +from django.contrib.contenttypes.models import ContentType from django.core.management.base import BaseCommand from django.utils import timezone -from extras.reports import get_reports +from extras.choices import JobResultStatusChoices +from extras.models import JobResult +from extras.reports import get_reports, run_report class Command(BaseCommand): @@ -20,15 +25,33 @@ class Command(BaseCommand): for report in report_list: if module_name in options['reports'] or report.full_name in options['reports']: - # Run the report and create a new ReportResult + # Run the report and create a new JobResult self.stdout.write( "[{:%H:%M:%S}] Running {}...".format(timezone.now(), report.full_name) ) - report.run() + + report_content_type = ContentType.objects.get(app_label='extras', model='report') + job_result = JobResult.enqueue_job( + run_report, + report.full_name, + report_content_type, + None + ) + + # Wait on the job to finish + while job_result.status not in JobResultStatusChoices.TERMINAL_STATE_CHOICES: + time.sleep(1) + job_result = JobResult.objects.get(pk=job_result.pk) # Report on success/failure - status = self.style.ERROR('FAILED') if report.failed else self.style.SUCCESS('SUCCESS') - for test_name, attrs in report.result.data.items(): + if job_result.status == JobResultStatusChoices.STATUS_FAILED: + status = self.style.ERROR('FAILED') + elif job_result == JobResultStatusChoices.STATUS_ERRORED: + status = self.style.ERROR('ERRORED') + else: + status = self.style.SUCCESS('SUCCESS') + + for test_name, attrs in job_result.data.items(): self.stdout.write( "\t{}: {} success, {} info, {} warning, {} failure".format( test_name, attrs['success'], attrs['info'], attrs['warning'], attrs['failure'] @@ -37,6 +60,9 @@ class Command(BaseCommand): self.stdout.write( "[{:%H:%M:%S}] {}: {}".format(timezone.now(), report.full_name, status) ) + self.stdout.write( + "[{:%H:%M:%S}] {}: Duration {}".format(timezone.now(), report.full_name, job_result.duration) + ) # Wrap things up self.stdout.write( diff --git a/netbox/extras/migrations/0051_migrate_customfields.py b/netbox/extras/migrations/0051_migrate_customfields.py index 0937ba1f3..ac6d3816f 100644 --- a/netbox/extras/migrations/0051_migrate_customfields.py +++ b/netbox/extras/migrations/0051_migrate_customfields.py @@ -55,7 +55,7 @@ class Migration(migrations.Migration): dependencies = [ ('circuits', '0020_custom_field_data'), - ('dcim', '0116_custom_field_data'), + ('dcim', '0117_custom_field_data'), ('extras', '0050_customfield_add_choices'), ('ipam', '0038_custom_field_data'), ('secrets', '0010_custom_field_data'), diff --git a/netbox/extras/tests/test_filters.py b/netbox/extras/tests/test_filters.py index 4310ee8f0..7bcd83e81 100644 --- a/netbox/extras/tests/test_filters.py +++ b/netbox/extras/tests/test_filters.py @@ -381,12 +381,11 @@ class ObjectChangeTestCase(TestCase): params = {'id': self.queryset.values_list('pk', flat=True)[:3]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) - # TODO: Merge #5167 from develop - # def test_user(self): - # params = {'user_id': User.objects.filter(username__in=['user1', 'user2']).values_list('pk', flat=True)} - # self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) - # params = {'user': ['user1', 'user2']} - # self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_user(self): + params = {'user_id': User.objects.filter(username__in=['user1', 'user2']).values_list('pk', flat=True)} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'user': ['user1', 'user2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) def test_user_name(self): params = {'user_name': ['user1', 'user2']} diff --git a/netbox/extras/tests/test_tags.py b/netbox/extras/tests/test_tags.py index 694cd77d9..39aae49dc 100644 --- a/netbox/extras/tests/test_tags.py +++ b/netbox/extras/tests/test_tags.py @@ -59,3 +59,21 @@ class TaggedItemTest(APITestCase): sorted([t.name for t in site.tags.all()]), sorted(["Foo", "Bar", "New Tag"]) ) + + def test_clear_tagged_item(self): + site = Site.objects.create( + name='Test Site', + slug='test-site' + ) + site.tags.add("Foo", "Bar", "Baz") + data = { + 'tags': [] + } + self.add_permissions('dcim.change_site') + url = reverse('dcim-api:site-detail', kwargs={'pk': site.pk}) + + response = self.client.patch(url, data, format='json', **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(len(response.data['tags']), 0) + site = Site.objects.get(pk=response.data['id']) + self.assertEqual(len(site.tags.all()), 0) diff --git a/netbox/extras/views.py b/netbox/extras/views.py index cd813d280..56477a59f 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -315,7 +315,7 @@ class ReportListView(ContentTypePermissionRequiredMixin, View): Retrieve all of the available reports from disk and the recorded JobResult (if any) for each. """ def get_required_permission(self): - return 'extras.view_reportresult' + return 'extras.view_report' def get(self, request): @@ -347,7 +347,7 @@ class ReportView(ContentTypePermissionRequiredMixin, View): Display a single Report and its associated JobResult (if any). """ def get_required_permission(self): - return 'extras.view_reportresult' + return 'extras.view_report' def get(self, request, module, name): diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index b10af8c4c..fd1dd00c6 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -641,11 +641,11 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel self.initial['primary_for_parent'] = True def clean(self): - super().clean() # Cannot select both a device interface and a VM interface if self.cleaned_data.get('interface') and self.cleaned_data.get('vminterface'): raise forms.ValidationError("Cannot select both a device interface and a virtual machine interface") + self.instance.assigned_object = self.cleaned_data.get('interface') or self.cleaned_data.get('vminterface') # Primary IP assignment is only available if an interface has been assigned. interface = self.cleaned_data.get('interface') or self.cleaned_data.get('vminterface') @@ -655,26 +655,21 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel ) def save(self, *args, **kwargs): - - # Set assigned object - interface = self.cleaned_data.get('interface') or self.cleaned_data.get('vminterface') - if interface: - self.instance.assigned_object = interface - ipaddress = super().save(*args, **kwargs) # Assign/clear this IPAddress as the primary for the associated Device/VirtualMachine. + interface = self.instance.assigned_object if interface and self.cleaned_data['primary_for_parent']: if ipaddress.address.version == 4: interface.parent.primary_ip4 = ipaddress else: - interface.primary_ip6 = ipaddress + interface.parent.primary_ip6 = ipaddress interface.parent.save() elif interface and ipaddress.address.version == 4 and interface.parent.primary_ip4 == ipaddress: interface.parent.primary_ip4 = None interface.parent.save() elif interface and ipaddress.address.version == 6 and interface.parent.primary_ip6 == ipaddress: - interface.parent.primary_ip4 = None + interface.parent.primary_ip6 = None interface.parent.save() return ipaddress diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index f34ed3749..c11f0e296 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -707,30 +707,18 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel): }) # Check for primary IP assignment that doesn't match the assigned device/VM - if self.pk and type(self.assigned_object) is Interface: + if self.pk: device = Device.objects.filter(Q(primary_ip4=self) | Q(primary_ip6=self)).first() if device: - if self.assigned_object is None: + if getattr(self.assigned_object, 'device', None) != device: raise ValidationError({ - 'interface': f"IP address is primary for device {device} but not assigned to an interface" + 'interface': f"IP address is primary for device {device} but not assigned to it!" }) - elif self.assigned_object.device != device: - raise ValidationError({ - 'interface': f"IP address is primary for device {device} but assigned to " - f"{self.assigned_object.device} ({self.assigned_object})" - }) - elif self.pk and type(self.assigned_object) is VMInterface: vm = VirtualMachine.objects.filter(Q(primary_ip4=self) | Q(primary_ip6=self)).first() if vm: - if self.assigned_object is None: + if getattr(self.assigned_object, 'virtual_machine', None) != vm: raise ValidationError({ - 'vminterface': f"IP address is primary for virtual machine {vm} but not assigned to an " - f"interface" - }) - elif self.assigned_object.virtual_machine != vm: - raise ValidationError({ - 'vminterface': f"IP address is primary for virtual machine {vm} but assigned to " - f"{self.assigned_object.virtual_machine} ({self.assigned_object})" + 'vminterface': f"IP address is primary for virtual machine {vm} but not assigned to it!" }) # Validate IP status selection @@ -973,13 +961,20 @@ class VLAN(ChangeLoggedModel, CustomFieldModel): def get_status_class(self): return self.STATUS_CLASS_MAP[self.status] - def get_members(self): - # Return all interfaces assigned to this VLAN + def get_interfaces(self): + # Return all device interfaces assigned to this VLAN return Interface.objects.filter( Q(untagged_vlan_id=self.pk) | Q(tagged_vlans=self.pk) ).distinct() + def get_vminterfaces(self): + # Return all VM interfaces assigned to this VLAN + return VMInterface.objects.filter( + Q(untagged_vlan_id=self.pk) | + Q(tagged_vlans=self.pk) + ).distinct() + @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks') class Service(ChangeLoggedModel, CustomFieldModel): diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index 532b0bcad..3e89ece64 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -4,6 +4,7 @@ from django_tables2.utils import Accessor from dcim.models import Interface from tenancy.tables import COL_TENANT from utilities.tables import BaseTable, BooleanColumn, ButtonsColumn, TagColumn, ToggleColumn +from virtualization.models import VMInterface from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF RIR_UTILIZATION = """ @@ -124,9 +125,11 @@ VLANGROUP_ADD_VLAN = """ {% endwith %} """ -VLAN_MEMBER_UNTAGGED = """ +VLAN_MEMBER_TAGGED = """ {% if record.untagged_vlan_id == vlan.pk %} - + +{% else %} + {% endif %} """ @@ -415,7 +418,7 @@ class IPAddressDetailTable(IPAddressTable): tenant = tables.TemplateColumn( template_code=COL_TENANT ) - assigned = tables.BooleanColumn( + assigned = BooleanColumn( accessor='assigned_object_id', verbose_name='Assigned' ) @@ -553,15 +556,15 @@ class VLANDetailTable(VLANTable): default_columns = ('pk', 'vid', 'site', 'group', 'name', 'prefixes', 'tenant', 'status', 'role', 'description') -class VLANMemberTable(BaseTable): - parent = tables.LinkColumn( - order_by=['device', 'virtual_machine'] - ) +class VLANMembersTable(BaseTable): + """ + Base table for Interface and VMInterface assignments + """ name = tables.LinkColumn( verbose_name='Interface' ) - untagged = tables.TemplateColumn( - template_code=VLAN_MEMBER_UNTAGGED, + tagged = tables.TemplateColumn( + template_code=VLAN_MEMBER_TAGGED, orderable=False ) actions = tables.TemplateColumn( @@ -570,9 +573,21 @@ class VLANMemberTable(BaseTable): verbose_name='' ) + +class VLANDevicesTable(VLANMembersTable): + device = tables.LinkColumn() + class Meta(BaseTable.Meta): model = Interface - fields = ('parent', 'name', 'untagged', 'actions') + fields = ('device', 'name', 'tagged', 'actions') + + +class VLANVirtualMachinesTable(VLANMembersTable): + virtual_machine = tables.LinkColumn() + + class Meta(BaseTable.Meta): + model = VMInterface + fields = ('virtual_machine', 'name', 'tagged', 'actions') class InterfaceVLANTable(BaseTable): diff --git a/netbox/ipam/urls.py b/netbox/ipam/urls.py index b2080c0a8..533335816 100644 --- a/netbox/ipam/urls.py +++ b/netbox/ipam/urls.py @@ -90,7 +90,8 @@ urlpatterns = [ path('vlans/edit/', views.VLANBulkEditView.as_view(), name='vlan_bulk_edit'), path('vlans/delete/', views.VLANBulkDeleteView.as_view(), name='vlan_bulk_delete'), path('vlans//', views.VLANView.as_view(), name='vlan'), - path('vlans//members/', views.VLANMembersView.as_view(), name='vlan_members'), + path('vlans//interfaces/', views.VLANInterfacesView.as_view(), name='vlan_interfaces'), + path('vlans//vm-interfaces/', views.VLANVMInterfacesView.as_view(), name='vlan_vminterfaces'), path('vlans//edit/', views.VLANEditView.as_view(), name='vlan_edit'), path('vlans//delete/', views.VLANDeleteView.as_view(), name='vlan_delete'), path('vlans//changelog/', ObjectChangeLogView.as_view(), name='vlan_changelog', kwargs={'model': VLAN}), diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 0978ddd8e..64e71b69b 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -749,15 +749,13 @@ class VLANView(ObjectView): }) -class VLANMembersView(ObjectView): +class VLANInterfacesView(ObjectView): queryset = VLAN.objects.all() def get(self, request, pk): - vlan = get_object_or_404(self.queryset, pk=pk) - members = vlan.get_members().restrict(request.user, 'view').prefetch_related('device', 'virtual_machine') - - members_table = tables.VLANMemberTable(members) + interfaces = vlan.get_interfaces().prefetch_related('device') + members_table = tables.VLANDevicesTable(interfaces) paginate = { 'paginator_class': EnhancedPaginator, @@ -765,10 +763,31 @@ class VLANMembersView(ObjectView): } RequestConfig(request, paginate).configure(members_table) - return render(request, 'ipam/vlan_members.html', { + return render(request, 'ipam/vlan_interfaces.html', { 'vlan': vlan, 'members_table': members_table, - 'active_tab': 'members', + 'active_tab': 'interfaces', + }) + + +class VLANVMInterfacesView(ObjectView): + queryset = VLAN.objects.all() + + def get(self, request, pk): + vlan = get_object_or_404(self.queryset, pk=pk) + interfaces = vlan.get_vminterfaces().prefetch_related('virtual_machine') + members_table = tables.VLANVirtualMachinesTable(interfaces) + + paginate = { + 'paginator_class': EnhancedPaginator, + 'per_page': request.GET.get('per_page', settings.PAGINATE_COUNT) + } + RequestConfig(request, paginate).configure(members_table) + + return render(request, 'ipam/vlan_vminterfaces.html', { + 'vlan': vlan, + 'members_table': members_table, + 'active_tab': 'vminterfaces', }) diff --git a/netbox/netbox/configuration.example.py b/netbox/netbox/configuration.example.py index e753dd637..51c73bccc 100644 --- a/netbox/netbox/configuration.example.py +++ b/netbox/netbox/configuration.example.py @@ -33,7 +33,6 @@ REDIS = { # 'SENTINEL_SERVICE': 'netbox', 'PASSWORD': '', 'DATABASE': 0, - 'DEFAULT_TIMEOUT': 300, 'SSL': False, }, 'caching': { @@ -44,7 +43,6 @@ REDIS = { # 'SENTINEL_SERVICE': 'netbox', 'PASSWORD': '', 'DATABASE': 1, - 'DEFAULT_TIMEOUT': 300, 'SSL': False, } } @@ -232,6 +230,9 @@ RELEASE_CHECK_URL = None # this setting is derived from the installed location. # REPORTS_ROOT = '/opt/netbox/netbox/reports' +# Maximum execution time for background tasks, in seconds. +RQ_DEFAULT_TIMEOUT = 300 + # The file path where custom scripts will be stored. A trailing slash is not needed. Note that the default value of # this setting is derived from the installed location. # SCRIPTS_ROOT = '/opt/netbox/netbox/scripts' diff --git a/netbox/netbox/configuration.testing.py b/netbox/netbox/configuration.testing.py index 09d5362ab..066f94841 100644 --- a/netbox/netbox/configuration.testing.py +++ b/netbox/netbox/configuration.testing.py @@ -24,7 +24,6 @@ REDIS = { 'PORT': 6379, 'PASSWORD': '', 'DATABASE': 0, - 'DEFAULT_TIMEOUT': 300, 'SSL': False, }, 'caching': { @@ -32,7 +31,6 @@ REDIS = { 'PORT': 6379, 'PASSWORD': '', 'DATABASE': 1, - 'DEFAULT_TIMEOUT': 300, 'SSL': False, } } diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index b36084dbe..db34caefb 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -16,7 +16,7 @@ from django.core.validators import URLValidator # Environment setup # -VERSION = '2.9.4-dev' +VERSION = '2.10-beta1' # Hostname HOSTNAME = platform.node() @@ -110,6 +110,7 @@ REMOTE_AUTH_HEADER = getattr(configuration, 'REMOTE_AUTH_HEADER', 'HTTP_REMOTE_U RELEASE_CHECK_URL = getattr(configuration, 'RELEASE_CHECK_URL', None) RELEASE_CHECK_TIMEOUT = getattr(configuration, 'RELEASE_CHECK_TIMEOUT', 24 * 3600) REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/') +RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300) SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/') SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None) SHORT_DATE_FORMAT = getattr(configuration, 'SHORT_DATE_FORMAT', 'Y-m-d') @@ -132,6 +133,7 @@ if RELEASE_CHECK_URL: if RELEASE_CHECK_TIMEOUT < 3600: raise ImproperlyConfigured("RELEASE_CHECK_TIMEOUT has to be at least 3600 seconds (1 hour)") + # # Database # @@ -201,10 +203,13 @@ TASKS_REDIS_USING_SENTINEL = all([ len(TASKS_REDIS_SENTINELS) > 0 ]) TASKS_REDIS_SENTINEL_SERVICE = TASKS_REDIS.get('SENTINEL_SERVICE', 'default') +TASKS_REDIS_SENTINEL_TIMEOUT = TASKS_REDIS.get('SENTINEL_TIMEOUT', 10) TASKS_REDIS_PASSWORD = TASKS_REDIS.get('PASSWORD', '') TASKS_REDIS_DATABASE = TASKS_REDIS.get('DATABASE', 0) -TASKS_REDIS_DEFAULT_TIMEOUT = TASKS_REDIS.get('DEFAULT_TIMEOUT', 300) TASKS_REDIS_SSL = TASKS_REDIS.get('SSL', False) +# TODO: Remove in v2.10 (see #5171) +if 'DEFAULT_TIMEOUT' in TASKS_REDIS: + warnings.warn('DEFAULT_TIMEOUT is no longer supported under REDIS configuration. Set RQ_DEFAULT_TIMEOUT instead.') # Caching if 'caching' not in REDIS: @@ -222,7 +227,6 @@ CACHING_REDIS_USING_SENTINEL = all([ CACHING_REDIS_SENTINEL_SERVICE = CACHING_REDIS.get('SENTINEL_SERVICE', 'default') CACHING_REDIS_PASSWORD = CACHING_REDIS.get('PASSWORD', '') CACHING_REDIS_DATABASE = CACHING_REDIS.get('DATABASE', 0) -CACHING_REDIS_DEFAULT_TIMEOUT = CACHING_REDIS.get('DEFAULT_TIMEOUT', 300) CACHING_REDIS_SSL = CACHING_REDIS.get('SSL', False) @@ -538,7 +542,7 @@ if TASKS_REDIS_USING_SENTINEL: 'PASSWORD': TASKS_REDIS_PASSWORD, 'SOCKET_TIMEOUT': None, 'CONNECTION_KWARGS': { - 'socket_connect_timeout': TASKS_REDIS_DEFAULT_TIMEOUT + 'socket_connect_timeout': TASKS_REDIS_SENTINEL_TIMEOUT }, } else: @@ -547,8 +551,8 @@ else: 'PORT': TASKS_REDIS_PORT, 'DB': TASKS_REDIS_DATABASE, 'PASSWORD': TASKS_REDIS_PASSWORD, - 'DEFAULT_TIMEOUT': TASKS_REDIS_DEFAULT_TIMEOUT, 'SSL': TASKS_REDIS_SSL, + 'DEFAULT_TIMEOUT': RQ_DEFAULT_TIMEOUT, } RQ_QUEUES = { diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 2918d2fe3..96b61ea47 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -11,11 +11,8 @@