Merge v2.9.4 release

This commit is contained in:
Jeremy Stretch 2020-09-23 16:11:00 -04:00
commit 9b16d6df2e
46 changed files with 344 additions and 130 deletions

View File

@ -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 ## SCRIPTS_ROOT
Default: `$INSTALL_ROOT/netbox/scripts/` Default: `$INSTALL_ROOT/netbox/scripts/`

View File

@ -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) * `PORT` - TCP port of the Redis service; leave blank for default port (6379)
* `PASSWORD` - Redis password (if set) * `PASSWORD` - Redis password (if set)
* `DATABASE` - Numeric database ID * `DATABASE` - Numeric database ID
* `DEFAULT_TIMEOUT` - Connection timeout in seconds
* `SSL` - Use SSL connection to Redis * `SSL` - Use SSL connection to Redis
An example configuration is provided below: An example configuration is provided below:
@ -77,7 +76,6 @@ REDIS = {
'PORT': 1234, 'PORT': 1234,
'PASSWORD': 'foobar', 'PASSWORD': 'foobar',
'DATABASE': 0, 'DATABASE': 0,
'DEFAULT_TIMEOUT': 300,
'SSL': False, 'SSL': False,
}, },
'caching': { 'caching': {
@ -85,7 +83,6 @@ REDIS = {
'PORT': 6379, 'PORT': 6379,
'PASSWORD': '', 'PASSWORD': '',
'DATABASE': 1, 'DATABASE': 1,
'DEFAULT_TIMEOUT': 300,
'SSL': False, '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 * `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 of the Redis server and port for each sentinel instance to connect to
* `SENTINEL_SERVICE`: Name of the master / service to connect to * `SENTINEL_SERVICE`: Name of the master / service to connect to
* `SENTINEL_TIMEOUT`: Connection timeout, in seconds
Example: Example:
@ -117,9 +115,9 @@ REDIS = {
'tasks': { 'tasks': {
'SENTINELS': [('mysentinel.redis.example.com', 6379)], 'SENTINELS': [('mysentinel.redis.example.com', 6379)],
'SENTINEL_SERVICE': 'netbox', 'SENTINEL_SERVICE': 'netbox',
'SENTINEL_TIMEOUT': 10,
'PASSWORD': '', 'PASSWORD': '',
'DATABASE': 0, 'DATABASE': 0,
'DEFAULT_TIMEOUT': 300,
'SSL': False, 'SSL': False,
}, },
'caching': { 'caching': {
@ -130,7 +128,6 @@ REDIS = {
'SENTINEL_SERVICE': 'netbox', 'SENTINEL_SERVICE': 'netbox',
'PASSWORD': '', 'PASSWORD': '',
'DATABASE': 1, 'DATABASE': 1,
'DEFAULT_TIMEOUT': 300,
'SSL': False, 'SSL': False,
} }
} }

View File

@ -163,7 +163,6 @@ REDIS = {
'PORT': 6379, # Redis port 'PORT': 6379, # Redis port
'PASSWORD': '', # Redis password (optional) 'PASSWORD': '', # Redis password (optional)
'DATABASE': 0, # Database ID 'DATABASE': 0, # Database ID
'DEFAULT_TIMEOUT': 300, # Timeout (seconds)
'SSL': False, # Use SSL (optional) 'SSL': False, # Use SSL (optional)
}, },
'caching': { 'caching': {
@ -171,7 +170,6 @@ REDIS = {
'PORT': 6379, 'PORT': 6379,
'PASSWORD': '', 'PASSWORD': '',
'DATABASE': 1, # Unique ID for second database 'DATABASE': 1, # Unique ID for second database
'DEFAULT_TIMEOUT': 300,
'SSL': False, 'SSL': False,
} }
} }

View File

@ -1,3 +1,3 @@
## Rear Port Templates ## 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).

View File

@ -1,5 +1,37 @@
# NetBox v2.9 # 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) ## v2.9.3 (2020-09-04)
### Enhancements ### 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 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.) * 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}`. * `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 ### REST API Changes

View File

@ -18,7 +18,7 @@ RACK_ELEVATION_LEGEND_WIDTH_DEFAULT = 30
# #
REARPORT_POSITIONS_MIN = 1 REARPORT_POSITIONS_MIN = 1
REARPORT_POSITIONS_MAX = 64 REARPORT_POSITIONS_MAX = 1024
# #

View File

@ -1680,12 +1680,21 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldModelForm):
'region_id': '$region' 'region_id': '$region'
} }
) )
rack_group = DynamicModelChoiceField(
queryset=RackGroup.objects.all(),
required=False,
display_field='display_name',
query_params={
'site_id': '$site'
}
)
rack = DynamicModelChoiceField( rack = DynamicModelChoiceField(
queryset=Rack.objects.all(), queryset=Rack.objects.all(),
required=False, required=False,
display_field='display_name', display_field='display_name',
query_params={ query_params={
'site_id': '$site' 'site_id': '$site',
'group_id': '$rack_group',
} }
) )
position = forms.TypedChoiceField( position = forms.TypedChoiceField(

View File

@ -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)]),
),
]

View File

@ -5,7 +5,7 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('dcim', '0115_rackreservation_order'), ('dcim', '0116_rearport_max_positions'),
] ]
operations = [ operations = [

View File

@ -6,7 +6,7 @@ import mptt.fields
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('dcim', '0116_custom_field_data'), ('dcim', '0117_custom_field_data'),
] ]
operations = [ operations = [

View File

@ -15,7 +15,7 @@ def rebuild_mptt(apps, schema_editor):
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('dcim', '0117_inventoryitem_mptt'), ('dcim', '0118_inventoryitem_mptt'),
] ]
operations = [ operations = [

View File

@ -264,7 +264,10 @@ class FrontPortTemplate(ComponentTemplateModel):
) )
rear_port_position = models.PositiveSmallIntegerField( rear_port_position = models.PositiveSmallIntegerField(
default=1, default=1,
validators=[MinValueValidator(1), MaxValueValidator(64)] validators=[
MinValueValidator(REARPORT_POSITIONS_MIN),
MaxValueValidator(REARPORT_POSITIONS_MAX)
]
) )
class Meta: class Meta:
@ -315,7 +318,10 @@ class RearPortTemplate(ComponentTemplateModel):
) )
positions = models.PositiveSmallIntegerField( positions = models.PositiveSmallIntegerField(
default=1, default=1,
validators=[MinValueValidator(1), MaxValueValidator(64)] validators=[
MinValueValidator(REARPORT_POSITIONS_MIN),
MaxValueValidator(REARPORT_POSITIONS_MAX)
]
) )
class Meta: class Meta:

View File

@ -811,7 +811,10 @@ class FrontPort(CableTermination, ComponentModel):
) )
rear_port_position = models.PositiveSmallIntegerField( rear_port_position = models.PositiveSmallIntegerField(
default=1, default=1,
validators=[MinValueValidator(1), MaxValueValidator(64)] validators=[
MinValueValidator(REARPORT_POSITIONS_MIN),
MaxValueValidator(REARPORT_POSITIONS_MAX)
]
) )
tags = TaggableManager(through=TaggedItem) tags = TaggableManager(through=TaggedItem)
@ -866,7 +869,10 @@ class RearPort(CableTermination, ComponentModel):
) )
positions = models.PositiveSmallIntegerField( positions = models.PositiveSmallIntegerField(
default=1, default=1,
validators=[MinValueValidator(1), MaxValueValidator(64)] validators=[
MinValueValidator(REARPORT_POSITIONS_MIN),
MaxValueValidator(REARPORT_POSITIONS_MAX)
]
) )
tags = TaggableManager(through=TaggedItem) tags = TaggableManager(through=TaggedItem)

View File

@ -168,9 +168,13 @@ class SiteView(ObjectView):
'circuit_count': Circuit.objects.restrict(request.user, 'view').filter(terminations__site=site).count(), '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(), '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_groups = RackGroup.objects.add_related_count(
rack_count=Count('racks') RackGroup.objects.all(),
) Rack,
'group',
'rack_count',
cumulative=True
).restrict(request.user, 'view').filter(site=site)
return render(request, 'dcim/site.html', { return render(request, 'dcim/site.html', {
'site': site, 'site': site,
@ -307,6 +311,11 @@ class RackElevationListView(ObjectListView):
racks = filters.RackFilterSet(request.GET, self.queryset).qs racks = filters.RackFilterSet(request.GET, self.queryset).qs
total_count = racks.count() total_count = racks.count()
# Determine ordering
reverse = bool(request.GET.get('reverse', False))
if reverse:
racks = racks.reverse()
# Pagination # Pagination
per_page = request.GET.get('per_page', settings.PAGINATE_COUNT) per_page = request.GET.get('per_page', settings.PAGINATE_COUNT)
page_number = request.GET.get('page', 1) page_number = request.GET.get('page', 1)
@ -327,6 +336,7 @@ class RackElevationListView(ObjectListView):
'paginator': paginator, 'paginator': paginator,
'page': page, 'page': page,
'total_count': total_count, 'total_count': total_count,
'reverse': reverse,
'rack_face': rack_face, 'rack_face': rack_face,
'filter_form': forms.RackElevationFilterForm(request.GET), 'filter_form': forms.RackElevationFilterForm(request.GET),
}) })
@ -405,7 +415,6 @@ class RackReservationListView(ObjectListView):
filterset = filters.RackReservationFilterSet filterset = filters.RackReservationFilterSet
filterset_form = forms.RackReservationFilterForm filterset_form = forms.RackReservationFilterForm
table = tables.RackReservationTable table = tables.RackReservationTable
action_buttons = ('export',)
class RackReservationView(ObjectView): class RackReservationView(ObjectView):

View File

@ -57,24 +57,30 @@ class TaggedObjectSerializer(serializers.Serializer):
tags = NestedTagSerializer(many=True, required=False) tags = NestedTagSerializer(many=True, required=False)
def create(self, validated_data): def create(self, validated_data):
tags = validated_data.pop('tags', []) tags = validated_data.pop('tags', None)
instance = super().create(validated_data) instance = super().create(validated_data)
if tags is not None:
return self._save_tags(instance, tags) return self._save_tags(instance, tags)
return instance
def update(self, instance, validated_data): def update(self, instance, validated_data):
tags = validated_data.pop('tags', []) tags = validated_data.pop('tags', None)
# Cache tags on instance for change logging # Cache tags on instance for change logging
instance._tags = tags instance._tags = tags or []
instance = super().update(instance, validated_data) instance = super().update(instance, validated_data)
if tags is not None:
return self._save_tags(instance, tags) return self._save_tags(instance, tags)
return instance
def _save_tags(self, instance, tags): def _save_tags(self, instance, tags):
if tags: if tags:
instance.tags.set(*[t.name for t in tags]) instance.tags.set(*[t.name for t in tags])
else:
instance.tags.clear()
return instance return instance

View File

@ -1,4 +1,5 @@
import django_filters import django_filters
from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db.models import Q from django.db.models import Q
@ -236,6 +237,16 @@ class ObjectChangeFilterSet(BaseFilterSet):
) )
time = django_filters.DateTimeFromToRangeFilter() time = django_filters.DateTimeFromToRangeFilter()
changed_object_type = ContentTypeFilter() 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: class Meta:
model = ObjectChange model = ObjectChange

View File

@ -353,10 +353,11 @@ class ObjectChangeFilterForm(BootstrapMixin, forms.Form):
required=False, required=False,
widget=StaticSelect2() widget=StaticSelect2()
) )
user = DynamicModelMultipleChoiceField( user_id = DynamicModelMultipleChoiceField(
queryset=User.objects.all(), queryset=User.objects.all(),
required=False, required=False,
display_field='username', display_field='username',
label='User',
widget=APISelectMultiple( widget=APISelectMultiple(
api_url='/api/users/users/', api_url='/api/users/users/',
) )

View File

@ -1,7 +1,12 @@
import time
from django.contrib.contenttypes.models import ContentType
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from django.utils import timezone 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): class Command(BaseCommand):
@ -20,15 +25,33 @@ class Command(BaseCommand):
for report in report_list: for report in report_list:
if module_name in options['reports'] or report.full_name in options['reports']: 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( self.stdout.write(
"[{:%H:%M:%S}] Running {}...".format(timezone.now(), report.full_name) "[{:%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 # Report on success/failure
status = self.style.ERROR('FAILED') if report.failed else self.style.SUCCESS('SUCCESS') if job_result.status == JobResultStatusChoices.STATUS_FAILED:
for test_name, attrs in report.result.data.items(): 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( self.stdout.write(
"\t{}: {} success, {} info, {} warning, {} failure".format( "\t{}: {} success, {} info, {} warning, {} failure".format(
test_name, attrs['success'], attrs['info'], attrs['warning'], attrs['failure'] test_name, attrs['success'], attrs['info'], attrs['warning'], attrs['failure']
@ -37,6 +60,9 @@ class Command(BaseCommand):
self.stdout.write( self.stdout.write(
"[{:%H:%M:%S}] {}: {}".format(timezone.now(), report.full_name, status) "[{:%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 # Wrap things up
self.stdout.write( self.stdout.write(

View File

@ -55,7 +55,7 @@ class Migration(migrations.Migration):
dependencies = [ dependencies = [
('circuits', '0020_custom_field_data'), ('circuits', '0020_custom_field_data'),
('dcim', '0116_custom_field_data'), ('dcim', '0117_custom_field_data'),
('extras', '0050_customfield_add_choices'), ('extras', '0050_customfield_add_choices'),
('ipam', '0038_custom_field_data'), ('ipam', '0038_custom_field_data'),
('secrets', '0010_custom_field_data'), ('secrets', '0010_custom_field_data'),

View File

@ -381,12 +381,11 @@ class ObjectChangeTestCase(TestCase):
params = {'id': self.queryset.values_list('pk', flat=True)[:3]} params = {'id': self.queryset.values_list('pk', flat=True)[:3]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
# TODO: Merge #5167 from develop def test_user(self):
# def test_user(self): params = {'user_id': User.objects.filter(username__in=['user1', 'user2']).values_list('pk', flat=True)}
# 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)
# self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) params = {'user': ['user1', 'user2']}
# params = {'user': ['user1', 'user2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
# self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_user_name(self): def test_user_name(self):
params = {'user_name': ['user1', 'user2']} params = {'user_name': ['user1', 'user2']}

View File

@ -59,3 +59,21 @@ class TaggedItemTest(APITestCase):
sorted([t.name for t in site.tags.all()]), sorted([t.name for t in site.tags.all()]),
sorted(["Foo", "Bar", "New Tag"]) 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)

View File

@ -315,7 +315,7 @@ class ReportListView(ContentTypePermissionRequiredMixin, View):
Retrieve all of the available reports from disk and the recorded JobResult (if any) for each. Retrieve all of the available reports from disk and the recorded JobResult (if any) for each.
""" """
def get_required_permission(self): def get_required_permission(self):
return 'extras.view_reportresult' return 'extras.view_report'
def get(self, request): def get(self, request):
@ -347,7 +347,7 @@ class ReportView(ContentTypePermissionRequiredMixin, View):
Display a single Report and its associated JobResult (if any). Display a single Report and its associated JobResult (if any).
""" """
def get_required_permission(self): def get_required_permission(self):
return 'extras.view_reportresult' return 'extras.view_report'
def get(self, request, module, name): def get(self, request, module, name):

View File

@ -641,11 +641,11 @@ class IPAddressForm(BootstrapMixin, TenancyForm, ReturnURLForm, CustomFieldModel
self.initial['primary_for_parent'] = True self.initial['primary_for_parent'] = True
def clean(self): def clean(self):
super().clean()
# Cannot select both a device interface and a VM interface # Cannot select both a device interface and a VM interface
if self.cleaned_data.get('interface') and self.cleaned_data.get('vminterface'): 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") 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. # Primary IP assignment is only available if an interface has been assigned.
interface = self.cleaned_data.get('interface') or self.cleaned_data.get('vminterface') 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): 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) ipaddress = super().save(*args, **kwargs)
# Assign/clear this IPAddress as the primary for the associated Device/VirtualMachine. # 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 interface and self.cleaned_data['primary_for_parent']:
if ipaddress.address.version == 4: if ipaddress.address.version == 4:
interface.parent.primary_ip4 = ipaddress interface.parent.primary_ip4 = ipaddress
else: else:
interface.primary_ip6 = ipaddress interface.parent.primary_ip6 = ipaddress
interface.parent.save() interface.parent.save()
elif interface and ipaddress.address.version == 4 and interface.parent.primary_ip4 == ipaddress: elif interface and ipaddress.address.version == 4 and interface.parent.primary_ip4 == ipaddress:
interface.parent.primary_ip4 = None interface.parent.primary_ip4 = None
interface.parent.save() interface.parent.save()
elif interface and ipaddress.address.version == 6 and interface.parent.primary_ip6 == ipaddress: 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() interface.parent.save()
return ipaddress return ipaddress

View File

@ -707,30 +707,18 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel):
}) })
# Check for primary IP assignment that doesn't match the assigned device/VM # 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() device = Device.objects.filter(Q(primary_ip4=self) | Q(primary_ip6=self)).first()
if device: if device:
if self.assigned_object is None: if getattr(self.assigned_object, 'device', None) != device:
raise ValidationError({ 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() vm = VirtualMachine.objects.filter(Q(primary_ip4=self) | Q(primary_ip6=self)).first()
if vm: if vm:
if self.assigned_object is None: if getattr(self.assigned_object, 'virtual_machine', None) != vm:
raise ValidationError({ raise ValidationError({
'vminterface': f"IP address is primary for virtual machine {vm} but not assigned to an " 'vminterface': f"IP address is primary for virtual machine {vm} but not assigned to it!"
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})"
}) })
# Validate IP status selection # Validate IP status selection
@ -973,13 +961,20 @@ class VLAN(ChangeLoggedModel, CustomFieldModel):
def get_status_class(self): def get_status_class(self):
return self.STATUS_CLASS_MAP[self.status] return self.STATUS_CLASS_MAP[self.status]
def get_members(self): def get_interfaces(self):
# Return all interfaces assigned to this VLAN # Return all device interfaces assigned to this VLAN
return Interface.objects.filter( return Interface.objects.filter(
Q(untagged_vlan_id=self.pk) | Q(untagged_vlan_id=self.pk) |
Q(tagged_vlans=self.pk) Q(tagged_vlans=self.pk)
).distinct() ).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') @extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
class Service(ChangeLoggedModel, CustomFieldModel): class Service(ChangeLoggedModel, CustomFieldModel):

View File

@ -4,6 +4,7 @@ from django_tables2.utils import Accessor
from dcim.models import Interface from dcim.models import Interface
from tenancy.tables import COL_TENANT from tenancy.tables import COL_TENANT
from utilities.tables import BaseTable, BooleanColumn, ButtonsColumn, TagColumn, ToggleColumn 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 from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
RIR_UTILIZATION = """ RIR_UTILIZATION = """
@ -124,9 +125,11 @@ VLANGROUP_ADD_VLAN = """
{% endwith %} {% endwith %}
""" """
VLAN_MEMBER_UNTAGGED = """ VLAN_MEMBER_TAGGED = """
{% if record.untagged_vlan_id == vlan.pk %} {% if record.untagged_vlan_id == vlan.pk %}
<i class="glyphicon glyphicon-ok"> <span class="text-danger"><i class="fa fa-close"></i></span>
{% else %}
<span class="text-success"><i class="fa fa-check"></i></span>
{% endif %} {% endif %}
""" """
@ -415,7 +418,7 @@ class IPAddressDetailTable(IPAddressTable):
tenant = tables.TemplateColumn( tenant = tables.TemplateColumn(
template_code=COL_TENANT template_code=COL_TENANT
) )
assigned = tables.BooleanColumn( assigned = BooleanColumn(
accessor='assigned_object_id', accessor='assigned_object_id',
verbose_name='Assigned' verbose_name='Assigned'
) )
@ -553,15 +556,15 @@ class VLANDetailTable(VLANTable):
default_columns = ('pk', 'vid', 'site', 'group', 'name', 'prefixes', 'tenant', 'status', 'role', 'description') default_columns = ('pk', 'vid', 'site', 'group', 'name', 'prefixes', 'tenant', 'status', 'role', 'description')
class VLANMemberTable(BaseTable): class VLANMembersTable(BaseTable):
parent = tables.LinkColumn( """
order_by=['device', 'virtual_machine'] Base table for Interface and VMInterface assignments
) """
name = tables.LinkColumn( name = tables.LinkColumn(
verbose_name='Interface' verbose_name='Interface'
) )
untagged = tables.TemplateColumn( tagged = tables.TemplateColumn(
template_code=VLAN_MEMBER_UNTAGGED, template_code=VLAN_MEMBER_TAGGED,
orderable=False orderable=False
) )
actions = tables.TemplateColumn( actions = tables.TemplateColumn(
@ -570,9 +573,21 @@ class VLANMemberTable(BaseTable):
verbose_name='' verbose_name=''
) )
class VLANDevicesTable(VLANMembersTable):
device = tables.LinkColumn()
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = Interface 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): class InterfaceVLANTable(BaseTable):

View File

@ -90,7 +90,8 @@ urlpatterns = [
path('vlans/edit/', views.VLANBulkEditView.as_view(), name='vlan_bulk_edit'), path('vlans/edit/', views.VLANBulkEditView.as_view(), name='vlan_bulk_edit'),
path('vlans/delete/', views.VLANBulkDeleteView.as_view(), name='vlan_bulk_delete'), path('vlans/delete/', views.VLANBulkDeleteView.as_view(), name='vlan_bulk_delete'),
path('vlans/<int:pk>/', views.VLANView.as_view(), name='vlan'), path('vlans/<int:pk>/', views.VLANView.as_view(), name='vlan'),
path('vlans/<int:pk>/members/', views.VLANMembersView.as_view(), name='vlan_members'), path('vlans/<int:pk>/interfaces/', views.VLANInterfacesView.as_view(), name='vlan_interfaces'),
path('vlans/<int:pk>/vm-interfaces/', views.VLANVMInterfacesView.as_view(), name='vlan_vminterfaces'),
path('vlans/<int:pk>/edit/', views.VLANEditView.as_view(), name='vlan_edit'), path('vlans/<int:pk>/edit/', views.VLANEditView.as_view(), name='vlan_edit'),
path('vlans/<int:pk>/delete/', views.VLANDeleteView.as_view(), name='vlan_delete'), path('vlans/<int:pk>/delete/', views.VLANDeleteView.as_view(), name='vlan_delete'),
path('vlans/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='vlan_changelog', kwargs={'model': VLAN}), path('vlans/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='vlan_changelog', kwargs={'model': VLAN}),

View File

@ -749,15 +749,13 @@ class VLANView(ObjectView):
}) })
class VLANMembersView(ObjectView): class VLANInterfacesView(ObjectView):
queryset = VLAN.objects.all() queryset = VLAN.objects.all()
def get(self, request, pk): def get(self, request, pk):
vlan = get_object_or_404(self.queryset, pk=pk) vlan = get_object_or_404(self.queryset, pk=pk)
members = vlan.get_members().restrict(request.user, 'view').prefetch_related('device', 'virtual_machine') interfaces = vlan.get_interfaces().prefetch_related('device')
members_table = tables.VLANDevicesTable(interfaces)
members_table = tables.VLANMemberTable(members)
paginate = { paginate = {
'paginator_class': EnhancedPaginator, 'paginator_class': EnhancedPaginator,
@ -765,10 +763,31 @@ class VLANMembersView(ObjectView):
} }
RequestConfig(request, paginate).configure(members_table) RequestConfig(request, paginate).configure(members_table)
return render(request, 'ipam/vlan_members.html', { return render(request, 'ipam/vlan_interfaces.html', {
'vlan': vlan, 'vlan': vlan,
'members_table': members_table, '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',
}) })

View File

@ -33,7 +33,6 @@ REDIS = {
# 'SENTINEL_SERVICE': 'netbox', # 'SENTINEL_SERVICE': 'netbox',
'PASSWORD': '', 'PASSWORD': '',
'DATABASE': 0, 'DATABASE': 0,
'DEFAULT_TIMEOUT': 300,
'SSL': False, 'SSL': False,
}, },
'caching': { 'caching': {
@ -44,7 +43,6 @@ REDIS = {
# 'SENTINEL_SERVICE': 'netbox', # 'SENTINEL_SERVICE': 'netbox',
'PASSWORD': '', 'PASSWORD': '',
'DATABASE': 1, 'DATABASE': 1,
'DEFAULT_TIMEOUT': 300,
'SSL': False, 'SSL': False,
} }
} }
@ -232,6 +230,9 @@ RELEASE_CHECK_URL = None
# this setting is derived from the installed location. # this setting is derived from the installed location.
# REPORTS_ROOT = '/opt/netbox/netbox/reports' # 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 # 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. # this setting is derived from the installed location.
# SCRIPTS_ROOT = '/opt/netbox/netbox/scripts' # SCRIPTS_ROOT = '/opt/netbox/netbox/scripts'

View File

@ -24,7 +24,6 @@ REDIS = {
'PORT': 6379, 'PORT': 6379,
'PASSWORD': '', 'PASSWORD': '',
'DATABASE': 0, 'DATABASE': 0,
'DEFAULT_TIMEOUT': 300,
'SSL': False, 'SSL': False,
}, },
'caching': { 'caching': {
@ -32,7 +31,6 @@ REDIS = {
'PORT': 6379, 'PORT': 6379,
'PASSWORD': '', 'PASSWORD': '',
'DATABASE': 1, 'DATABASE': 1,
'DEFAULT_TIMEOUT': 300,
'SSL': False, 'SSL': False,
} }
} }

View File

@ -16,7 +16,7 @@ from django.core.validators import URLValidator
# Environment setup # Environment setup
# #
VERSION = '2.9.4-dev' VERSION = '2.10-beta1'
# Hostname # Hostname
HOSTNAME = platform.node() 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_URL = getattr(configuration, 'RELEASE_CHECK_URL', None)
RELEASE_CHECK_TIMEOUT = getattr(configuration, 'RELEASE_CHECK_TIMEOUT', 24 * 3600) RELEASE_CHECK_TIMEOUT = getattr(configuration, 'RELEASE_CHECK_TIMEOUT', 24 * 3600)
REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/') 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('/') SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/')
SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None) SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None)
SHORT_DATE_FORMAT = getattr(configuration, 'SHORT_DATE_FORMAT', 'Y-m-d') SHORT_DATE_FORMAT = getattr(configuration, 'SHORT_DATE_FORMAT', 'Y-m-d')
@ -132,6 +133,7 @@ if RELEASE_CHECK_URL:
if RELEASE_CHECK_TIMEOUT < 3600: if RELEASE_CHECK_TIMEOUT < 3600:
raise ImproperlyConfigured("RELEASE_CHECK_TIMEOUT has to be at least 3600 seconds (1 hour)") raise ImproperlyConfigured("RELEASE_CHECK_TIMEOUT has to be at least 3600 seconds (1 hour)")
# #
# Database # Database
# #
@ -201,10 +203,13 @@ TASKS_REDIS_USING_SENTINEL = all([
len(TASKS_REDIS_SENTINELS) > 0 len(TASKS_REDIS_SENTINELS) > 0
]) ])
TASKS_REDIS_SENTINEL_SERVICE = TASKS_REDIS.get('SENTINEL_SERVICE', 'default') 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_PASSWORD = TASKS_REDIS.get('PASSWORD', '')
TASKS_REDIS_DATABASE = TASKS_REDIS.get('DATABASE', 0) 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) 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 # Caching
if 'caching' not in REDIS: 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_SENTINEL_SERVICE = CACHING_REDIS.get('SENTINEL_SERVICE', 'default')
CACHING_REDIS_PASSWORD = CACHING_REDIS.get('PASSWORD', '') CACHING_REDIS_PASSWORD = CACHING_REDIS.get('PASSWORD', '')
CACHING_REDIS_DATABASE = CACHING_REDIS.get('DATABASE', 0) 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) CACHING_REDIS_SSL = CACHING_REDIS.get('SSL', False)
@ -538,7 +542,7 @@ if TASKS_REDIS_USING_SENTINEL:
'PASSWORD': TASKS_REDIS_PASSWORD, 'PASSWORD': TASKS_REDIS_PASSWORD,
'SOCKET_TIMEOUT': None, 'SOCKET_TIMEOUT': None,
'CONNECTION_KWARGS': { 'CONNECTION_KWARGS': {
'socket_connect_timeout': TASKS_REDIS_DEFAULT_TIMEOUT 'socket_connect_timeout': TASKS_REDIS_SENTINEL_TIMEOUT
}, },
} }
else: else:
@ -547,8 +551,8 @@ else:
'PORT': TASKS_REDIS_PORT, 'PORT': TASKS_REDIS_PORT,
'DB': TASKS_REDIS_DATABASE, 'DB': TASKS_REDIS_DATABASE,
'PASSWORD': TASKS_REDIS_PASSWORD, 'PASSWORD': TASKS_REDIS_PASSWORD,
'DEFAULT_TIMEOUT': TASKS_REDIS_DEFAULT_TIMEOUT,
'SSL': TASKS_REDIS_SSL, 'SSL': TASKS_REDIS_SSL,
'DEFAULT_TIMEOUT': RQ_DEFAULT_TIMEOUT,
} }
RQ_QUEUES = { RQ_QUEUES = {

View File

@ -11,11 +11,8 @@
<div class="row noprint"> <div class="row noprint">
<div class="col-sm-8 col-md-9"> <div class="col-sm-8 col-md-9">
<ol class="breadcrumb"> <ol class="breadcrumb">
<li><a href="{% url 'dcim:site' slug=device.site.slug %}">{{ device.site }}</a></li> <li><a href="{% url 'dcim:device_list' %}">Devices</a></li>
{% if device.rack %} <li><a href="{% url 'dcim:device_list' %}?site={{ device.site.slug }}">{{ device.site }}</a></li>
<li><a href="{% url 'dcim:rack_list' %}?site={{ device.site.slug }}">Racks</a></li>
<li><a href="{% url 'dcim:rack' pk=device.rack.pk %}">{{ device.rack }}</a></li>
{% endif %}
{% if device.parent_bay %} {% if device.parent_bay %}
<li><a href="{% url 'dcim:device' pk=device.parent_bay.device.pk %}">{{ device.parent_bay.device }}</a></li> <li><a href="{% url 'dcim:device' pk=device.parent_bay.device.pk %}">{{ device.parent_bay.device }}</a></li>
<li>{{ device.parent_bay }}</li> <li>{{ device.parent_bay }}</li>

View File

@ -23,6 +23,7 @@
<div class="panel-body"> <div class="panel-body">
{% render_field form.region %} {% render_field form.region %}
{% render_field form.site %} {% render_field form.site %}
{% render_field form.rack_group %}
{% render_field form.rack %} {% render_field form.rack %}
{% if obj.device_type.is_child_device and obj.parent_bay %} {% if obj.device_type.is_child_device and obj.parent_bay %}
<div class="form-group"> <div class="form-group">

View File

@ -11,6 +11,12 @@
<ol class="breadcrumb"> <ol class="breadcrumb">
<li><a href="{% url 'dcim:rack_list' %}">Racks</a></li> <li><a href="{% url 'dcim:rack_list' %}">Racks</a></li>
<li><a href="{% url 'dcim:rack_list' %}?site={{ rack.site.slug }}">{{ rack.site }}</a></li> <li><a href="{% url 'dcim:rack_list' %}?site={{ rack.site.slug }}">{{ rack.site }}</a></li>
{% if rack.group %}
{% for group in rack.group.get_ancestors %}
<li><a href="{{ group.get_absolute_url }}">{{ group }}</a></li>
{% endfor %}
<li><a href="{{ rack.group.get_absolute_url }}">{{ rack.group }}</a></li>
{% endif %}
<li>{{ rack }}</li> <li>{{ rack }}</li>
</ol> </ol>
</div> </div>
@ -87,7 +93,10 @@
<td>Group</td> <td>Group</td>
<td> <td>
{% if rack.group %} {% if rack.group %}
<a href="{% url 'dcim:rack_list' %}?site={{ rack.site.slug }}&group={{ rack.group.slug }}">{{ rack.group }}</a> {% for group in rack.group.get_ancestors %}
<a href="{{ group.get_absolute_url }}">{{ group }}</a> <i class="fa fa-caret-right"></i>
{% endfor %}
<a href="{{ rack.group.get_absolute_url }}">{{ rack.group }}</a>
{% else %} {% else %}
<span class="text-muted">None</span> <span class="text-muted">None</span>
{% endif %} {% endif %}

View File

@ -3,13 +3,19 @@
{% load static %} {% load static %}
{% block content %} {% block content %}
<div class="btn-group pull-right noprint" role="group"> <div class="btn-toolbar pull-right noprint" role="toolbar">
<button class="btn btn-default toggle-images" selected="selected"> <button class="btn btn-default toggle-images" selected="selected">
<span class="glyphicon glyphicon-check" aria-hidden="true"></span> Show Images <span class="glyphicon glyphicon-check" aria-hidden="true"></span> Show Images
</button> </button>
<div class="btn-group" role="group">
<a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face='front' %}" class="btn btn-default{% if rack_face == 'front' %} active{% endif %}">Front</a> <a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face='front' %}" class="btn btn-default{% if rack_face == 'front' %} active{% endif %}">Front</a>
<a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face='rear' %}" class="btn btn-default{% if rack_face == 'rear' %} active{% endif %}">Rear</a> <a href="{% url 'dcim:rack_elevation_list' %}{% querystring request face='rear' %}" class="btn btn-default{% if rack_face == 'rear' %} active{% endif %}">Rear</a>
</div> </div>
<div class="btn-group" role="group">
<a href="{% url 'dcim:rack_elevation_list' %}{% querystring request %}" class="btn btn-default{% if not reverse %} active{% endif %}">Normal</a>
<a href="{% url 'dcim:rack_elevation_list' %}{% querystring request reverse='true' %}" class="btn btn-default{% if reverse %} active{% endif %}">Reversed</a>
</div>
</div>
<h1>{% block title %}Rack Elevations{% endblock %}</h1> <h1>{% block title %}Rack Elevations{% endblock %}</h1>
<div class="row"> <div class="row">
{% if page %} {% if page %}

View File

@ -12,7 +12,7 @@
<ol class="breadcrumb"> <ol class="breadcrumb">
<li><a href="{% url 'dcim:site_list' %}">Sites</a></li> <li><a href="{% url 'dcim:site_list' %}">Sites</a></li>
{% if site.region %} {% if site.region %}
{% for region in site.region.get_ancestors.unrestricted %} {% for region in site.region.get_ancestors %}
<li><a href="{{ region.get_absolute_url }}">{{ region }}</a></li> <li><a href="{{ region.get_absolute_url }}">{{ region }}</a></li>
{% endfor %} {% endfor %}
<li><a href="{{ site.region.get_absolute_url }}">{{ site.region }}</a></li> <li><a href="{{ site.region.get_absolute_url }}">{{ site.region }}</a></li>
@ -80,7 +80,7 @@
<td>Region</td> <td>Region</td>
<td> <td>
{% if site.region %} {% if site.region %}
{% for region in site.region.get_ancestors.unrestricted %} {% for region in site.region.get_ancestors %}
<a href="{{ region.get_absolute_url }}">{{ region }}</a> <a href="{{ region.get_absolute_url }}">{{ region }}</a>
<i class="fa fa-angle-right"></i> <i class="fa fa-angle-right"></i>
{% endfor %} {% endfor %}
@ -249,7 +249,7 @@
<table class="table table-hover panel-body"> <table class="table table-hover panel-body">
{% for rg in rack_groups %} {% for rg in rack_groups %}
<tr> <tr>
<td><i class="fa fa-fw fa-folder-o"></i> <a href="{{ rg.get_absolute_url }}">{{ rg }}</a></td> <td style="padding-left: {{ rg.level }}8px"><i class="fa fa-fw fa-folder-o"></i> <a href="{{ rg.get_absolute_url }}">{{ rg }}</a></td>
<td>{{ rg.rack_count }}</td> <td>{{ rg.rack_count }}</td>
<td class="text-right noprint"> <td class="text-right noprint">
<a href="{% url 'dcim:rack_elevation_list' %}?group_id={{ rg.pk }}" class="btn btn-xs btn-primary" title="View elevations"> <a href="{% url 'dcim:rack_elevation_list' %}?group_id={{ rg.pk }}" class="btn btn-xs btn-primary" title="View elevations">

View File

@ -5,14 +5,15 @@
A module import error occurred during this request. Common causes include the following: A module import error occurred during this request. Common causes include the following:
</p> </p>
<p> <p>
<i class="fa fa-warning"></i> <strong>Missing required packages</strong> - This installation of NetBox might be missing one or more required <i class="fa fa-warning"></i> <strong>Missing required packages</strong> - This installation of NetBox might be
Python packages. These packages are listed in <code>requirements.txt</code> and are normally installed as part missing one or more required Python packages. These packages are listed in <code>requirements.txt</code> and
of the installation or upgrade process. To verify installed packages, run <code>pip freeze</code> from the <code>local_requirements.txt</code>, and are normally installed as part of the installation or upgrade process.
console and compare the output to the list of required packages. To verify installed packages, run <code>pip freeze</code> from the console and compare the output to the list of
required packages.
</p> </p>
<p> <p>
<i class="fa fa-warning"></i> <strong>WSGI service not restarted after upgrade</strong> - If this installation has recently been upgraded, <i class="fa fa-warning"></i> <strong>WSGI service not restarted after upgrade</strong> - If this installation
check that the WSGI service (e.g. gunicorn or uWSGI) has been restarted. This ensures that the new code is has recently been upgraded, check that the WSGI service (e.g. gunicorn or uWSGI) has been restarted. This
running. ensures that the new code is running.
</p> </p>
{% endblock %} {% endblock %}

View File

@ -276,7 +276,7 @@
<div class="panel-heading"> <div class="panel-heading">
<strong>Reports</strong> <strong>Reports</strong>
</div> </div>
{% if report_results and perms.extras.view_reportresult %} {% if report_results and perms.extras.view_report %}
<table class="table table-hover panel-body"> <table class="table table-hover panel-body">
{% for result in report_results %} {% for result in report_results %}
<tr> <tr>
@ -285,7 +285,7 @@
</tr> </tr>
{% endfor %} {% endfor %}
</table> </table>
{% elif perms.extras.view_reportresult %} {% elif perms.extras.view_report %}
<div class="panel-body text-muted"> <div class="panel-body text-muted">
None found None found
</div> </div>

View File

@ -518,7 +518,7 @@
<li{% if not perms.extras.view_script %} class="disabled"{% endif %}> <li{% if not perms.extras.view_script %} class="disabled"{% endif %}>
<a href="{% url 'extras:script_list' %}">Scripts</a> <a href="{% url 'extras:script_list' %}">Scripts</a>
</li> </li>
<li{% if not perms.extras.view_reportresult %} class="disabled"{% endif %}> <li{% if not perms.extras.view_report %} class="disabled"{% endif %}>
<a href="{% url 'extras:report_list' %}">Reports</a> <a href="{% url 'extras:report_list' %}">Reports</a>
</li> </li>
</ul> </ul>

View File

@ -52,8 +52,11 @@
<li role="presentation"{% if not active_tab %} class="active"{% endif %}> <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
<a href="{% url 'ipam:vlan' pk=vlan.pk %}">VLAN</a> <a href="{% url 'ipam:vlan' pk=vlan.pk %}">VLAN</a>
</li> </li>
<li role="presentation"{% if active_tab == 'members' %} class="active"{% endif %}> <li role="presentation"{% if active_tab == 'interfaces' %} class="active"{% endif %}>
<a href="{% url 'ipam:vlan_members' pk=vlan.pk %}">Members <span class="badge">{{ vlan.get_members.count }}</span></a> <a href="{% url 'ipam:vlan_interfaces' pk=vlan.pk %}">Device Interfaces <span class="badge">{{ vlan.get_interfaces.count }}</span></a>
</li>
<li role="presentation"{% if active_tab == 'vminterfaces' %} class="active"{% endif %}>
<a href="{% url 'ipam:vlan_vminterfaces' pk=vlan.pk %}">VM Interfaces <span class="badge">{{ vlan.get_vminterfaces.count }}</span></a>
</li> </li>
{% if perms.extras.view_objectchange %} {% if perms.extras.view_objectchange %}
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}> <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>

View File

@ -1,11 +1,9 @@
{% extends 'ipam/vlan.html' %} {% extends 'ipam/vlan.html' %}
{% block title %}{{ block.super }} - Members{% endblock %}
{% block content %} {% block content %}
<div class="row"> <div class="row">
<div class="col-md-12"> <div class="col-md-12">
{% include 'utilities/obj_table.html' with table=members_table table_template='panel_table.html' heading='VLAN Members' parent=vlan %} {% include 'utilities/obj_table.html' with table=members_table table_template='panel_table.html' heading='Device Interfaces' parent=vlan %}
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@ -0,0 +1,9 @@
{% extends 'ipam/vlan.html' %}
{% block content %}
<div class="row">
<div class="col-md-12">
{% include 'utilities/obj_table.html' with table=members_table table_template='panel_table.html' heading='Virtual Machine Interfaces' parent=vlan %}
</div>
</div>
{% endblock %}

View File

@ -141,7 +141,7 @@ class APISelect(SelectWithDisabled):
key = f'data-query-param-{name}' key = f'data-query-param-{name}'
values = json.loads(self.attrs.get(key, '[]')) values = json.loads(self.attrs.get(key, '[]'))
if type(value) is list: if type(value) in (list, tuple):
values.extend([str(v) for v in value]) values.extend([str(v) for v in value])
else: else:
values.append(str(value)) values.append(str(value))

View File

@ -114,12 +114,12 @@ class BooleanColumn(tables.Column):
character. character.
""" """
def render(self, value): def render(self, value):
if value is True: if value:
rendered = '<span class="text-success"><i class="fa fa-check"></i></span>' rendered = '<span class="text-success"><i class="fa fa-check"></i></span>'
elif value is False: elif value is None:
rendered = '<span class="text-danger"><i class="fa fa-close"></i></span>'
else:
rendered = '<span class="text-muted">&mdash;</span>' rendered = '<span class="text-muted">&mdash;</span>'
else:
rendered = '<span class="text-danger"><i class="fa fa-close"></i></span>'
return mark_safe(rendered) return mark_safe(rendered)

View File

@ -267,7 +267,7 @@ class APIViewTestCases:
response = self.client.patch(url, update_data, format='json', **self.header) response = self.client.patch(url, update_data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK) self.assertHttpStatus(response, status.HTTP_200_OK)
instance.refresh_from_db() instance.refresh_from_db()
self.assertInstanceEqual(instance, self.update_data, api=True) self.assertInstanceEqual(instance, update_data, api=True)
def test_bulk_update_objects(self): def test_bulk_update_objects(self):
""" """

View File

@ -936,7 +936,7 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
# ManyToManyFields # ManyToManyFields
elif isinstance(model_field, ManyToManyField): elif isinstance(model_field, ManyToManyField):
if form.cleaned_data[name].count() > 0: if form.cleaned_data[name]:
getattr(obj, name).set(form.cleaned_data[name]) getattr(obj, name).set(form.cleaned_data[name])
# Normal fields # Normal fields
elif form.cleaned_data[name] not in (None, ''): elif form.cleaned_data[name] not in (None, ''):

View File

@ -83,6 +83,7 @@ def replicate_interfaces(apps, schema_editor):
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('dcim', '0082_3569_interface_fields'),
('ipam', '0037_ipaddress_assignment'), ('ipam', '0037_ipaddress_assignment'),
('virtualization', '0015_vminterface'), ('virtualization', '0015_vminterface'),
] ]