Merge upstream v2.6.9 into develop

This commit is contained in:
Marco Ceppi 2019-12-20 06:58:34 -05:00
commit df61fe8b6c
35 changed files with 302 additions and 98 deletions

View File

@ -36,13 +36,6 @@ Please see [the documentation](http://netbox.readthedocs.io/en/stable/) for
instructions on installing NetBox. To upgrade NetBox, please download the [latest release](https://github.com/netbox-community/netbox/releases) instructions on installing NetBox. To upgrade NetBox, please download the [latest release](https://github.com/netbox-community/netbox/releases)
and run `upgrade.sh`. and run `upgrade.sh`.
## Alternative Installations
* [Docker container](https://github.com/netbox-community/netbox-docker) (via [@cimnine](https://github.com/cimnine))
* [Vagrant deployment](https://github.com/ryanmerolle/netbox-vagrant) (via [@ryanmerolle](https://github.com/ryanmerolle))
* [Ansible deployment](https://github.com/lae/ansible-role-netbox) (via [@lae](https://github.com/lae))
* [Kubernetes deployment](https://github.com/CENGN/netbox-kubernetes) (via [@CENGN](https://github.com/CENGN))
# Providing Feedback # Providing Feedback
Feature requests and bug reports must be submitted as GiHub issues. (Please be Feature requests and bug reports must be submitted as GiHub issues. (Please be

View File

@ -182,7 +182,7 @@ class NewBranchScript(Script):
class Meta: class Meta:
name = "New Branch" name = "New Branch"
description = "Provision a new branch site" description = "Provision a new branch site"
fields = ['site_name', 'switch_count', 'switch_model'] field_order = ['site_name', 'switch_count', 'switch_model']
site_name = StringVar( site_name = StringVar(
description="Name of the new site" description="Name of the new site"

View File

@ -32,7 +32,6 @@ server {
proxy_set_header X-Forwarded-Host $server_name; proxy_set_header X-Forwarded-Host $server_name;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
add_header P3P 'CP="ALL DSP COR PSAa PSDa OUR NOR ONL UNI COM NAV"';
} }
} }
``` ```

View File

@ -1,3 +1,40 @@
# v2.6.9 (2019-12-16)
## Enhancements
* [#3152](https://github.com/netbox-community/netbox/issues/3152) - Include direct link to rack elevations on site view
* [#3441](https://github.com/netbox-community/netbox/issues/3441) - Move virtual machine results near devices in global search
* [#3761](https://github.com/netbox-community/netbox/issues/3761) - Added copy button for API tokens
## Bug Fixes
* [#2170](https://github.com/netbox-community/netbox/issues/2170) - Prevent the deletion of a virtual chassis when a cross-member LAG is present
* [#2358](https://github.com/netbox-community/netbox/issues/2358) - Respect custom field default values when creating objects via the REST API
* [#3749](https://github.com/netbox-community/netbox/issues/3749) - Fix exception on password change page for local users
* [#3757](https://github.com/netbox-community/netbox/issues/3757) - Fix unable to assign IP to interface
# v2.6.8 (2019-12-10)
## Enhancements
* [#3139](https://github.com/netbox-community/netbox/issues/3139) - Disable password change form for LDAP-authenticated users
* [#3457](https://github.com/netbox-community/netbox/issues/3457) - Display cable colors on device view
* [#3329](https://github.com/netbox-community/netbox/issues/3329) - Remove obsolete P3P policy header
* [#3663](https://github.com/netbox-community/netbox/issues/3663) - Add query filters for `created` and `last_updated` fields
* [#3722](https://github.com/netbox-community/netbox/issues/3722) - Allow the underscore character in IPAddress DNS names
## Bug Fixes
* [#3312](https://github.com/netbox-community/netbox/issues/3312) - Fix validation error when editing power cables in bulk
* [#3644](https://github.com/netbox-community/netbox/issues/3644) - Fix exception when connecting a cable to a RearPort with no corresponding FrontPort
* [#3669](https://github.com/netbox-community/netbox/issues/3669) - Include `weight` field in prefix/VLAN role form
* [#3674](https://github.com/netbox-community/netbox/issues/3674) - Include comments on PowerFeed view
* [#3679](https://github.com/netbox-community/netbox/issues/3679) - Fix link for assigned ipaddress in interface page
* [#3709](https://github.com/netbox-community/netbox/issues/3709) - Prevent exception when importing an invalid cable definition
* [#3720](https://github.com/netbox-community/netbox/issues/3720) - Correctly indicate power feed terminations on cable list
* [#3724](https://github.com/netbox-community/netbox/issues/3724) - Fix API filtering of interfaces by more than one device name
* [#3725](https://github.com/netbox-community/netbox/issues/3725) - Enforce client validation for minimum service port number
# v2.6.7 (2019-11-01) # v2.6.7 (2019-11-01)
## Enhancements ## Enhancements

View File

@ -31,6 +31,7 @@ pages:
- Change Logging: 'additional-features/change-logging.md' - Change Logging: 'additional-features/change-logging.md'
- Context Data: 'additional-features/context-data.md' - Context Data: 'additional-features/context-data.md'
- Custom Fields: 'additional-features/custom-fields.md' - Custom Fields: 'additional-features/custom-fields.md'
- Custom Links: 'additional-features/custom-links.md'
- Custom Scripts: 'additional-features/custom-scripts.md' - Custom Scripts: 'additional-features/custom-scripts.md'
- Export Templates: 'additional-features/export-templates.md' - Export Templates: 'additional-features/export-templates.md'
- Graphs: 'additional-features/graphs.md' - Graphs: 'additional-features/graphs.md'

View File

@ -2,14 +2,14 @@ import django_filters
from django.db.models import Q from django.db.models import Q
from dcim.models import Region, Site from dcim.models import Region, Site
from extras.filters import CustomFieldFilterSet from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet
from tenancy.filtersets import TenancyFilterSet from tenancy.filtersets import TenancyFilterSet
from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter
from .constants import * from .constants import *
from .models import Circuit, CircuitTermination, CircuitType, Provider from .models import Circuit, CircuitTermination, CircuitType, Provider
class ProviderFilter(CustomFieldFilterSet): class ProviderFilter(CustomFieldFilterSet, CreatedUpdatedFilterSet):
id__in = NumericInFilter( id__in = NumericInFilter(
field_name='id', field_name='id',
lookup_expr='in' lookup_expr='in'
@ -54,7 +54,7 @@ class CircuitTypeFilter(NameSlugSearchFilterSet):
fields = ['id', 'name', 'slug'] fields = ['id', 'name', 'slug']
class CircuitFilter(CustomFieldFilterSet, TenancyFilterSet): class CircuitFilter(CustomFieldFilterSet, TenancyFilterSet, CreatedUpdatedFilterSet):
id__in = NumericInFilter( id__in = NumericInFilter(
field_name='id', field_name='id',
lookup_expr='in' lookup_expr='in'

View File

@ -391,7 +391,8 @@ CONNECTION_STATUS_CHOICES = [
# Cable endpoint types # Cable endpoint types
CABLE_TERMINATION_TYPES = [ CABLE_TERMINATION_TYPES = [
'consoleport', 'consoleserverport', 'interface', 'poweroutlet', 'powerport', 'frontport', 'rearport', 'circuittermination', 'consoleport', 'consoleserverport', 'interface', 'poweroutlet', 'powerport', 'frontport', 'rearport',
'circuittermination', 'powerfeed',
] ]
# Cable types # Cable types

View File

@ -2,13 +2,13 @@ import django_filters
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.db.models import Q from django.db.models import Q
from extras.filters import CustomFieldFilterSet, LocalConfigContextFilter from extras.filters import CustomFieldFilterSet, LocalConfigContextFilter, CreatedUpdatedFilterSet
from tenancy.filtersets import TenancyFilterSet from tenancy.filtersets import TenancyFilterSet
from tenancy.models import Tenant from tenancy.models import Tenant
from utilities.constants import COLOR_CHOICES from utilities.constants import COLOR_CHOICES
from utilities.filters import ( from utilities.filters import (
MultiValueMACAddressFilter, MultiValueNumberFilter, NameSlugSearchFilterSet, NumericInFilter, TagFilter, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, NameSlugSearchFilterSet, NumericInFilter,
TreeNodeMultipleChoiceFilter, TagFilter, TreeNodeMultipleChoiceFilter,
) )
from virtualization.models import Cluster from virtualization.models import Cluster
from .constants import * from .constants import *
@ -38,7 +38,7 @@ class RegionFilter(NameSlugSearchFilterSet, CustomFieldFilterSet):
fields = ['id', 'name', 'slug'] fields = ['id', 'name', 'slug']
class SiteFilter(TenancyFilterSet, CustomFieldFilterSet): class SiteFilter(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
id__in = NumericInFilter( id__in = NumericInFilter(
field_name='id', field_name='id',
lookup_expr='in' lookup_expr='in'
@ -116,7 +116,7 @@ class RackRoleFilter(NameSlugSearchFilterSet):
fields = ['id', 'name', 'slug', 'color'] fields = ['id', 'name', 'slug', 'color']
class RackFilter(TenancyFilterSet, CustomFieldFilterSet): class RackFilter(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
id__in = NumericInFilter( id__in = NumericInFilter(
field_name='id', field_name='id',
lookup_expr='in' lookup_expr='in'
@ -251,7 +251,7 @@ class ManufacturerFilter(NameSlugSearchFilterSet):
fields = ['id', 'name', 'slug'] fields = ['id', 'name', 'slug']
class DeviceTypeFilter(CustomFieldFilterSet): class DeviceTypeFilter(CustomFieldFilterSet, CreatedUpdatedFilterSet):
id__in = NumericInFilter( id__in = NumericInFilter(
field_name='id', field_name='id',
lookup_expr='in' lookup_expr='in'
@ -423,7 +423,7 @@ class PlatformFilter(NameSlugSearchFilterSet):
fields = ['id', 'name', 'slug', 'napalm_driver'] fields = ['id', 'name', 'slug', 'napalm_driver']
class DeviceFilter(LocalConfigContextFilter, TenancyFilterSet, CustomFieldFilterSet): class DeviceFilter(LocalConfigContextFilter, TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
id__in = NumericInFilter( id__in = NumericInFilter(
field_name='id', field_name='id',
lookup_expr='in' lookup_expr='in'
@ -696,7 +696,7 @@ class InterfaceFilter(django_filters.FilterSet):
method='search', method='search',
label='Search', label='Search',
) )
device = django_filters.CharFilter( device = MultiValueCharFilter(
method='filter_device', method='filter_device',
field_name='name', field_name='name',
label='Device', label='Device',
@ -749,8 +749,10 @@ class InterfaceFilter(django_filters.FilterSet):
def filter_device(self, queryset, name, value): def filter_device(self, queryset, name, value):
try: try:
device = Device.objects.get(**{name: value}) devices = Device.objects.filter(**{'{}__in'.format(name): value})
vc_interface_ids = device.vc_interfaces.values_list('id', flat=True) vc_interface_ids = []
for device in devices:
vc_interface_ids.extend(device.vc_interfaces.values_list('id', flat=True))
return queryset.filter(pk__in=vc_interface_ids) return queryset.filter(pk__in=vc_interface_ids)
except Device.DoesNotExist: except Device.DoesNotExist:
return queryset.none() return queryset.none()
@ -1096,7 +1098,7 @@ class PowerPanelFilter(django_filters.FilterSet):
return queryset.filter(qs_filter) return queryset.filter(qs_filter)
class PowerFeedFilter(CustomFieldFilterSet): class PowerFeedFilter(CustomFieldFilterSet, CreatedUpdatedFilterSet):
id__in = NumericInFilter( id__in = NumericInFilter(
field_name='id', field_name='id',
lookup_expr='in' lookup_expr='in'

View File

@ -174,8 +174,8 @@ class Migration(migrations.Migration):
('length', models.PositiveSmallIntegerField(blank=True, null=True)), ('length', models.PositiveSmallIntegerField(blank=True, null=True)),
('length_unit', models.PositiveSmallIntegerField(blank=True, null=True)), ('length_unit', models.PositiveSmallIntegerField(blank=True, null=True)),
('_abs_length', models.DecimalField(blank=True, decimal_places=4, max_digits=10, null=True)), ('_abs_length', models.DecimalField(blank=True, decimal_places=4, max_digits=10, null=True)),
('termination_a_type', models.ForeignKey(limit_choices_to={'model__in': ['consoleport', 'consoleserverport', 'interface', 'poweroutlet', 'powerport', 'frontport', 'rearport', 'circuittermination']}, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.ContentType')), ('termination_a_type', models.ForeignKey(limit_choices_to={'model__in': ['consoleport', 'consoleserverport', 'interface', 'poweroutlet', 'powerport', 'frontport', 'rearport', 'circuittermination', 'powerfeed']}, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.ContentType')),
('termination_b_type', models.ForeignKey(limit_choices_to={'model__in': ['consoleport', 'consoleserverport', 'interface', 'poweroutlet', 'powerport', 'frontport', 'rearport', 'circuittermination']}, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.ContentType')), ('termination_b_type', models.ForeignKey(limit_choices_to={'model__in': ['consoleport', 'consoleserverport', 'interface', 'poweroutlet', 'powerport', 'frontport', 'rearport', 'circuittermination', 'powerfeed']}, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.ContentType')),
], ],
), ),
migrations.AlterUniqueTogether( migrations.AlterUniqueTogether(

View File

@ -9,7 +9,7 @@ from django.contrib.postgres.fields import ArrayField, JSONField
from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models from django.db import models
from django.db.models import Count, Q, Sum from django.db.models import Count, F, ProtectedError, Q, Sum
from django.urls import reverse from django.urls import reverse
from mptt.models import MPTTModel, TreeForeignKey from mptt.models import MPTTModel, TreeForeignKey
from taggit.managers import TaggableManager from taggit.managers import TaggableManager
@ -98,6 +98,8 @@ class CableTermination(models.Model):
object_id_field='termination_b_id' object_id_field='termination_b_id'
) )
is_path_endpoint = True
class Meta: class Meta:
abstract = True abstract = True
@ -2449,6 +2451,8 @@ class FrontPort(CableTermination, ComponentModel):
validators=[MinValueValidator(1), MaxValueValidator(64)] validators=[MinValueValidator(1), MaxValueValidator(64)]
) )
is_path_endpoint = False
objects = NaturalOrderingManager() objects = NaturalOrderingManager()
tags = TaggableManager(through=TaggedItem) tags = TaggableManager(through=TaggedItem)
@ -2511,6 +2515,8 @@ class RearPort(CableTermination, ComponentModel):
validators=[MinValueValidator(1), MaxValueValidator(64)] validators=[MinValueValidator(1), MaxValueValidator(64)]
) )
is_path_endpoint = False
objects = NaturalOrderingManager() objects = NaturalOrderingManager()
tags = TaggableManager(through=TaggedItem) tags = TaggableManager(through=TaggedItem)
@ -2729,6 +2735,24 @@ class VirtualChassis(ChangeLoggedModel):
'master': "The selected master is not assigned to this virtual chassis." 'master': "The selected master is not assigned to this virtual chassis."
}) })
def delete(self, *args, **kwargs):
# Check for LAG interfaces split across member chassis
interfaces = Interface.objects.filter(
device__in=self.members.all(),
lag__isnull=False
).exclude(
lag__device=F('device')
)
if interfaces:
raise ProtectedError(
"Unable to delete virtual chassis {}. There are member interfaces which form a cross-chassis "
"LAG".format(self),
interfaces
)
return super().delete(*args, **kwargs)
def to_csv(self): def to_csv(self):
return ( return (
self.master, self.master,
@ -2843,6 +2867,8 @@ class Cable(ChangeLoggedModel):
def clean(self): def clean(self):
# Validate that termination A exists # Validate that termination A exists
if not hasattr(self, 'termination_a_type'):
raise ValidationError('Termination A type has not been specified')
try: try:
self.termination_a_type.model_class().objects.get(pk=self.termination_a_id) self.termination_a_type.model_class().objects.get(pk=self.termination_a_id)
except ObjectDoesNotExist: except ObjectDoesNotExist:
@ -2851,6 +2877,8 @@ class Cable(ChangeLoggedModel):
}) })
# Validate that termination B exists # Validate that termination B exists
if not hasattr(self, 'termination_b_type'):
raise ValidationError('Termination B type has not been specified')
try: try:
self.termination_b_type.model_class().objects.get(pk=self.termination_b_id) self.termination_b_type.model_class().objects.get(pk=self.termination_b_id)
except ObjectDoesNotExist: except ObjectDoesNotExist:

View File

@ -45,7 +45,7 @@ def update_connected_endpoints(instance, **kwargs):
# Check if this Cable has formed a complete path. If so, update both endpoints. # Check if this Cable has formed a complete path. If so, update both endpoints.
endpoint_a, endpoint_b, path_status = instance.get_path_endpoints() endpoint_a, endpoint_b, path_status = instance.get_path_endpoints()
if endpoint_a is not None and endpoint_b is not None: if getattr(endpoint_a, 'is_path_endpoint', False) and getattr(endpoint_b, 'is_path_endpoint', False):
endpoint_a.connected_endpoint = endpoint_b endpoint_a.connected_endpoint = endpoint_b
endpoint_a.connection_status = path_status endpoint_a.connection_status = path_status
endpoint_a.save() endpoint_a.save()

View File

@ -181,8 +181,10 @@ VIRTUALCHASSIS_ACTIONS = """
CABLE_TERMINATION_PARENT = """ CABLE_TERMINATION_PARENT = """
{% if value.device %} {% if value.device %}
<a href="{{ value.device.get_absolute_url }}">{{ value.device }}</a> <a href="{{ value.device.get_absolute_url }}">{{ value.device }}</a>
{% else %} {% elif value.circuit %}
<a href="{{ value.circuit.get_absolute_url }}">{{ value.circuit }}</a> <a href="{{ value.circuit.get_absolute_url }}">{{ value.circuit }}</a>
{% elif value.power_panel %}
<a href="{{ value.power_panel.get_absolute_url }}">{{ value.power_panel }}</a>
{% endif %} {% endif %}
""" """
@ -718,7 +720,7 @@ class CableTable(BaseTable):
orderable=False, orderable=False,
verbose_name='Termination A' verbose_name='Termination A'
) )
termination_a = tables.Column( termination_a = tables.LinkColumn(
accessor=Accessor('termination_a'), accessor=Accessor('termination_a'),
orderable=False, orderable=False,
verbose_name='' verbose_name=''
@ -729,7 +731,7 @@ class CableTable(BaseTable):
orderable=False, orderable=False,
verbose_name='Termination B' verbose_name='Termination B'
) )
termination_b = tables.Column( termination_b = tables.LinkColumn(
accessor=Accessor('termination_b'), accessor=Accessor('termination_b'),
orderable=False, orderable=False,
verbose_name='' verbose_name=''

View File

@ -22,7 +22,9 @@ class CustomFieldsSerializer(serializers.BaseSerializer):
def to_internal_value(self, data): def to_internal_value(self, data):
content_type = ContentType.objects.get_for_model(self.parent.Meta.model) content_type = ContentType.objects.get_for_model(self.parent.Meta.model)
custom_fields = {field.name: field for field in CustomField.objects.filter(obj_type=content_type)} custom_fields = {
field.name: field for field in CustomField.objects.filter(obj_type=content_type)
}
for field_name, value in data.items(): for field_name, value in data.items():
@ -107,11 +109,11 @@ class CustomFieldModelSerializer(ValidatedModelSerializer):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if self.instance is not None: # Retrieve the set of CustomFields which apply to this type of object
content_type = ContentType.objects.get_for_model(self.Meta.model)
fields = CustomField.objects.filter(obj_type=content_type)
# Retrieve the set of CustomFields which apply to this type of object if self.instance is not None:
content_type = ContentType.objects.get_for_model(self.Meta.model)
fields = CustomField.objects.filter(obj_type=content_type)
# Populate CustomFieldValues for each instance from database # Populate CustomFieldValues for each instance from database
try: try:
@ -120,6 +122,23 @@ class CustomFieldModelSerializer(ValidatedModelSerializer):
except TypeError: except TypeError:
_populate_custom_fields(self.instance, fields) _populate_custom_fields(self.instance, fields)
else:
# Populate default values
if fields and 'custom_fields' not in self.initial_data:
self.initial_data['custom_fields'] = {}
# Populate initial data using custom field default values
for field in fields:
if field.name not in self.initial_data['custom_fields'] and field.default:
if field.type == CF_TYPE_SELECT:
field_value = field.choices.get(value=field.default).pk
elif field.type == CF_TYPE_BOOLEAN:
field_value = bool(field.default)
else:
field_value = field.default
self.initial_data['custom_fields'][field.name] = field_value
def _save_custom_fields(self, instance, custom_fields): def _save_custom_fields(self, instance, custom_fields):
content_type = ContentType.objects.get_for_model(self.Meta.model) content_type = ContentType.objects.get_for_model(self.Meta.model)
for field_name, value in custom_fields.items(): for field_name, value in custom_fields.items():

View File

@ -18,7 +18,7 @@ router.APIRootView = ExtrasRootView
router.register(r'_choices', views.ExtrasFieldChoicesViewSet, basename='field-choice') router.register(r'_choices', views.ExtrasFieldChoicesViewSet, basename='field-choice')
# Custom field choices # Custom field choices
router.register(r'_custom_field_choices', views.CustomFieldChoicesViewSet, base_name='custom-field-choice') router.register(r'_custom_field_choices', views.CustomFieldChoicesViewSet, basename='custom-field-choice')
# Graphs # Graphs
router.register(r'graphs', views.GraphViewSet) router.register(r'graphs', views.GraphViewSet)

View File

@ -241,3 +241,24 @@ class ObjectChangeFilter(django_filters.FilterSet):
Q(user_name__icontains=value) | Q(user_name__icontains=value) |
Q(object_repr__icontains=value) Q(object_repr__icontains=value)
) )
class CreatedUpdatedFilterSet(django_filters.FilterSet):
created = django_filters.DateFilter()
created__gte = django_filters.DateFilter(
field_name='created',
lookup_expr='gte'
)
created__lte = django_filters.DateFilter(
field_name='created',
lookup_expr='lte'
)
last_updated = django_filters.DateTimeFilter()
last_updated__gte = django_filters.DateTimeFilter(
field_name='last_updated',
lookup_expr='gte'
)
last_updated__lte = django_filters.DateTimeFilter(
field_name='last_updated',
lookup_expr='lte'
)

View File

@ -301,6 +301,40 @@ class CustomFieldAPITest(APITestCase):
cfv = self.site.custom_field_values.get(field=self.cf_select) cfv = self.site.custom_field_values.get(field=self.cf_select)
self.assertEqual(cfv.value.pk, data['custom_fields']['magic_choice']) self.assertEqual(cfv.value.pk, data['custom_fields']['magic_choice'])
def test_set_custom_field_defaults(self):
"""
Create a new object with no custom field data. Custom field values should be created using the custom fields'
default values.
"""
CUSTOM_FIELD_DEFAULTS = {
'magic_word': 'foobar',
'magic_number': '123',
'is_magic': 'true',
'magic_date': '2019-12-13',
'magic_url': 'http://example.com/',
'magic_choice': self.cf_select_choice1.value,
}
# Update CustomFields to set default values
for field_name, default_value in CUSTOM_FIELD_DEFAULTS.items():
CustomField.objects.filter(name=field_name).update(default=default_value)
data = {
'name': 'Test Site X',
'slug': 'test-site-x',
}
url = reverse('dcim-api:site-list')
response = self.client.post(url, data, format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_201_CREATED)
self.assertEqual(response.data['custom_fields']['magic_word'], CUSTOM_FIELD_DEFAULTS['magic_word'])
self.assertEqual(response.data['custom_fields']['magic_number'], str(CUSTOM_FIELD_DEFAULTS['magic_number']))
self.assertEqual(response.data['custom_fields']['is_magic'], bool(CUSTOM_FIELD_DEFAULTS['is_magic']))
self.assertEqual(response.data['custom_fields']['magic_date'], CUSTOM_FIELD_DEFAULTS['magic_date'])
self.assertEqual(response.data['custom_fields']['magic_url'], CUSTOM_FIELD_DEFAULTS['magic_url'])
self.assertEqual(response.data['custom_fields']['magic_choice'], self.cf_select_choice1.pk)
class CustomFieldChoiceAPITest(APITestCase): class CustomFieldChoiceAPITest(APITestCase):
def setUp(self): def setUp(self):

View File

@ -5,7 +5,7 @@ from django.db.models import Q
from netaddr.core import AddrFormatError from netaddr.core import AddrFormatError
from dcim.models import Site, Device, Interface from dcim.models import Site, Device, Interface
from extras.filters import CustomFieldFilterSet from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet
from tenancy.filtersets import TenancyFilterSet from tenancy.filtersets import TenancyFilterSet
from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter
from virtualization.models import VirtualMachine from virtualization.models import VirtualMachine
@ -13,7 +13,7 @@ from .constants import *
from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF
class VRFFilter(TenancyFilterSet, CustomFieldFilterSet): class VRFFilter(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
id__in = NumericInFilter( id__in = NumericInFilter(
field_name='id', field_name='id',
lookup_expr='in' lookup_expr='in'
@ -49,7 +49,7 @@ class RIRFilter(NameSlugSearchFilterSet):
fields = ['name', 'slug', 'is_private'] fields = ['name', 'slug', 'is_private']
class AggregateFilter(CustomFieldFilterSet): class AggregateFilter(CustomFieldFilterSet, CreatedUpdatedFilterSet):
id__in = NumericInFilter( id__in = NumericInFilter(
field_name='id', field_name='id',
lookup_expr='in' lookup_expr='in'
@ -110,7 +110,7 @@ class RoleFilter(NameSlugSearchFilterSet):
fields = ['id', 'name', 'slug'] fields = ['id', 'name', 'slug']
class PrefixFilter(TenancyFilterSet, CustomFieldFilterSet): class PrefixFilter(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
id__in = NumericInFilter( id__in = NumericInFilter(
field_name='id', field_name='id',
lookup_expr='in' lookup_expr='in'
@ -247,7 +247,7 @@ class PrefixFilter(TenancyFilterSet, CustomFieldFilterSet):
return queryset.filter(prefix__net_mask_length=value) return queryset.filter(prefix__net_mask_length=value)
class IPAddressFilter(TenancyFilterSet, CustomFieldFilterSet): class IPAddressFilter(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
id__in = NumericInFilter( id__in = NumericInFilter(
field_name='id', field_name='id',
lookup_expr='in' lookup_expr='in'
@ -384,7 +384,7 @@ class VLANGroupFilter(NameSlugSearchFilterSet):
fields = ['id', 'name', 'slug'] fields = ['id', 'name', 'slug']
class VLANFilter(TenancyFilterSet, CustomFieldFilterSet): class VLANFilter(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
id__in = NumericInFilter( id__in = NumericInFilter(
field_name='id', field_name='id',
lookup_expr='in' lookup_expr='in'
@ -444,7 +444,7 @@ class VLANFilter(TenancyFilterSet, CustomFieldFilterSet):
return queryset.filter(qs_filter) return queryset.filter(qs_filter)
class ServiceFilter(django_filters.FilterSet): class ServiceFilter(CreatedUpdatedFilterSet):
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',
label='Search', label='Search',

View File

@ -240,7 +240,7 @@ class RoleForm(BootstrapMixin, forms.ModelForm):
class Meta: class Meta:
model = Role model = Role
fields = [ fields = [
'name', 'slug', 'name', 'slug', 'weight',
] ]
@ -1250,6 +1250,10 @@ class VLANFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
# #
class ServiceForm(BootstrapMixin, CustomFieldForm): class ServiceForm(BootstrapMixin, CustomFieldForm):
port = forms.IntegerField(
min_value=1,
max_value=65535
)
tags = TagField( tags = TagField(
required=False required=False
) )

View File

@ -14,6 +14,6 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='ipaddress', model_name='ipaddress',
name='dns_name', name='dns_name',
field=models.CharField(blank=True, max_length=255, validators=[django.core.validators.RegexValidator(code='invalid', message='Only alphanumeric characters, hyphens, and periods are allowed in DNS names', regex='^[0-9A-Za-z.-]+$')]), field=models.CharField(blank=True, max_length=255, validators=[django.core.validators.RegexValidator(code='invalid', message='Only alphanumeric characters, hyphens, periods, and underscores are allowed in DNS names', regex='^[0-9A-Za-z._-]+$')]),
), ),
] ]

View File

@ -85,7 +85,11 @@ IPADDRESS_LINK = """
""" """
IPADDRESS_ASSIGN_LINK = """ IPADDRESS_ASSIGN_LINK = """
<a href="{% url 'ipam:ipaddress_edit' pk=record.pk %}?interface={{ request.GET.interface }}&return_url={{ request.GET.return_url }}">{{ record }}</a> {% if request.GET %}
<a href="{% url 'ipam:ipaddress_edit' pk=record.pk %}?interface={{ request.GET.interface }}&return_url={{ request.GET.return_url }}">{{ record }}</a>
{% else %}
<a href="{% url 'ipam:ipaddress_edit' pk=record.pk %}?interface={{ record.interface.pk }}&return_url={{ request.path }}">{{ record }}</a>
{% endif %}
""" """
IPADDRESS_PARENT = """ IPADDRESS_PARENT = """
@ -292,7 +296,7 @@ class RoleTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = Role model = Role
fields = ('pk', 'name', 'prefix_count', 'vlan_count', 'slug', 'actions') fields = ('pk', 'name', 'prefix_count', 'vlan_count', 'slug', 'weight', 'actions')
# #

View File

@ -2,7 +2,7 @@ from django.core.validators import RegexValidator
DNSValidator = RegexValidator( DNSValidator = RegexValidator(
regex='^[0-9A-Za-z.-]+$', regex='^[0-9A-Za-z._-]+$',
message='Only alphanumeric characters, hyphens, and periods are allowed in DNS names', message='Only alphanumeric characters, hyphens, periods, and underscores are allowed in DNS names',
code='invalid' code='invalid'
) )

View File

@ -12,7 +12,7 @@ from django.core.exceptions import ImproperlyConfigured
# Environment setup # Environment setup
# #
VERSION = '2.6.7' VERSION = '2.6.9'
# Hostname # Hostname
HOSTNAME = platform.node() HOSTNAME = platform.node()

View File

@ -116,6 +116,23 @@ SEARCH_TYPES = OrderedDict((
'table': PowerFeedTable, 'table': PowerFeedTable,
'url': 'dcim:powerfeed_list', 'url': 'dcim:powerfeed_list',
}), }),
# Virtualization
('cluster', {
'permission': 'virtualization.view_cluster',
'queryset': Cluster.objects.prefetch_related('type', 'group'),
'filter': ClusterFilter,
'table': ClusterTable,
'url': 'virtualization:cluster_list',
}),
('virtualmachine', {
'permission': 'virtualization.view_virtualmachine',
'queryset': VirtualMachine.objects.prefetch_related(
'cluster', 'tenant', 'platform', 'primary_ip4', 'primary_ip6',
),
'filter': VirtualMachineFilter,
'table': VirtualMachineDetailTable,
'url': 'virtualization:virtualmachine_list',
}),
# IPAM # IPAM
('vrf', { ('vrf', {
'permission': 'ipam.view_vrf', 'permission': 'ipam.view_vrf',
@ -168,23 +185,6 @@ SEARCH_TYPES = OrderedDict((
'table': TenantTable, 'table': TenantTable,
'url': 'tenancy:tenant_list', 'url': 'tenancy:tenant_list',
}), }),
# Virtualization
('cluster', {
'permission': 'virtualization.view_cluster',
'queryset': Cluster.objects.prefetch_related('type', 'group'),
'filter': ClusterFilter,
'table': ClusterTable,
'url': 'virtualization:cluster_list',
}),
('virtualmachine', {
'permission': 'virtualization.view_virtualmachine',
'queryset': VirtualMachine.objects.prefetch_related(
'cluster', 'tenant', 'platform', 'primary_ip4', 'primary_ip6',
),
'filter': VirtualMachineFilter,
'table': VirtualMachineDetailTable,
'url': 'virtualization:virtualmachine_list',
}),
)) ))

View File

@ -457,6 +457,14 @@ table.report th a {
width: 80px; width: 80px;
border: 1px solid grey; border: 1px solid grey;
} }
.inline-color-block {
display: inline-block;
width: 1.5em;
height: 1.5em;
border: 1px solid grey;
border-radius: .25em;
vertical-align: middle;
}
.text-nowrap { .text-nowrap {
white-space: nowrap; white-space: nowrap;
} }

View File

@ -2,7 +2,7 @@ import django_filters
from django.db.models import Q from django.db.models import Q
from dcim.models import Device from dcim.models import Device
from extras.filters import CustomFieldFilterSet from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet
from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter
from .models import Secret, SecretRole from .models import Secret, SecretRole
@ -14,7 +14,7 @@ class SecretRoleFilter(NameSlugSearchFilterSet):
fields = ['id', 'name', 'slug'] fields = ['id', 'name', 'slug']
class SecretFilter(CustomFieldFilterSet): class SecretFilter(CustomFieldFilterSet, CreatedUpdatedFilterSet):
id__in = NumericInFilter( id__in = NumericInFilter(
field_name='id', field_name='id',
lookup_expr='in' lookup_expr='in'

View File

@ -48,6 +48,9 @@
<td class="text-nowrap"> <td class="text-nowrap">
{% if iface.cable %} {% if iface.cable %}
<a href="{{ iface.cable.get_absolute_url }}">{{ iface.cable }}</a> <a href="{{ iface.cable.get_absolute_url }}">{{ iface.cable }}</a>
{% if iface.cable.color %}
<span class="inline-color-block" style="background-color: #{{ iface.cable.color }}">&nbsp;</span>
{% endif %}
<a href="{% url 'dcim:interface_trace' pk=iface.pk %}" class="btn btn-primary btn-xs" title="Trace"> <a href="{% url 'dcim:interface_trace' pk=iface.pk %}" class="btn btn-primary btn-xs" title="Trace">
<i class="fa fa-share-alt" aria-hidden="true"></i> <i class="fa fa-share-alt" aria-hidden="true"></i>
</a> </a>

View File

@ -121,6 +121,18 @@
</tr> </tr>
</table> </table>
</div> </div>
<div class="panel panel-default">
<div class="panel-heading">
<strong>Comments</strong>
</div>
<div class="panel-body rendered-markdown">
{% if powerfeed.comments %}
{{ powerfeed.comments|gfm }}
{% else %}
<span class="text-muted">None</span>
{% endif %}
</div>
</div>
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<div class="panel panel-default"> <div class="panel panel-default">

View File

@ -251,25 +251,28 @@
<div class="panel-heading"> <div class="panel-heading">
<strong>Rack Groups</strong> <strong>Rack Groups</strong>
</div> </div>
{% if rack_groups %} <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><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"> <i class="fa fa-eye"></i>
<i class="fa fa-eye"></i> </a>
</a> </td>
</td> </tr>
</tr> {% endfor %}
{% endfor %} <tr>
</table> <td><i class="fa fa-fw fa-folder-o"></i> All racks</td>
{% else %} <td>{{ stats.rack_count }}</td>
<div class="panel-body text-muted"> <td class="text-right noprint">
None <a href="{% url 'dcim:rack_elevation_list' %}?site={{ site.slug }}" class="btn btn-xs btn-primary" title="View elevations">
</div> <i class="fa fa-eye"></i>
{% endif %} </a>
</td>
</tr>
</table>
</div> </div>
<div class="panel panel-default"> <div class="panel panel-default">
<div class="panel-heading"> <div class="panel-heading">

View File

@ -12,9 +12,11 @@
<li{% ifequal active_tab "profile" %} class="active"{% endifequal %}> <li{% ifequal active_tab "profile" %} class="active"{% endifequal %}>
<a href="{% url 'user:profile' %}">Profile</a> <a href="{% url 'user:profile' %}">Profile</a>
</li> </li>
<li{% ifequal active_tab "change_password" %} class="active"{% endifequal %}> {% if not request.user.ldap_username %}
<a href="{% url 'user:change_password' %}">Change Password</a> <li{% ifequal active_tab "change_password" %} class="active"{% endifequal %}>
</li> <a href="{% url 'user:change_password' %}">Change Password</a>
</li>
{% endif %}
<li{% ifequal active_tab "api_tokens" %} class="active"{% endifequal %}> <li{% ifequal active_tab "api_tokens" %} class="active"{% endifequal %}>
<a href="{% url 'user:token_list' %}">API Tokens</a> <a href="{% url 'user:token_list' %}">API Tokens</a>
</li> </li>

View File

@ -10,6 +10,7 @@
<div class="panel panel-{% if token.is_expired %}danger{% else %}default{% endif %}"> <div class="panel panel-{% if token.is_expired %}danger{% else %}default{% endif %}">
<div class="panel-heading"> <div class="panel-heading">
<div class="pull-right noprint"> <div class="pull-right noprint">
<a class="btn btn-xs btn-success copy-token" data-clipboard-target="#token_{{ token.pk }}">Copy</a>
{% if perms.users.change_token %} {% if perms.users.change_token %}
<a href="{% url 'user:token_edit' pk=token.pk %}" class="btn btn-xs btn-warning">Edit</a> <a href="{% url 'user:token_edit' pk=token.pk %}" class="btn btn-xs btn-warning">Edit</a>
{% endif %} {% endif %}
@ -17,7 +18,8 @@
<a href="{% url 'user:token_delete' pk=token.pk %}" class="btn btn-xs btn-danger">Delete</a> <a href="{% url 'user:token_delete' pk=token.pk %}" class="btn btn-xs btn-danger">Delete</a>
{% endif %} {% endif %}
</div> </div>
<i class="fa fa-key"></i> {{ token.key }} <i class="fa fa-key"></i>
<span id="token_{{ token.pk }}">{{ token.key }}</span>
{% if token.is_expired %} {% if token.is_expired %}
<span class="label label-danger">Expired</span> <span class="label label-danger">Expired</span>
{% endif %} {% endif %}
@ -66,3 +68,9 @@
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
{% block javascript %}
<script type="text/javascript">
new ClipboardJS('.copy-token');
</script>
{% endblock %}

View File

@ -1,7 +1,7 @@
import django_filters import django_filters
from django.db.models import Q from django.db.models import Q
from extras.filters import CustomFieldFilterSet from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet
from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter from utilities.filters import NameSlugSearchFilterSet, NumericInFilter, TagFilter
from .models import Tenant, TenantGroup from .models import Tenant, TenantGroup
@ -13,7 +13,7 @@ class TenantGroupFilter(NameSlugSearchFilterSet):
fields = ['id', 'name', 'slug'] fields = ['id', 'name', 'slug']
class TenantFilter(CustomFieldFilterSet): class TenantFilter(CustomFieldFilterSet, CreatedUpdatedFilterSet):
id__in = NumericInFilter( id__in = NumericInFilter(
field_name='id', field_name='id',
lookup_expr='in' lookup_expr='in'

View File

@ -95,6 +95,11 @@ class ChangePasswordView(LoginRequiredMixin, View):
template_name = 'users/change_password.html' template_name = 'users/change_password.html'
def get(self, request): def get(self, request):
# LDAP users cannot change their password here
if getattr(request.user, 'ldap_username', None):
messages.warning(request, "LDAP-authenticated user credentials cannot be changed within NetBox.")
return redirect('user:profile')
form = PasswordChangeForm(user=request.user) form = PasswordChangeForm(user=request.user)
return render(request, self.template_name, { return render(request, self.template_name, {

View File

@ -4,7 +4,7 @@ from netaddr import EUI
from netaddr.core import AddrFormatError from netaddr.core import AddrFormatError
from dcim.models import DeviceRole, Interface, Platform, Region, Site from dcim.models import DeviceRole, Interface, Platform, Region, Site
from extras.filters import CustomFieldFilterSet from extras.filters import CustomFieldFilterSet, CreatedUpdatedFilterSet
from tenancy.filtersets import TenancyFilterSet from tenancy.filtersets import TenancyFilterSet
from utilities.filters import ( from utilities.filters import (
MultiValueMACAddressFilter, NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter, MultiValueMACAddressFilter, NameSlugSearchFilterSet, NumericInFilter, TagFilter, TreeNodeMultipleChoiceFilter,
@ -27,7 +27,7 @@ class ClusterGroupFilter(NameSlugSearchFilterSet):
fields = ['id', 'name', 'slug'] fields = ['id', 'name', 'slug']
class ClusterFilter(CustomFieldFilterSet): class ClusterFilter(CustomFieldFilterSet, CreatedUpdatedFilterSet):
id__in = NumericInFilter( id__in = NumericInFilter(
field_name='id', field_name='id',
lookup_expr='in' lookup_expr='in'
@ -81,7 +81,7 @@ class ClusterFilter(CustomFieldFilterSet):
) )
class VirtualMachineFilter(TenancyFilterSet, CustomFieldFilterSet): class VirtualMachineFilter(TenancyFilterSet, CustomFieldFilterSet, CreatedUpdatedFilterSet):
id__in = NumericInFilter( id__in = NumericInFilter(
field_name='id', field_name='id',
lookup_expr='in' lookup_expr='in'

View File

@ -9,6 +9,24 @@
exec 1>&2 exec 1>&2
EXIT=0
RED='\033[0;31m'
NOCOLOR='\033[0m'
echo "Validating PEP8 compliance..." echo "Validating PEP8 compliance..."
pycodestyle --ignore=W504,E501 netbox/ pycodestyle --ignore=W504,E501 netbox/
if [ $? != 0 ]; then
EXIT=1
fi
echo "Checking for missing migrations..."
python netbox/manage.py makemigrations --dry-run --check
if [ $? != 0 ]; then
EXIT=1
fi
if [ $EXIT != 0 ]; then
printf "${RED}COMMIT FAILED${NOCOLOR}\n"
fi
exit $EXIT