1
0
mirror of https://github.com/netbox-community/netbox.git synced 2024-05-10 07:54:54 +00:00

Merge v2.9.4 release

This commit is contained in:
Jeremy Stretch
2020-09-23 16:11:00 -04:00
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
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)
* `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,
}
}

View File

@ -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,
}
}

View File

@ -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).

View File

@ -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

View File

@ -18,7 +18,7 @@ RACK_ELEVATION_LEGEND_WIDTH_DEFAULT = 30
#
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'
}
)
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(

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):
dependencies = [
('dcim', '0115_rackreservation_order'),
('dcim', '0116_rearport_max_positions'),
]
operations = [

View File

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

View File

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

View File

@ -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:

View File

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

View File

@ -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):

View File

@ -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

View File

@ -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

View File

@ -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/',
)

View File

@ -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(

View File

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

View File

@ -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']}

View File

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

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.
"""
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):

View File

@ -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

View File

@ -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):

View File

@ -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 %}
<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 %}
"""
@ -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):

View File

@ -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/<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>/delete/', views.VLANDeleteView.as_view(), name='vlan_delete'),
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()
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',
})

View File

@ -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'

View File

@ -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,
}
}

View File

@ -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 = {

View File

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

View File

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

View File

@ -11,6 +11,12 @@
<ol class="breadcrumb">
<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>
{% 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>
</ol>
</div>
@ -87,7 +93,10 @@
<td>Group</td>
<td>
{% 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 %}
<span class="text-muted">None</span>
{% endif %}

View File

@ -3,12 +3,18 @@
{% load static %}
{% 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">
<span class="glyphicon glyphicon-check" aria-hidden="true"></span> Show Images
</button>
<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>
<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='rear' %}" class="btn btn-default{% if rack_face == 'rear' %} active{% endif %}">Rear</a>
</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>
<div class="row">

View File

@ -12,7 +12,7 @@
<ol class="breadcrumb">
<li><a href="{% url 'dcim:site_list' %}">Sites</a></li>
{% 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>
{% endfor %}
<li><a href="{{ site.region.get_absolute_url }}">{{ site.region }}</a></li>
@ -80,7 +80,7 @@
<td>Region</td>
<td>
{% 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>
<i class="fa fa-angle-right"></i>
{% endfor %}
@ -249,7 +249,7 @@
<table class="table table-hover panel-body">
{% for rg in rack_groups %}
<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 class="text-right noprint">
<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:
</p>
<p>
<i class="fa fa-warning"></i> <strong>Missing required packages</strong> - This installation of NetBox might be missing one or more required
Python packages. These packages are listed in <code>requirements.txt</code> and are normally installed as part
of the installation or upgrade process. To verify installed packages, run <code>pip freeze</code> from the
console and compare the output to the list of required packages.
<i class="fa fa-warning"></i> <strong>Missing required packages</strong> - This installation of NetBox might be
missing one or more required Python packages. These packages are listed in <code>requirements.txt</code> and
<code>local_requirements.txt</code>, and are normally installed as part of the installation or upgrade process.
To verify installed packages, run <code>pip freeze</code> from the console and compare the output to the list of
required packages.
</p>
<p>
<i class="fa fa-warning"></i> <strong>WSGI service not restarted after upgrade</strong> - If this installation has recently been upgraded,
check that the WSGI service (e.g. gunicorn or uWSGI) has been restarted. This ensures that the new code is
running.
<i class="fa fa-warning"></i> <strong>WSGI service not restarted after upgrade</strong> - If this installation
has recently been upgraded, check that the WSGI service (e.g. gunicorn or uWSGI) has been restarted. This
ensures that the new code is running.
</p>
{% endblock %}

View File

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

View File

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

View File

@ -52,8 +52,11 @@
<li role="presentation"{% if not active_tab %} class="active"{% endif %}>
<a href="{% url 'ipam:vlan' pk=vlan.pk %}">VLAN</a>
</li>
<li role="presentation"{% if active_tab == 'members' %} class="active"{% endif %}>
<a href="{% url 'ipam:vlan_members' pk=vlan.pk %}">Members <span class="badge">{{ vlan.get_members.count }}</span></a>
<li role="presentation"{% if active_tab == 'interfaces' %} class="active"{% endif %}>
<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>
{% if perms.extras.view_objectchange %}
<li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>

View File

@ -1,11 +1,9 @@
{% extends 'ipam/vlan.html' %}
{% block title %}{{ block.super }} - Members{% endblock %}
{% 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='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>
{% 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}'
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])
else:
values.append(str(value))

View File

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

View File

@ -267,7 +267,7 @@ class APIViewTestCases:
response = self.client.patch(url, update_data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
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):
"""

View File

@ -936,7 +936,7 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
# ManyToManyFields
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])
# Normal fields
elif form.cleaned_data[name] not in (None, ''):

View File

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