Merge pull request #2387 from digitalocean/develop

Release v2.4.4
This commit is contained in:
Jeremy Stretch 2018-08-22 11:53:56 -04:00 committed by GitHub
commit bcf22831e2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 1817 additions and 50 deletions

1654
CHANGELOG.md Normal file

File diff suppressed because it is too large Load Diff

View File

@ -12,5 +12,5 @@ While NetBox has many configuration settings, only a few of them must be defined
Configuration settings may be changed at any time. However, the NetBox service must be restarted before the changes will take effect: Configuration settings may be changed at any time. However, the NetBox service must be restarted before the changes will take effect:
```no-highlight ```no-highlight
# sudo supervsiorctl restart netbox # sudo supervisorctl restart netbox
``` ```

View File

@ -48,9 +48,9 @@ Close the release milestone on GitHub. Ensure that there are no remaining open i
Ensure that continuous integration testing on the `develop` branch is completing successfully. Ensure that continuous integration testing on the `develop` branch is completing successfully.
## Update VERSION ## Update Version and Changelog
Update the `VERSION` constant in `settings.py` to the new release. Update the `VERSION` constant in `settings.py` to the new release version and add the current date to the release notes in `CHANGELOG.md`.
## Submit a Pull Request ## Submit a Pull Request

View File

@ -56,7 +56,7 @@ To enable SSL, consider this guide on [securing nginx with Let's Encrypt](https:
## Option B: Apache ## Option B: Apache
```no-highlight ```no-highlight
# apt-get install -y apache2 # apt-get install -y apache2 libapache2-mod-wsgi-py3
``` ```
Once Apache is installed, proceed with the following configuration (Be sure to modify the `ServerName` appropriately): Once Apache is installed, proceed with the following configuration (Be sure to modify the `ServerName` appropriately):

View File

@ -93,6 +93,11 @@ IFACE_FF_STACKWISE_PLUS = 5050
IFACE_FF_FLEXSTACK = 5100 IFACE_FF_FLEXSTACK = 5100
IFACE_FF_FLEXSTACK_PLUS = 5150 IFACE_FF_FLEXSTACK_PLUS = 5150
IFACE_FF_JUNIPER_VCP = 5200 IFACE_FF_JUNIPER_VCP = 5200
IFACE_FF_SUMMITSTACK = 5300
IFACE_FF_SUMMITSTACK128 = 5310
IFACE_FF_SUMMITSTACK256 = 5320
IFACE_FF_SUMMITSTACK512 = 5330
# Other # Other
IFACE_FF_OTHER = 32767 IFACE_FF_OTHER = 32767
@ -168,6 +173,10 @@ IFACE_FF_CHOICES = [
[IFACE_FF_FLEXSTACK, 'Cisco FlexStack'], [IFACE_FF_FLEXSTACK, 'Cisco FlexStack'],
[IFACE_FF_FLEXSTACK_PLUS, 'Cisco FlexStack Plus'], [IFACE_FF_FLEXSTACK_PLUS, 'Cisco FlexStack Plus'],
[IFACE_FF_JUNIPER_VCP, 'Juniper VCP'], [IFACE_FF_JUNIPER_VCP, 'Juniper VCP'],
[IFACE_FF_SUMMITSTACK, 'Extreme SummitStack'],
[IFACE_FF_SUMMITSTACK128, 'Extreme SummitStack-128'],
[IFACE_FF_SUMMITSTACK256, 'Extreme SummitStack-256'],
[IFACE_FF_SUMMITSTACK512, 'Extreme SummitStack-512'],
] ]
], ],
[ [

View File

@ -112,6 +112,10 @@ class SiteFilter(CustomFieldFilterSet, django_filters.FilterSet):
class RackGroupFilter(django_filters.FilterSet): class RackGroupFilter(django_filters.FilterSet):
q = django_filters.CharFilter(
method='search',
label='Search',
)
site_id = django_filters.ModelMultipleChoiceFilter( site_id = django_filters.ModelMultipleChoiceFilter(
queryset=Site.objects.all(), queryset=Site.objects.all(),
label='Site (ID)', label='Site (ID)',
@ -127,6 +131,15 @@ class RackGroupFilter(django_filters.FilterSet):
model = RackGroup model = RackGroup
fields = ['site_id', 'name', 'slug'] fields = ['site_id', 'name', 'slug']
def search(self, queryset, name, value):
if not value.strip():
return queryset
qs_filter = (
Q(name__icontains=value) |
Q(slug__icontains=value)
)
return queryset.filter(qs_filter)
class RackRoleFilter(django_filters.FilterSet): class RackRoleFilter(django_filters.FilterSet):

View File

@ -0,0 +1,29 @@
# Generated by Django 2.0.8 on 2018-08-22 14:23
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0061_platform_napalm_args'),
]
operations = [
migrations.AlterField(
model_name='interface',
name='mtu',
field=models.PositiveIntegerField(blank=True, null=True, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(65536)], verbose_name='MTU'),
),
migrations.AlterField(
model_name='interface',
name='form_factor',
field=models.PositiveSmallIntegerField(choices=[['Virtual interfaces', [[0, 'Virtual'], [200, 'Link Aggregation Group (LAG)']]], ['Ethernet (fixed)', [[800, '100BASE-TX (10/100ME)'], [1000, '1000BASE-T (1GE)'], [1150, '10GBASE-T (10GE)'], [1170, '10GBASE-CX4 (10GE)']]], ['Ethernet (modular)', [[1050, 'GBIC (1GE)'], [1100, 'SFP (1GE)'], [1200, 'SFP+ (10GE)'], [1300, 'XFP (10GE)'], [1310, 'XENPAK (10GE)'], [1320, 'X2 (10GE)'], [1350, 'SFP28 (25GE)'], [1400, 'QSFP+ (40GE)'], [1500, 'CFP (100GE)'], [1510, 'CFP2 (100GE)'], [1520, 'CFP4 (100GE)'], [1550, 'Cisco CPAK (100GE)'], [1600, 'QSFP28 (100GE)']]], ['Wireless', [[2600, 'IEEE 802.11a'], [2610, 'IEEE 802.11b/g'], [2620, 'IEEE 802.11n'], [2630, 'IEEE 802.11ac'], [2640, 'IEEE 802.11ad']]], ['FibreChannel', [[3010, 'SFP (1GFC)'], [3020, 'SFP (2GFC)'], [3040, 'SFP (4GFC)'], [3080, 'SFP+ (8GFC)'], [3160, 'SFP+ (16GFC)']]], ['Serial', [[4000, 'T1 (1.544 Mbps)'], [4010, 'E1 (2.048 Mbps)'], [4040, 'T3 (45 Mbps)'], [4050, 'E3 (34 Mbps)']]], ['Stacking', [[5000, 'Cisco StackWise'], [5050, 'Cisco StackWise Plus'], [5100, 'Cisco FlexStack'], [5150, 'Cisco FlexStack Plus'], [5200, 'Juniper VCP'], [5300, 'Extreme SummitStack'], [5310, 'Extreme SummitStack-128'], [5320, 'Extreme SummitStack-256'], [5330, 'Extreme SummitStack-512']]], ['Other', [[32767, 'Other']]]], default=1200),
),
migrations.AlterField(
model_name='interfacetemplate',
name='form_factor',
field=models.PositiveSmallIntegerField(choices=[['Virtual interfaces', [[0, 'Virtual'], [200, 'Link Aggregation Group (LAG)']]], ['Ethernet (fixed)', [[800, '100BASE-TX (10/100ME)'], [1000, '1000BASE-T (1GE)'], [1150, '10GBASE-T (10GE)'], [1170, '10GBASE-CX4 (10GE)']]], ['Ethernet (modular)', [[1050, 'GBIC (1GE)'], [1100, 'SFP (1GE)'], [1200, 'SFP+ (10GE)'], [1300, 'XFP (10GE)'], [1310, 'XENPAK (10GE)'], [1320, 'X2 (10GE)'], [1350, 'SFP28 (25GE)'], [1400, 'QSFP+ (40GE)'], [1500, 'CFP (100GE)'], [1510, 'CFP2 (100GE)'], [1520, 'CFP4 (100GE)'], [1550, 'Cisco CPAK (100GE)'], [1600, 'QSFP28 (100GE)']]], ['Wireless', [[2600, 'IEEE 802.11a'], [2610, 'IEEE 802.11b/g'], [2620, 'IEEE 802.11n'], [2630, 'IEEE 802.11ac'], [2640, 'IEEE 802.11ad']]], ['FibreChannel', [[3010, 'SFP (1GFC)'], [3020, 'SFP (2GFC)'], [3040, 'SFP (4GFC)'], [3080, 'SFP+ (8GFC)'], [3160, 'SFP+ (16GFC)']]], ['Serial', [[4000, 'T1 (1.544 Mbps)'], [4010, 'E1 (2.048 Mbps)'], [4040, 'T3 (45 Mbps)'], [4050, 'E3 (34 Mbps)']]], ['Stacking', [[5000, 'Cisco StackWise'], [5050, 'Cisco StackWise Plus'], [5100, 'Cisco FlexStack'], [5150, 'Cisco FlexStack Plus'], [5200, 'Juniper VCP'], [5300, 'Extreme SummitStack'], [5310, 'Extreme SummitStack-128'], [5320, 'Extreme SummitStack-256'], [5330, 'Extreme SummitStack-512']]], ['Other', [[32767, 'Other']]]], default=1200),
),
]

View File

@ -1809,9 +1809,10 @@ class Interface(ComponentModel):
blank=True, blank=True,
verbose_name='MAC Address' verbose_name='MAC Address'
) )
mtu = models.PositiveSmallIntegerField( mtu = models.PositiveIntegerField(
blank=True, blank=True,
null=True, null=True,
validators=[MinValueValidator(1), MaxValueValidator(65536)],
verbose_name='MTU' verbose_name='MTU'
) )
mgmt_only = models.BooleanField( mgmt_only = models.BooleanField(
@ -2071,6 +2072,7 @@ class InterfaceConnection(models.Model):
(self.interface_a, self.interface_b), (self.interface_a, self.interface_b),
(self.interface_b, self.interface_a), (self.interface_b, self.interface_a),
) )
for interface, peer_interface in interfaces: for interface, peer_interface in interfaces:
if action == OBJECTCHANGE_ACTION_DELETE: if action == OBJECTCHANGE_ACTION_DELETE:
connection_data = { connection_data = {
@ -2081,11 +2083,17 @@ class InterfaceConnection(models.Model):
'connected_interface': peer_interface.pk, 'connected_interface': peer_interface.pk,
'connection_status': self.connection_status 'connection_status': self.connection_status
} }
try:
parent_obj = interface.parent
except ObjectDoesNotExist:
parent_obj = None
ObjectChange( ObjectChange(
user=user, user=user,
request_id=request_id, request_id=request_id,
changed_object=interface, changed_object=interface,
related_object=interface.parent, related_object=parent_obj,
action=OBJECTCHANGE_ACTION_UPDATE, action=OBJECTCHANGE_ACTION_UPDATE,
object_data=serialize_object(interface, extra=connection_data) object_data=serialize_object(interface, extra=connection_data)
).save() ).save()

View File

@ -4,12 +4,9 @@ from django import forms
from django.contrib import admin from django.contrib import admin
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from netbox.admin import admin_site
from utilities.forms import LaxURLField from utilities.forms import LaxURLField
from .constants import OBJECTCHANGE_ACTION_CREATE, OBJECTCHANGE_ACTION_DELETE, OBJECTCHANGE_ACTION_UPDATE from .models import CustomField, CustomFieldChoice, Graph, ExportTemplate, TopologyMap, UserAction, Webhook
from .models import (
ConfigContext, CustomField, CustomFieldChoice, Graph, ExportTemplate, ObjectChange, TopologyMap, UserAction,
Webhook,
)
def order_content_types(field): def order_content_types(field):
@ -39,7 +36,7 @@ class WebhookForm(forms.ModelForm):
order_content_types(self.fields['obj_type']) order_content_types(self.fields['obj_type'])
@admin.register(Webhook) @admin.register(Webhook, site=admin_site)
class WebhookAdmin(admin.ModelAdmin): class WebhookAdmin(admin.ModelAdmin):
list_display = [ list_display = [
'name', 'models', 'payload_url', 'http_content_type', 'enabled', 'type_create', 'type_update', 'name', 'models', 'payload_url', 'http_content_type', 'enabled', 'type_create', 'type_update',
@ -72,7 +69,7 @@ class CustomFieldChoiceAdmin(admin.TabularInline):
extra = 5 extra = 5
@admin.register(CustomField) @admin.register(CustomField, site=admin_site)
class CustomFieldAdmin(admin.ModelAdmin): class CustomFieldAdmin(admin.ModelAdmin):
inlines = [CustomFieldChoiceAdmin] inlines = [CustomFieldChoiceAdmin]
list_display = ['name', 'models', 'type', 'required', 'filter_logic', 'default', 'weight', 'description'] list_display = ['name', 'models', 'type', 'required', 'filter_logic', 'default', 'weight', 'description']
@ -86,7 +83,7 @@ class CustomFieldAdmin(admin.ModelAdmin):
# Graphs # Graphs
# #
@admin.register(Graph) @admin.register(Graph, site=admin_site)
class GraphAdmin(admin.ModelAdmin): class GraphAdmin(admin.ModelAdmin):
list_display = ['name', 'type', 'weight', 'source'] list_display = ['name', 'type', 'weight', 'source']
@ -109,7 +106,7 @@ class ExportTemplateForm(forms.ModelForm):
self.fields['content_type'].choices.insert(0, ('', '---------')) self.fields['content_type'].choices.insert(0, ('', '---------'))
@admin.register(ExportTemplate) @admin.register(ExportTemplate, site=admin_site)
class ExportTemplateAdmin(admin.ModelAdmin): class ExportTemplateAdmin(admin.ModelAdmin):
list_display = ['name', 'content_type', 'description', 'mime_type', 'file_extension'] list_display = ['name', 'content_type', 'description', 'mime_type', 'file_extension']
form = ExportTemplateForm form = ExportTemplateForm
@ -119,7 +116,7 @@ class ExportTemplateAdmin(admin.ModelAdmin):
# Topology maps # Topology maps
# #
@admin.register(TopologyMap) @admin.register(TopologyMap, site=admin_site)
class TopologyMapAdmin(admin.ModelAdmin): class TopologyMapAdmin(admin.ModelAdmin):
list_display = ['name', 'slug', 'site'] list_display = ['name', 'slug', 'site']
prepopulated_fields = { prepopulated_fields = {
@ -131,7 +128,7 @@ class TopologyMapAdmin(admin.ModelAdmin):
# User actions # User actions
# #
@admin.register(UserAction) @admin.register(UserAction, site=admin_site)
class UserActionAdmin(admin.ModelAdmin): class UserActionAdmin(admin.ModelAdmin):
actions = None actions = None
list_display = ['user', 'action', 'content_type', 'object_id', '_message'] list_display = ['user', 'action', 'content_type', 'object_id', '_message']

30
netbox/netbox/admin.py Normal file
View File

@ -0,0 +1,30 @@
from django.conf import settings
from django.contrib.admin import AdminSite
from django.contrib.auth.models import Group, User
from django.contrib.auth.admin import GroupAdmin, UserAdmin
from taggit.admin import TagAdmin
from taggit.models import Tag
class NetBoxAdminSite(AdminSite):
"""
Custom admin site
"""
site_header = 'NetBox Administration'
site_title = 'NetBox'
site_url = '/{}'.format(settings.BASE_PATH)
admin_site = NetBoxAdminSite(name='admin')
# Register external models
admin_site.register(Group, GroupAdmin)
admin_site.register(User, UserAdmin)
admin_site.register(Tag, TagAdmin)
# Modify the template to include an RQ link if django_rq is installed (see RQ_SHOW_ADMIN_LINK)
try:
import django_rq
admin_site.index_template = 'django_rq/index.html'
except ImportError:
pass

View File

@ -13,6 +13,7 @@ OBJ_TYPE_CHOICES = (
('DCIM', ( ('DCIM', (
('site', 'Sites'), ('site', 'Sites'),
('rack', 'Racks'), ('rack', 'Racks'),
('rackgroup', 'Rack Groups'),
('devicetype', 'Device types'), ('devicetype', 'Device types'),
('device', 'Devices'), ('device', 'Devices'),
('virtualchassis', 'Virtual Chassis'), ('virtualchassis', 'Virtual Chassis'),

View File

@ -22,7 +22,7 @@ if sys.version_info[0] < 3:
DeprecationWarning DeprecationWarning
) )
VERSION = '2.4.3' VERSION = '2.4.4'
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
@ -272,7 +272,6 @@ RQ_QUEUES = {
'DEFAULT_TIMEOUT': REDIS_DEFAULT_TIMEOUT, 'DEFAULT_TIMEOUT': REDIS_DEFAULT_TIMEOUT,
} }
} }
RQ_SHOW_ADMIN_LINK = True
# drf_yasg settings for Swagger # drf_yasg settings for Swagger
SWAGGER_SETTINGS = { SWAGGER_SETTINGS = {

View File

@ -2,13 +2,13 @@ from __future__ import unicode_literals
from django.conf import settings from django.conf import settings
from django.conf.urls import include, url from django.conf.urls import include, url
from django.contrib import admin
from django.views.static import serve from django.views.static import serve
from drf_yasg.views import get_schema_view from drf_yasg.views import get_schema_view
from drf_yasg import openapi from drf_yasg import openapi
from netbox.views import APIRootView, HomeView, SearchView from netbox.views import APIRootView, HomeView, SearchView
from users.views import LoginView, LogoutView from users.views import LoginView, LogoutView
from .admin import admin_site
schema_view = get_schema_view( schema_view = get_schema_view(
openapi.Info( openapi.Info(
@ -60,7 +60,7 @@ _patterns = [
url(r'^media/(?P<path>.*)$', serve, {'document_root': settings.MEDIA_ROOT}), url(r'^media/(?P<path>.*)$', serve, {'document_root': settings.MEDIA_ROOT}),
# Admin # Admin
url(r'^admin/', admin.site.urls), url(r'^admin/', admin_site.urls),
] ]
@ -69,7 +69,6 @@ if settings.WEBHOOKS_ENABLED:
url(r'^admin/webhook-backend-status/', include('django_rq.urls')), url(r'^admin/webhook-backend-status/', include('django_rq.urls')),
] ]
if settings.DEBUG: if settings.DEBUG:
import debug_toolbar import debug_toolbar
_patterns += [ _patterns += [

View File

@ -12,9 +12,16 @@ from rest_framework.views import APIView
from circuits.filters import CircuitFilter, ProviderFilter from circuits.filters import CircuitFilter, ProviderFilter
from circuits.models import Circuit, Provider from circuits.models import Circuit, Provider
from circuits.tables import CircuitTable, ProviderTable from circuits.tables import CircuitTable, ProviderTable
from dcim.filters import DeviceFilter, DeviceTypeFilter, RackFilter, SiteFilter, VirtualChassisFilter from dcim.filters import (
from dcim.models import ConsolePort, Device, DeviceType, InterfaceConnection, PowerPort, Rack, Site, VirtualChassis DeviceFilter, DeviceTypeFilter, RackFilter, RackGroupFilter, SiteFilter, VirtualChassisFilter
from dcim.tables import DeviceDetailTable, DeviceTypeTable, RackTable, SiteTable, VirtualChassisTable )
from dcim.models import (
ConsolePort, Device, DeviceType, InterfaceConnection, PowerPort, Rack, RackGroup, Site,
VirtualChassis
)
from dcim.tables import (
DeviceDetailTable, DeviceTypeTable, RackTable, RackGroupTable, SiteTable, VirtualChassisTable
)
from extras.models import ObjectChange, ReportResult, TopologyMap from extras.models import ObjectChange, ReportResult, TopologyMap
from ipam.filters import AggregateFilter, IPAddressFilter, PrefixFilter, VLANFilter, VRFFilter from ipam.filters import AggregateFilter, IPAddressFilter, PrefixFilter, VLANFilter, VRFFilter
from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF
@ -58,6 +65,12 @@ SEARCH_TYPES = OrderedDict((
'table': RackTable, 'table': RackTable,
'url': 'dcim:rack_list', 'url': 'dcim:rack_list',
}), }),
('rackgroup', {
'queryset': RackGroup.objects.select_related('site').annotate(rack_count=Count('racks')),
'filter': RackGroupFilter,
'table': RackGroupTable,
'url': 'dcim:rackgroup_list',
}),
('devicetype', { ('devicetype', {
'queryset': DeviceType.objects.select_related('manufacturer').annotate(instance_count=Count('instances')), 'queryset': DeviceType.objects.select_related('manufacturer').annotate(instance_count=Count('instances')),
'filter': DeviceTypeFilter, 'filter': DeviceTypeFilter,

View File

@ -3,11 +3,12 @@ from __future__ import unicode_literals
from django.contrib import admin, messages from django.contrib import admin, messages
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from netbox.admin import admin_site
from .forms import ActivateUserKeyForm from .forms import ActivateUserKeyForm
from .models import UserKey from .models import UserKey
@admin.register(UserKey) @admin.register(UserKey, site=admin_site)
class UserKeyAdmin(admin.ModelAdmin): class UserKeyAdmin(admin.ModelAdmin):
actions = ['activate_selected'] actions = ['activate_selected']
list_display = ['user', 'is_filled', 'is_active', 'created'] list_display = ['user', 'is_filled', 'is_active', 'created']

View File

@ -54,7 +54,9 @@
<a href="{% url 'dcim:device' pk=device.pk %}">Device</a> <a href="{% url 'dcim:device' pk=device.pk %}">Device</a>
</li> </li>
<li role="presentation"{% if active_tab == 'inventory' %} class="active"{% endif %}> <li role="presentation"{% if active_tab == 'inventory' %} class="active"{% endif %}>
<a href="{% url 'dcim:device_inventory' pk=device.pk %}">Inventory</a> <a href="{% url 'dcim:device_inventory' pk=device.pk %}">
Inventory <span class="badge">{{ device.inventory_items.count }}</span>
</a>
</li> </li>
{% if perms.dcim.napalm_read %} {% if perms.dcim.napalm_read %}
{% if device.status != 1 %} {% if device.status != 1 %}
@ -445,7 +447,7 @@
<div class="col-md-12"> <div class="col-md-12">
{% if device_bays or device.device_type.is_parent_device %} {% if device_bays or device.device_type.is_parent_device %}
{% if perms.dcim.delete_devicebay %} {% if perms.dcim.delete_devicebay %}
<form method="post" action="{% url 'dcim:devicebay_bulk_delete' pk=device.pk %}"> <form method="post">
{% csrf_token %} {% csrf_token %}
{% endif %} {% endif %}
<div class="panel panel-default"> <div class="panel panel-default">
@ -481,7 +483,7 @@
</button> </button>
{% endif %} {% endif %}
{% if device_bays and perms.dcim.delete_devicebay %} {% if device_bays and perms.dcim.delete_devicebay %}
<button type="submit" class="btn btn-danger btn-xs"> <button type="submit" formaction="{% url 'dcim:devicebay_bulk_delete' pk=device.pk %}?return_url={{ device.get_absolute_url }}" class="btn btn-danger btn-xs">
<span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete selected <span class="glyphicon glyphicon-trash" aria-hidden="true"></span> Delete selected
</button> </button>
{% endif %} {% endif %}

View File

@ -17,12 +17,12 @@
</div> </div>
<div class="pull-right"> <div class="pull-right">
{% if perms.dcim.change_interface %} {% if perms.dcim.change_interface %}
<a href="{% url 'dcim:interface_edit' pk=interface.pk %}" class="btn btn-warning"> <a href="{% if interface.device %}{% url 'dcim:interface_edit' pk=interface.pk %}{% else %}{% url 'virtualization:interface_edit' pk=interface.pk %}{% endif %}" class="btn btn-warning">
<span class="fa fa-pencil" aria-hidden="true"></span> Edit this interface <span class="fa fa-pencil" aria-hidden="true"></span> Edit this interface
</a> </a>
{% endif %} {% endif %}
{% if perms.dcim.delete_interface %} {% if perms.dcim.delete_interface %}
<a href="{% url 'dcim:interface_delete' pk=interface.pk %}" class="btn btn-danger"> <a href="{% if interface.device %}{% url 'dcim:interface_delete' pk=interface.pk %}{% else %}{% url 'virtualization:interface_delete' pk=interface.pk %}{% endif %}" class="btn btn-danger">
<span class="fa fa-trash" aria-hidden="true"></span> Delete this interface <span class="fa fa-trash" aria-hidden="true"></span> Delete this interface
</a> </a>
{% endif %} {% endif %}

View File

@ -315,9 +315,9 @@
$('button.toggle-ips').click(function() { $('button.toggle-ips').click(function() {
var selected = $(this).attr('selected'); var selected = $(this).attr('selected');
if (selected) { if (selected) {
$('#interfaces_table tr.ipaddress').hide(); $('#interfaces_table tr.ipaddresses').hide();
} else { } else {
$('#interfaces_table tr.ipaddress').show(); $('#interfaces_table tr.ipaddresses').show();
} }
$(this).attr('selected', !selected); $(this).attr('selected', !selected);
$(this).children('span').toggleClass('glyphicon-check glyphicon-unchecked'); $(this).children('span').toggleClass('glyphicon-check glyphicon-unchecked');

View File

@ -3,6 +3,7 @@ from __future__ import unicode_literals
from django import forms from django import forms
from django.contrib import admin from django.contrib import admin
from netbox.admin import admin_site
from .models import Token from .models import Token
@ -14,7 +15,7 @@ class TokenAdminForm(forms.ModelForm):
model = Token model = Token
@admin.register(Token) @admin.register(Token, site=admin_site)
class TokenAdmin(admin.ModelAdmin): class TokenAdmin(admin.ModelAdmin):
form = TokenAdminForm form = TokenAdminForm
list_display = ['key', 'user', 'created', 'expires', 'write_enabled', 'description'] list_display = ['key', 'user', 'created', 'expires', 'write_enabled', 'description']

View File

@ -108,10 +108,9 @@ class TimeZoneField(Field):
def to_internal_value(self, data): def to_internal_value(self, data):
if not data: if not data:
return "" return ""
try: if data not in pytz.common_timezones:
return pytz.timezone(str(data)) raise ValidationError('Unknown time zone "{}" (see pytz.common_timezones for all options)'.format(data))
except pytz.exceptions.UnknownTimeZoneError: return pytz.timezone(data)
raise ValidationError('Invalid time zone "{}"'.format(data))
class SerializedPKRelatedField(PrimaryKeyRelatedField): class SerializedPKRelatedField(PrimaryKeyRelatedField):

View File

@ -92,6 +92,7 @@ class VirtualMachineIPAddressSerializer(WritableNestedSerializer):
class VirtualMachineSerializer(TaggitSerializer, CustomFieldModelSerializer): class VirtualMachineSerializer(TaggitSerializer, CustomFieldModelSerializer):
status = ChoiceField(choices=VM_STATUS_CHOICES, required=False) status = ChoiceField(choices=VM_STATUS_CHOICES, required=False)
site = NestedSiteSerializer(read_only=True)
cluster = NestedClusterSerializer(required=False, allow_null=True) cluster = NestedClusterSerializer(required=False, allow_null=True)
role = NestedDeviceRoleSerializer(required=False, allow_null=True) role = NestedDeviceRoleSerializer(required=False, allow_null=True)
tenant = NestedTenantSerializer(required=False, allow_null=True) tenant = NestedTenantSerializer(required=False, allow_null=True)
@ -104,8 +105,8 @@ class VirtualMachineSerializer(TaggitSerializer, CustomFieldModelSerializer):
class Meta: class Meta:
model = VirtualMachine model = VirtualMachine
fields = [ fields = [
'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'primary_ip', 'primary_ip4', 'primary_ip6', 'id', 'name', 'status', 'site', 'cluster', 'role', 'tenant', 'platform', 'primary_ip', 'primary_ip4',
'vcpus', 'memory', 'disk', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'primary_ip6', 'vcpus', 'memory', 'disk', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
] ]

View File

@ -45,7 +45,9 @@ class ClusterViewSet(CustomFieldModelViewSet):
# #
class VirtualMachineViewSet(CustomFieldModelViewSet): class VirtualMachineViewSet(CustomFieldModelViewSet):
queryset = VirtualMachine.objects.all() queryset = VirtualMachine.objects.select_related(
'cluster__site', 'role', 'tenant', 'platform', 'primary_ip4', 'primary_ip6'
)
filter_class = filters.VirtualMachineFilter filter_class = filters.VirtualMachineFilter
def get_serializer_class(self): def get_serializer_class(self):

View File

@ -2,6 +2,7 @@ from __future__ import unicode_literals
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.mixins import PermissionRequiredMixin from django.contrib.auth.mixins import PermissionRequiredMixin
from django.db import transaction
from django.db.models import Count from django.db.models import Count
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse from django.urls import reverse
@ -183,17 +184,21 @@ class ClusterAddDevicesView(PermissionRequiredMixin, View):
if form.is_valid(): if form.is_valid():
# Assign the selected Devices to the Cluster device_pks = form.cleaned_data['devices']
devices = form.cleaned_data['devices'] with transaction.atomic():
Device.objects.filter(pk__in=devices).update(cluster=cluster)
# Assign the selected Devices to the Cluster
for device in Device.objects.filter(pk__in=device_pks):
device.cluster = cluster
device.save()
messages.success(request, "Added {} devices to cluster {}".format( messages.success(request, "Added {} devices to cluster {}".format(
len(devices), cluster len(device_pks), cluster
)) ))
return redirect(cluster.get_absolute_url()) return redirect(cluster.get_absolute_url())
return render(request, self.template_name, { return render(request, self.template_name, {
'cluser': cluster, 'cluster': cluster,
'form': form, 'form': form,
'return_url': cluster.get_absolute_url(), 'return_url': cluster.get_absolute_url(),
}) })
@ -212,12 +217,16 @@ class ClusterRemoveDevicesView(PermissionRequiredMixin, View):
form = self.form(request.POST) form = self.form(request.POST)
if form.is_valid(): if form.is_valid():
# Remove the selected Devices from the Cluster device_pks = form.cleaned_data['pk']
devices = form.cleaned_data['pk'] with transaction.atomic():
Device.objects.filter(pk__in=devices).update(cluster=None)
# Remove the selected Devices from the Cluster
for device in Device.objects.filter(pk__in=device_pks):
device.cluster = None
device.save()
messages.success(request, "Removed {} devices from cluster {}".format( messages.success(request, "Removed {} devices from cluster {}".format(
len(devices), cluster len(device_pks), cluster
)) ))
return redirect(cluster.get_absolute_url()) return redirect(cluster.get_absolute_url())