diff --git a/README.md b/README.md index 2cf8f0193..28c73b2cc 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# NetBox +![NetBox](docs/netbox_logo.png "NetBox logo") NetBox is an IP address management (IPAM) and data center infrastructure management (DCIM) tool. Initially conceived by the network engineering team at [DigitalOcean](https://www.digitalocean.com/), NetBox was developed specifically to address the needs of network and infrastructure engineers. diff --git a/docs/data-model/dcim.md b/docs/data-model/dcim.md index 6512995ea..aa8673fb1 100644 --- a/docs/data-model/dcim.md +++ b/docs/data-model/dcim.md @@ -10,15 +10,21 @@ Sites can be assigned an optional facility ID to identify the actual facility ho # Racks -Within each site exist one or more racks. Each rack within NetBox represents a physical two- or four-post equipment rack in which equipment is mounted. Rack height is measured in *rack units *(U); most racks are between 42U and 48U, but NetBox allows you to define racks of any height. Each rack has two faces (front and rear) on which devices can be mounted. +Within each site exist one or more racks. Each rack within NetBox represents a physical two- or four-post equipment rack in which equipment is mounted. Rack height is measured in *rack units* (U); most racks are between 42U and 48U, but NetBox allows you to define racks of any height. Each rack has two faces (front and rear) on which devices can be mounted. Each rack is assigned a name and (optionally) a separate facility ID. This is helpful when leasing space in a data center your organization does not own: The facility will often assign a seemingly arbitrary ID to a rack (for example, M204.313) whereas internally you refer to is simply as "R113." The facility ID can alternatively be used to store a rack's serial number. +The available rack types include 2- and 4-post frames, 4-post cabinet, and wall-mounted frame and cabinet. Rail-to-rail width may be 19 or 23 inches. + ### Rack Groups Racks can be arranged into groups. As with sites, how you choose to designate rack groups will depend on the nature of your organization. For example, if each site is a campus, each group might be a building. If each site is a building, each rack group might be a floor or room. -Each group is assigned to a parent site for easy navigation. Hierarchical recursion of rack groups is not currently supported. +Each group is assigned to a parent site for easy navigation. Hierarchical recursion of rack groups is not supported. + +### Rack Roles + +Each rak can optionally be assigned to a functional role. For example, you might designate a rack for compute or storage resources, or to house colocated customer devices. --- @@ -74,7 +80,7 @@ The assignment of platforms to devices is an entirely optional feature, and may ### Modules -A device can be assigned modules which represent internal components. Currently, these are used merely for inventory tracking, although future development might see their functionality expand. +A device can be assigned modules which represent internal components. Currently, these are used merely for inventory tracking, although future development might see their functionality expand. Each module can optionally be assigned to a manufacturer. ### Components diff --git a/docs/netbox_logo.png b/docs/netbox_logo.png new file mode 100644 index 000000000..c6e0a58e6 Binary files /dev/null and b/docs/netbox_logo.png differ diff --git a/netbox/circuits/admin.py b/netbox/circuits/admin.py index 31caecdec..97711b7a8 100644 --- a/netbox/circuits/admin.py +++ b/netbox/circuits/admin.py @@ -21,8 +21,8 @@ class CircuitTypeAdmin(admin.ModelAdmin): @admin.register(Circuit) class CircuitAdmin(admin.ModelAdmin): - list_display = ['cid', 'provider', 'type', 'tenant', 'site', 'install_date', 'port_speed', 'commit_rate', - 'xconnect_id'] + list_display = ['cid', 'provider', 'type', 'tenant', 'site', 'install_date', 'port_speed_human', + 'upstream_speed_human', 'commit_rate_human', 'xconnect_id'] list_filter = ['provider', 'type', 'tenant'] exclude = ['interface'] diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index 0efedd04c..ed6033963 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -53,7 +53,7 @@ class CircuitSerializer(serializers.ModelSerializer): class Meta: model = Circuit fields = ['id', 'cid', 'provider', 'type', 'tenant', 'site', 'interface', 'install_date', 'port_speed', - 'commit_rate', 'xconnect_id', 'comments'] + 'upstream_speed', 'commit_rate', 'xconnect_id', 'comments'] class CircuitNestedSerializer(CircuitSerializer): diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index b5152e08c..a44dd763e 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -102,7 +102,7 @@ class CircuitForm(forms.ModelForm, BootstrapMixin): model = Circuit fields = [ 'cid', 'type', 'provider', 'tenant', 'site', 'rack', 'device', 'livesearch', 'interface', 'install_date', - 'port_speed', 'commit_rate', 'xconnect_id', 'pp_info', 'comments' + 'port_speed', 'upstream_speed', 'commit_rate', 'xconnect_id', 'pp_info', 'comments' ] help_texts = { 'cid': "Unique circuit ID", @@ -169,8 +169,8 @@ class CircuitFromCSVForm(forms.ModelForm): class Meta: model = Circuit - fields = ['cid', 'provider', 'type', 'tenant', 'site', 'install_date', 'port_speed', 'commit_rate', - 'xconnect_id', 'pp_info'] + fields = ['cid', 'provider', 'type', 'tenant', 'site', 'install_date', 'port_speed', 'upstream_speed', + 'commit_rate', 'xconnect_id', 'pp_info'] class CircuitImportForm(BulkImportForm, BootstrapMixin): diff --git a/netbox/circuits/migrations/0005_circuit_add_upstream_speed.py b/netbox/circuits/migrations/0005_circuit_add_upstream_speed.py new file mode 100644 index 000000000..f309cb2d8 --- /dev/null +++ b/netbox/circuits/migrations/0005_circuit_add_upstream_speed.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.8 on 2016-08-08 20:24 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('circuits', '0004_circuit_add_tenant'), + ] + + operations = [ + migrations.AddField( + model_name='circuit', + name='upstream_speed', + field=models.PositiveIntegerField(blank=True, help_text=b'Upstream speed, if different from port speed', null=True, verbose_name=b'Upstream speed (Kbps)'), + ), + ] diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index 15dbea216..00367a27a 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -72,6 +72,8 @@ class Circuit(CreatedUpdatedModel): interface = models.OneToOneField(Interface, related_name='circuit', blank=True, null=True) install_date = models.DateField(blank=True, null=True, verbose_name='Date installed') port_speed = models.PositiveIntegerField(verbose_name='Port speed (Kbps)') + upstream_speed = models.PositiveIntegerField(blank=True, null=True, verbose_name='Upstream speed (Kbps)', + help_text='Upstream speed, if different from port speed') commit_rate = models.PositiveIntegerField(blank=True, null=True, verbose_name='Commit rate (Kbps)') xconnect_id = models.CharField(max_length=50, blank=True, verbose_name='Cross-connect ID') pp_info = models.CharField(max_length=100, blank=True, verbose_name='Patch panel/port(s)') @@ -96,6 +98,7 @@ class Circuit(CreatedUpdatedModel): self.site.name, self.install_date.isoformat() if self.install_date else '', str(self.port_speed), + str(self.upstream_speed), str(self.commit_rate) if self.commit_rate else '', self.xconnect_id, self.pp_info, @@ -116,12 +119,18 @@ class Circuit(CreatedUpdatedModel): else: return '{} Kbps'.format(speed) - @property def port_speed_human(self): return self._humanize_speed(self.port_speed) + port_speed_human.admin_order_field = 'port_speed' + + def upstream_speed_human(self): + if not self.upstream_speed: + return '' + return self._humanize_speed(self.upstream_speed) + upstream_speed_human.admin_order_field = 'upstream_speed' - @property def commit_rate_human(self): if not self.commit_rate: return '' return self._humanize_speed(self.commit_rate) + commit_rate_human.admin_order_field = 'commit_rate' diff --git a/netbox/dcim/admin.py b/netbox/dcim/admin.py index c5210987b..001a685c7 100644 --- a/netbox/dcim/admin.py +++ b/netbox/dcim/admin.py @@ -4,7 +4,7 @@ from django.db.models import Count from .models import ( ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceTemplate, Manufacturer, Module, Platform, - PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, Site, + PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackRole, Site, ) @@ -24,9 +24,17 @@ class RackGroupAdmin(admin.ModelAdmin): } +@admin.register(RackRole) +class RackRoleAdmin(admin.ModelAdmin): + list_display = ['name', 'slug', 'color'] + prepopulated_fields = { + 'slug': ['name'], + } + + @admin.register(Rack) class RackAdmin(admin.ModelAdmin): - list_display = ['name', 'facility_id', 'site', 'u_height'] + list_display = ['name', 'facility_id', 'site', 'group', 'tenant', 'role', 'type', 'width', 'u_height'] # diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 7a6693c37..a0a2776f1 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -3,8 +3,8 @@ from rest_framework import serializers from ipam.models import IPAddress from dcim.models import ( ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceType, - DeviceRole, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, Platform, PowerOutlet, - PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RACK_FACE_FRONT, RACK_FACE_REAR, Site, + DeviceRole, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, Module, Platform, PowerOutlet, + PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, RackRole, RACK_FACE_FRONT, RACK_FACE_REAR, Site, ) from tenancy.api.serializers import TenantNestedSerializer @@ -46,6 +46,23 @@ class RackGroupNestedSerializer(RackGroupSerializer): fields = ['id', 'name', 'slug'] +# +# Rack roles +# + +class RackRoleSerializer(serializers.ModelSerializer): + + class Meta: + model = RackRole + fields = ['id', 'name', 'slug', 'color'] + + +class RackRoleNestedSerializer(RackRoleSerializer): + + class Meta(RackRoleSerializer.Meta): + fields = ['id', 'name', 'slug'] + + # # Racks # @@ -55,10 +72,12 @@ class RackSerializer(serializers.ModelSerializer): site = SiteNestedSerializer() group = RackGroupNestedSerializer() tenant = TenantNestedSerializer() + role = RackRoleNestedSerializer() class Meta: model = Rack - fields = ['id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'u_height', 'comments'] + fields = ['id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'type', 'width', + 'u_height', 'comments'] class RackNestedSerializer(RackSerializer): @@ -72,8 +91,8 @@ class RackDetailSerializer(RackSerializer): rear_units = serializers.SerializerMethodField() class Meta(RackSerializer.Meta): - fields = ['id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'u_height', 'comments', - 'front_units', 'rear_units'] + fields = ['id', 'name', 'facility_id', 'display_name', 'site', 'group', 'tenant', 'role', 'type', 'width', + 'u_height', 'comments', 'front_units', 'rear_units'] def get_front_units(self, obj): units = obj.get_rack_units(face=RACK_FACE_FRONT) @@ -384,6 +403,25 @@ class DeviceBayDetailSerializer(DeviceBaySerializer): fields = ['id', 'device', 'name', 'installed_device'] +# +# Modules +# + +class ModuleSerializer(serializers.ModelSerializer): + device = DeviceNestedSerializer() + manufacturer = ManufacturerNestedSerializer() + + class Meta: + model = Module + fields = ['id', 'device', 'parent', 'name', 'manufacturer', 'part_id', 'serial', 'discovered'] + + +class ModuleNestedSerializer(ModuleSerializer): + + class Meta(ModuleSerializer.Meta): + fields = ['id', 'device', 'parent', 'name'] + + # # Interface connections # diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index a22a661aa..23787f4b4 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -18,6 +18,10 @@ urlpatterns = [ url(r'^rack-groups/$', RackGroupListView.as_view(), name='rackgroup_list'), url(r'^rack-groups/(?P\d+)/$', RackGroupDetailView.as_view(), name='rackgroup_detail'), + # Rack roles + url(r'^rack-roles/$', RackRoleListView.as_view(), name='rackrole_list'), + url(r'^rack-roles/(?P\d+)/$', RackRoleDetailView.as_view(), name='rackrole_detail'), + # Racks url(r'^racks/$', RackListView.as_view(), name='rack_list'), url(r'^racks/(?P\d+)/$', RackDetailView.as_view(), name='rack_detail'), @@ -50,6 +54,7 @@ urlpatterns = [ url(r'^devices/(?P\d+)/power-outlets/$', PowerOutletListView.as_view(), name='device_poweroutlets'), url(r'^devices/(?P\d+)/interfaces/$', InterfaceListView.as_view(), name='device_interfaces'), url(r'^devices/(?P\d+)/device-bays/$', DeviceBayListView.as_view(), name='device_devicebays'), + url(r'^devices/(?P\d+)/modules/$', ModuleListView.as_view(), name='device_modules'), # Console ports url(r'^console-ports/(?P\d+)/$', ConsolePortView.as_view(), name='consoleport'), diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index e14eb8f87..5bde03a6e 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -10,7 +10,7 @@ from django.shortcuts import get_object_or_404 from dcim.models import ( ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, DeviceType, IFACE_FF_VIRTUAL, Interface, - InterfaceConnection, Manufacturer, Platform, PowerOutlet, PowerPort, Rack, RackGroup, Site, + InterfaceConnection, Manufacturer, Module, Platform, PowerOutlet, PowerPort, Rack, RackGroup, RackRole, Site, ) from dcim import filters from .exceptions import MissingFilterException @@ -60,6 +60,26 @@ class RackGroupDetailView(generics.RetrieveAPIView): serializer_class = serializers.RackGroupSerializer +# +# Rack roles +# + +class RackRoleListView(generics.ListAPIView): + """ + List all rack roles + """ + queryset = RackRole.objects.all() + serializer_class = serializers.RackRoleSerializer + + +class RackRoleDetailView(generics.RetrieveAPIView): + """ + Retrieve a single rack role + """ + queryset = RackRole.objects.all() + serializer_class = serializers.RackRoleSerializer + + # # Racks # @@ -349,18 +369,23 @@ class DeviceBayListView(generics.ListAPIView): def get_queryset(self): device = get_object_or_404(Device, pk=self.kwargs['pk']) - queryset = DeviceBay.objects.filter(device=device).select_related('installed_device') + return DeviceBay.objects.filter(device=device).select_related('installed_device') - # Filter by type (physical or virtual) - iface_type = self.request.query_params.get('type') - if iface_type == 'physical': - queryset = queryset.exclude(form_factor=IFACE_FF_VIRTUAL) - elif iface_type == 'virtual': - queryset = queryset.filter(form_factor=IFACE_FF_VIRTUAL) - elif iface_type is not None: - queryset = queryset.empty() - return queryset +# +# Modules +# + +class ModuleListView(generics.ListAPIView): + """ + List device modules (by device) + """ + serializer_class = serializers.ModuleSerializer + + def get_queryset(self): + + device = get_object_or_404(Device, pk=self.kwargs['pk']) + return Module.objects.filter(device=device).select_related('device', 'manufacturer') # diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index 1800bf266..b124d53dd 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -4,7 +4,7 @@ from django.db.models import Q from .models import ( ConsolePort, ConsoleServerPort, Device, DeviceRole, DeviceType, Interface, InterfaceConnection, Manufacturer, - Platform, PowerOutlet, PowerPort, Rack, RackGroup, Site, + Platform, PowerOutlet, PowerPort, Rack, RackGroup, RackRole, Site, ) from tenancy.models import Tenant @@ -96,6 +96,17 @@ class RackFilter(django_filters.FilterSet): to_field_name='slug', label='Tenant (slug)', ) + role_id = django_filters.ModelMultipleChoiceFilter( + name='role', + queryset=RackRole.objects.all(), + label='Role (ID)', + ) + role = django_filters.ModelMultipleChoiceFilter( + name='role', + queryset=RackRole.objects.all(), + to_field_name='slug', + label='Role (slug)', + ) class Meta: model = Rack diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index fef87ded0..56e786a26 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -7,7 +7,7 @@ from ipam.models import IPAddress from tenancy.forms import bulkedit_tenant_choices from tenancy.models import Tenant from utilities.forms import ( - APISelect, BootstrapMixin, BulkImportForm, CommentField, CSVDataField, ExpandableNameField, + APISelect, add_blank_choice, BootstrapMixin, BulkImportForm, CommentField, CSVDataField, ExpandableNameField, FlexibleModelChoiceField, Livesearch, SelectWithDisabled, SmallTextarea, SlugField, ) @@ -15,7 +15,8 @@ from .models import ( DeviceBay, DeviceBayTemplate, CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_PLANNED, CONNECTION_STATUS_CONNECTED, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceRole, DeviceType, Interface, IFACE_FF_VIRTUAL, InterfaceConnection, InterfaceTemplate, Manufacturer, Module, Platform, PowerOutlet, - PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD + PowerOutletTemplate, PowerPort, PowerPortTemplate, RACK_TYPE_CHOICES, RACK_WIDTH_CHOICES, Rack, RackGroup, RackRole, + Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD ) @@ -49,6 +50,30 @@ def bulkedit_platform_choices(): return choices +def bulkedit_rackgroup_choices(): + """ + Include an option to remove the currently assigned group from a rack. + """ + choices = [ + (None, '---------'), + (0, 'None'), + ] + choices += [(r.pk, r) for r in RackGroup.objects.all()] + return choices + + +def bulkedit_rackrole_choices(): + """ + Include an option to remove the currently assigned role from a rack. + """ + choices = [ + (None, '---------'), + (0, 'None'), + ] + choices += [(r.pk, r.name) for r in RackRole.objects.all()] + return choices + + # # Sites # @@ -123,6 +148,18 @@ class RackGroupFilterForm(forms.Form, BootstrapMixin): widget=forms.SelectMultiple(attrs={'size': 8})) +# +# Rack roles +# + +class RackRoleForm(forms.ModelForm, BootstrapMixin): + slug = SlugField() + + class Meta: + model = RackRole + fields = ['name', 'slug', 'color'] + + # # Racks # @@ -135,7 +172,7 @@ class RackForm(forms.ModelForm, BootstrapMixin): class Meta: model = Rack - fields = ['site', 'group', 'name', 'facility_id', 'tenant', 'u_height', 'comments'] + fields = ['site', 'group', 'name', 'facility_id', 'tenant', 'role', 'type', 'width', 'u_height', 'comments'] help_texts = { 'site': "The site at which the rack exists", 'name': "Organizational rack name", @@ -165,10 +202,13 @@ class RackFromCSVForm(forms.ModelForm): group_name = forms.CharField(required=False) tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False, error_messages={'invalid_choice': 'Tenant not found.'}) + role = forms.ModelChoiceField(RackRole.objects.all(), to_field_name='name', required=False, + error_messages={'invalid_choice': 'Role not found.'}) + type = forms.CharField(required=False) class Meta: model = Rack - fields = ['site', 'group_name', 'name', 'facility_id', 'tenant', 'u_height'] + fields = ['site', 'group_name', 'name', 'facility_id', 'tenant', 'role', 'type', 'width', 'u_height'] def clean(self): @@ -182,6 +222,19 @@ class RackFromCSVForm(forms.ModelForm): except RackGroup.DoesNotExist: self.add_error('group_name', "Invalid rack group ({})".format(group)) + def clean_type(self): + rack_type = self.cleaned_data['type'] + if not rack_type: + return None + try: + choices = {v.lower(): k for k, v in RACK_TYPE_CHOICES} + return choices[rack_type.lower()] + except KeyError: + raise forms.ValidationError('Invalid rack type ({}). Valid choices are: {}.'.format( + rack_type, + ', '.join({v: k for k, v in RACK_TYPE_CHOICES}), + )) + class RackImportForm(BulkImportForm, BootstrapMixin): csv = CSVDataField(csv_form=RackFromCSVForm) @@ -189,9 +242,12 @@ class RackImportForm(BulkImportForm, BootstrapMixin): class RackBulkEditForm(forms.Form, BootstrapMixin): pk = forms.ModelMultipleChoiceField(queryset=Rack.objects.all(), widget=forms.MultipleHiddenInput) - site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False) - group = forms.ModelChoiceField(queryset=RackGroup.objects.all(), required=False) + site = forms.ModelChoiceField(queryset=Site.objects.all(), required=False, label='Site') + group = forms.TypedChoiceField(choices=bulkedit_rackgroup_choices, coerce=int, required=False, label='Group') tenant = forms.TypedChoiceField(choices=bulkedit_tenant_choices, coerce=int, required=False, label='Tenant') + role = forms.TypedChoiceField(choices=bulkedit_rackrole_choices, coerce=int, required=False, label='Role') + type = forms.ChoiceField(choices=add_blank_choice(RACK_TYPE_CHOICES), required=False, label='Type') + width = forms.ChoiceField(choices=add_blank_choice(RACK_WIDTH_CHOICES), required=False, label='Width') u_height = forms.IntegerField(required=False, label='Height (U)') comments = CommentField() @@ -211,6 +267,11 @@ def rack_tenant_choices(): return [(t.slug, u'{} ({})'.format(t.name, t.rack_count)) for t in tenant_choices] +def rack_role_choices(): + role_choices = RackRole.objects.annotate(rack_count=Count('racks')) + return [(r.slug, u'{} ({})'.format(r.name, r.rack_count)) for r in role_choices] + + class RackFilterForm(forms.Form, BootstrapMixin): site = forms.MultipleChoiceField(required=False, choices=rack_site_choices, widget=forms.SelectMultiple(attrs={'size': 8})) @@ -218,6 +279,8 @@ class RackFilterForm(forms.Form, BootstrapMixin): widget=forms.SelectMultiple(attrs={'size': 8})) tenant = forms.MultipleChoiceField(required=False, choices=rack_tenant_choices, widget=forms.SelectMultiple(attrs={'size': 8})) + role = forms.MultipleChoiceField(required=False, choices=rack_role_choices, + widget=forms.SelectMultiple(attrs={'size': 8})) # @@ -402,7 +465,7 @@ class DeviceForm(forms.ModelForm, BootstrapMixin): self.fields['primary_ip6'].widget.attrs['readonly'] = True # Limit rack choices - if self.is_bound: + if self.is_bound and self.data.get('site'): self.fields['rack'].queryset = Rack.objects.filter(site__pk=self.data['site']) elif self.initial.get('site'): self.fields['rack'].queryset = Rack.objects.filter(site=self.initial['site']) @@ -443,6 +506,8 @@ class DeviceForm(forms.ModelForm, BootstrapMixin): if pk and self.instance.device_type.is_child_device and hasattr(self.instance, 'parent_bay'): self.fields['site'].disabled = True self.fields['rack'].disabled = True + self.initial['site'] = self.instance.parent_bay.device.rack.site_id + self.initial['rack'] = self.instance.parent_bay.device.rack_id class BaseDeviceFromCSVForm(forms.ModelForm): @@ -1254,4 +1319,4 @@ class ModuleForm(forms.ModelForm, BootstrapMixin): class Meta: model = Module - fields = ['name', 'part_id', 'serial'] + fields = ['name', 'manufacturer', 'part_id', 'serial'] diff --git a/netbox/dcim/migrations/0014_rack_add_type_width.py b/netbox/dcim/migrations/0014_rack_add_type_width.py new file mode 100644 index 000000000..c14768c0f --- /dev/null +++ b/netbox/dcim/migrations/0014_rack_add_type_width.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.8 on 2016-08-08 21:11 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0013_add_interface_form_factors'), + ] + + operations = [ + migrations.AddField( + model_name='rack', + name='type', + field=models.PositiveSmallIntegerField(blank=True, choices=[(100, b'2-post frame'), (200, b'4-post frame'), (300, b'4-post cabinet'), (1000, b'Wall-mounted frame'), (1100, b'Wall-mounted cabinet')], null=True, verbose_name=b'Type'), + ), + migrations.AddField( + model_name='rack', + name='width', + field=models.PositiveSmallIntegerField(choices=[(19, b'19 inches'), (23, b'23 inches')], default=19, help_text=b'Rail-to-rail width', verbose_name=b'Width'), + ), + ] diff --git a/netbox/dcim/migrations/0015_rack_add_u_height_validator.py b/netbox/dcim/migrations/0015_rack_add_u_height_validator.py new file mode 100644 index 000000000..8e555204b --- /dev/null +++ b/netbox/dcim/migrations/0015_rack_add_u_height_validator.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.8 on 2016-08-09 21:18 +from __future__ import unicode_literals + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0014_rack_add_type_width'), + ] + + operations = [ + migrations.AlterField( + model_name='rack', + name='u_height', + field=models.PositiveSmallIntegerField(default=42, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(100)], verbose_name=b'Height (U)'), + ), + ] diff --git a/netbox/dcim/migrations/0016_module_add_manufacturer.py b/netbox/dcim/migrations/0016_module_add_manufacturer.py new file mode 100644 index 000000000..6a2264a83 --- /dev/null +++ b/netbox/dcim/migrations/0016_module_add_manufacturer.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.8 on 2016-08-10 13:45 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0015_rack_add_u_height_validator'), + ] + + operations = [ + migrations.AddField( + model_name='module', + name='manufacturer', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='modules', to='dcim.Manufacturer'), + ), + ] diff --git a/netbox/dcim/migrations/0017_rack_add_role.py b/netbox/dcim/migrations/0017_rack_add_role.py new file mode 100644 index 000000000..eb3560b37 --- /dev/null +++ b/netbox/dcim/migrations/0017_rack_add_role.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.8 on 2016-08-10 14:58 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0016_module_add_manufacturer'), + ] + + operations = [ + migrations.CreateModel( + name='RackRole', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=50, unique=True)), + ('slug', models.SlugField(unique=True)), + ('color', models.CharField(choices=[[b'teal', b'Teal'], [b'green', b'Green'], [b'blue', b'Blue'], [b'purple', b'Purple'], [b'yellow', b'Yellow'], [b'orange', b'Orange'], [b'red', b'Red'], [b'light_gray', b'Light Gray'], [b'medium_gray', b'Medium Gray'], [b'dark_gray', b'Dark Gray']], max_length=30)), + ], + options={ + 'ordering': ['name'], + }, + ), + migrations.AddField( + model_name='rack', + name='role', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='racks', to='dcim.RackRole'), + ), + ] diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index 119336569..537d33087 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -3,7 +3,7 @@ from collections import OrderedDict from django.conf import settings from django.core.exceptions import MultipleObjectsReturned, ValidationError from django.core.urlresolvers import reverse -from django.core.validators import MinValueValidator +from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.db.models import Count, Q, ObjectDoesNotExist @@ -16,6 +16,26 @@ from utilities.models import CreatedUpdatedModel from .fields import ASNField, MACAddressField +RACK_TYPE_2POST = 100 +RACK_TYPE_4POST = 200 +RACK_TYPE_CABINET = 300 +RACK_TYPE_WALLFRAME = 1000 +RACK_TYPE_WALLCABINET = 1100 +RACK_TYPE_CHOICES = ( + (RACK_TYPE_2POST, '2-post frame'), + (RACK_TYPE_4POST, '4-post frame'), + (RACK_TYPE_CABINET, '4-post cabinet'), + (RACK_TYPE_WALLFRAME, 'Wall-mounted frame'), + (RACK_TYPE_WALLCABINET, 'Wall-mounted cabinet'), +) + +RACK_WIDTH_19IN = 19 +RACK_WIDTH_23IN = 23 +RACK_WIDTH_CHOICES = ( + (RACK_WIDTH_19IN, '19 inches'), + (RACK_WIDTH_23IN, '23 inches'), +) + RACK_FACE_FRONT = 0 RACK_FACE_REAR = 1 RACK_FACE_CHOICES = [ @@ -41,7 +61,7 @@ COLOR_RED = 'red' COLOR_GRAY1 = 'light_gray' COLOR_GRAY2 = 'medium_gray' COLOR_GRAY3 = 'dark_gray' -DEVICE_ROLE_COLOR_CHOICES = [ +ROLE_COLOR_CHOICES = [ [COLOR_TEAL, 'Teal'], [COLOR_GREEN, 'Green'], [COLOR_BLUE, 'Blue'], @@ -183,6 +203,10 @@ def order_interfaces(queryset, sql_col, primary_ordering=tuple()): }).order_by(*ordering) +# +# Sites +# + class SiteManager(NaturalOrderByManager): def get_queryset(self): @@ -244,6 +268,10 @@ class Site(CreatedUpdatedModel): return self.circuits.count() +# +# Racks +# + class RackGroup(models.Model): """ Racks can be grouped as subsets within a Site. The scope of a group will depend on how Sites are defined. For @@ -268,6 +296,24 @@ class RackGroup(models.Model): return "{}?group_id={}".format(reverse('dcim:rack_list'), self.pk) +class RackRole(models.Model): + """ + Racks can be organized by functional role, similar to Devices. + """ + name = models.CharField(max_length=50, unique=True) + slug = models.SlugField(unique=True) + color = models.CharField(max_length=30, choices=ROLE_COLOR_CHOICES) + + class Meta: + ordering = ['name'] + + def __unicode__(self): + return self.name + + def get_absolute_url(self): + return "{}?role={}".format(reverse('dcim:rack_list'), self.slug) + + class RackManager(NaturalOrderByManager): def get_queryset(self): @@ -284,7 +330,12 @@ class Rack(CreatedUpdatedModel): site = models.ForeignKey('Site', related_name='racks', on_delete=models.PROTECT) group = models.ForeignKey('RackGroup', related_name='racks', blank=True, null=True, on_delete=models.SET_NULL) tenant = models.ForeignKey(Tenant, blank=True, null=True, related_name='racks', on_delete=models.PROTECT) - u_height = models.PositiveSmallIntegerField(default=42, verbose_name='Height (U)') + role = models.ForeignKey('RackRole', related_name='racks', blank=True, null=True, on_delete=models.PROTECT) + type = models.PositiveSmallIntegerField(choices=RACK_TYPE_CHOICES, blank=True, null=True, verbose_name='Type') + width = models.PositiveSmallIntegerField(choices=RACK_WIDTH_CHOICES, default=RACK_WIDTH_19IN, verbose_name='Width', + help_text='Rail-to-rail width') + u_height = models.PositiveSmallIntegerField(default=42, verbose_name='Height (U)', + validators=[MinValueValidator(1), MaxValueValidator(100)]) comments = models.TextField(blank=True) objects = RackManager() @@ -320,6 +371,9 @@ class Rack(CreatedUpdatedModel): self.name, self.facility_id or '', self.tenant.name if self.tenant else '', + self.role.name if self.role else '', + self.get_type_display() if self.type else '', + self.width, str(self.u_height), ]) @@ -627,7 +681,7 @@ class DeviceRole(models.Model): """ name = models.CharField(max_length=50, unique=True) slug = models.SlugField(unique=True) - color = models.CharField(max_length=30, choices=DEVICE_ROLE_COLOR_CHOICES) + color = models.CharField(max_length=30, choices=ROLE_COLOR_CHOICES) class Meta: ordering = ['name'] @@ -1073,6 +1127,8 @@ class Module(models.Model): device = models.ForeignKey('Device', related_name='modules', on_delete=models.CASCADE) parent = models.ForeignKey('self', related_name='submodules', blank=True, null=True, on_delete=models.CASCADE) name = models.CharField(max_length=50, verbose_name='Name') + manufacturer = models.ForeignKey('Manufacturer', related_name='modules', blank=True, null=True, + on_delete=models.PROTECT) part_id = models.CharField(max_length=50, verbose_name='Part ID', blank=True) serial = models.CharField(max_length=50, verbose_name='Serial number', blank=True) discovered = models.BooleanField(default=False, verbose_name='Discovered') diff --git a/netbox/dcim/tables.py b/netbox/dcim/tables.py index b7920cb71..0a049ce94 100644 --- a/netbox/dcim/tables.py +++ b/netbox/dcim/tables.py @@ -1,7 +1,7 @@ import django_tables2 as tables from django_tables2.utils import Accessor -from utilities.tables import BaseTable, ToggleColumn +from utilities.tables import BaseTable, ColorColumn, ToggleColumn from .models import ( ConsolePort, ConsolePortTemplate, ConsoleServerPortTemplate, Device, DeviceBayTemplate, DeviceRole, DeviceType, @@ -22,6 +22,12 @@ RACKGROUP_ACTIONS = """ {% endif %} """ +RACKROLE_ACTIONS = """ +{% if perms.dcim.change_rackrole %} + +{% endif %} +""" + DEVICEROLE_ACTIONS = """ {% if perms.dcim.change_devicerole %} @@ -94,6 +100,24 @@ class RackGroupTable(BaseTable): fields = ('pk', 'name', 'site', 'rack_count', 'slug', 'actions') +# +# Rack roles +# + +class RackRoleTable(BaseTable): + pk = ToggleColumn() + name = tables.LinkColumn(verbose_name='Name') + rack_count = tables.Column(verbose_name='Racks') + color = ColorColumn(verbose_name='Color') + slug = tables.Column(verbose_name='Slug') + actions = tables.TemplateColumn(template_code=RACKROLE_ACTIONS, attrs={'td': {'class': 'text-right'}}, + verbose_name='') + + class Meta(BaseTable.Meta): + model = RackGroup + fields = ('pk', 'name', 'rack_count', 'color', 'slug', 'actions') + + # # Racks # @@ -105,6 +129,7 @@ class RackTable(BaseTable): group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group') facility_id = tables.Column(verbose_name='Facility ID') tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant') + role = tables.Column(verbose_name='Role') u_height = tables.TemplateColumn("{{ record.u_height }}U", verbose_name='Height') devices = tables.Column(accessor=Accessor('device_count'), verbose_name='Devices') u_consumed = tables.TemplateColumn("{{ record.u_consumed|default:'0' }}U", verbose_name='Used') @@ -112,7 +137,7 @@ class RackTable(BaseTable): class Meta(BaseTable.Meta): model = Rack - fields = ('pk', 'name', 'site', 'group', 'facility_id', 'tenant', 'u_height', 'devices', 'u_consumed', + fields = ('pk', 'name', 'site', 'group', 'facility_id', 'tenant', 'role', 'u_height', 'devices', 'u_consumed', 'utilization') @@ -233,14 +258,14 @@ class DeviceRoleTable(BaseTable): pk = ToggleColumn() name = tables.LinkColumn(verbose_name='Name') device_count = tables.Column(verbose_name='Devices') + color = ColorColumn(verbose_name='Color') slug = tables.Column(verbose_name='Slug') - color = tables.Column(verbose_name='Color') actions = tables.TemplateColumn(template_code=DEVICEROLE_ACTIONS, attrs={'td': {'class': 'text-right'}}, verbose_name='') class Meta(BaseTable.Meta): model = DeviceRole - fields = ('pk', 'name', 'device_count', 'slug', 'color', 'actions') + fields = ('pk', 'name', 'device_count', 'color', 'slug', 'actions') # diff --git a/netbox/dcim/tests/test_apis.py b/netbox/dcim/tests/test_apis.py index 8b0d8ca53..bc9ea6547 100644 --- a/netbox/dcim/tests/test_apis.py +++ b/netbox/dcim/tests/test_apis.py @@ -42,6 +42,9 @@ class SiteTest(APITestCase): 'site', 'group', 'tenant', + 'role', + 'type', + 'width', 'u_height', 'comments' ] @@ -118,6 +121,9 @@ class RackTest(APITestCase): 'site', 'group', 'tenant', + 'role', + 'type', + 'width', 'u_height', 'comments' ] @@ -130,6 +136,9 @@ class RackTest(APITestCase): 'site', 'group', 'tenant', + 'role', + 'type', + 'width', 'u_height', 'comments', 'front_units', diff --git a/netbox/dcim/tests/test_forms.py b/netbox/dcim/tests/test_forms.py index 6ca891b76..f859fe5e1 100644 --- a/netbox/dcim/tests/test_forms.py +++ b/netbox/dcim/tests/test_forms.py @@ -13,59 +13,67 @@ class DeviceTestCase(TestCase): def test_racked_device(self): test = DeviceForm(data={ - 'device_role': get_id(DeviceRole, 'leaf-switch'), 'name': 'test', - 'site': get_id(Site, 'test1'), - 'face': RACK_FACE_FRONT, - 'platform': get_id(Platform, 'juniper-junos'), - 'device_type': get_id(DeviceType, 'qfx5100-48s'), - 'position': 41, - 'rack': '1', + 'device_role': get_id(DeviceRole, 'leaf-switch'), + 'tenant': None, 'manufacturer': get_id(Manufacturer, 'juniper'), + 'device_type': get_id(DeviceType, 'qfx5100-48s'), + 'site': get_id(Site, 'test1'), + 'rack': '1', + 'face': RACK_FACE_FRONT, + 'position': 41, + 'platform': get_id(Platform, 'juniper-junos'), + 'status': STATUS_ACTIVE, }) self.assertTrue(test.is_valid(), test.fields['position'].choices) self.assertTrue(test.save()) def test_racked_device_occupied(self): test = DeviceForm(data={ - 'device_role': get_id(DeviceRole, 'leaf-switch'), 'name': 'test', - 'site': get_id(Site, 'test1'), - 'face': RACK_FACE_FRONT, - 'platform': get_id(Platform, 'juniper-junos'), - 'device_type': get_id(DeviceType, 'qfx5100-48s'), - 'position': 1, - 'rack': '1', + 'device_role': get_id(DeviceRole, 'leaf-switch'), + 'tenant': None, 'manufacturer': get_id(Manufacturer, 'juniper'), + 'device_type': get_id(DeviceType, 'qfx5100-48s'), + 'site': get_id(Site, 'test1'), + 'rack': '1', + 'face': RACK_FACE_FRONT, + 'position': 1, + 'platform': get_id(Platform, 'juniper-junos'), + 'status': STATUS_ACTIVE, }) self.assertFalse(test.is_valid()) def test_non_racked_device(self): test = DeviceForm(data={ - 'device_role': get_id(DeviceRole, 'pdu'), 'name': 'test', - 'site': get_id(Site, 'test1'), - 'face': None, - 'platform': None, - 'device_type': get_id(DeviceType, 'cwg-24vym415c9'), - 'position': None, - 'rack': '1', + 'device_role': get_id(DeviceRole, 'pdu'), + 'tenant': None, 'manufacturer': get_id(Manufacturer, 'servertech'), + 'device_type': get_id(DeviceType, 'cwg-24vym415c9'), + 'site': get_id(Site, 'test1'), + 'rack': '1', + 'face': None, + 'position': None, + 'platform': None, + 'status': STATUS_ACTIVE, }) self.assertTrue(test.is_valid()) self.assertTrue(test.save()) def test_non_racked_device_with_face(self): test = DeviceForm(data={ - 'device_role': get_id(DeviceRole, 'pdu'), 'name': 'test', - 'site': get_id(Site, 'test1'), - 'face': RACK_FACE_REAR, - 'platform': None, - 'device_type': get_id(DeviceType, 'cwg-24vym415c9'), - 'position': None, - 'rack': '1', + 'device_role': get_id(DeviceRole, 'pdu'), + 'tenant': None, 'manufacturer': get_id(Manufacturer, 'servertech'), + 'device_type': get_id(DeviceType, 'cwg-24vym415c9'), + 'site': get_id(Site, 'test1'), + 'rack': '1', + 'face': RACK_FACE_REAR, + 'position': None, + 'platform': None, + 'status': STATUS_ACTIVE, }) self.assertTrue(test.is_valid()) self.assertTrue(test.save()) diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 12aa60d4f..dfeb06467 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -26,6 +26,12 @@ urlpatterns = [ url(r'^rack-groups/delete/$', views.RackGroupBulkDeleteView.as_view(), name='rackgroup_bulk_delete'), url(r'^rack-groups/(?P\d+)/edit/$', views.RackGroupEditView.as_view(), name='rackgroup_edit'), + # Rack roles + url(r'^rack-roles/$', views.RackRoleListView.as_view(), name='rackrole_list'), + url(r'^rack-roles/add/$', views.RackRoleEditView.as_view(), name='rackrole_add'), + url(r'^rack-roles/delete/$', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'), + url(r'^rack-roles/(?P\d+)/edit/$', views.RackRoleEditView.as_view(), name='rackrole_edit'), + # Racks url(r'^racks/$', views.RackListView.as_view(), name='rack_list'), url(r'^racks/add/$', views.RackEditView.as_view(), name='rack_add'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index cc5ea9af3..17c0e1886 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -8,6 +8,7 @@ from django.contrib.auth.mixins import PermissionRequiredMixin from django.core.exceptions import ValidationError from django.core.urlresolvers import reverse from django.db.models import Count, Sum +from django.db.models.functions import Coalesce from django.http import HttpResponseRedirect from django.shortcuts import get_object_or_404, redirect, render from django.utils.http import urlencode @@ -26,7 +27,7 @@ from .models import ( CONNECTION_STATUS_CONNECTED, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, Interface, InterfaceConnection, InterfaceTemplate, Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, Rack, RackGroup, - Site, + RackRole, Site, ) @@ -137,7 +138,7 @@ class SiteBulkEditView(PermissionRequiredMixin, BulkEditView): # class RackGroupListView(ObjectListView): - queryset = RackGroup.objects.annotate(rack_count=Count('racks')) + queryset = RackGroup.objects.select_related('site').annotate(rack_count=Count('racks')) filter = filters.RackGroupFilter filter_form = forms.RackGroupFilterForm table = tables.RackGroupTable @@ -149,6 +150,7 @@ class RackGroupEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.change_rackgroup' model = RackGroup form_class = forms.RackGroupForm + success_url = 'dcim:rackgroup_list' cancel_url = 'dcim:rackgroup_list' @@ -158,13 +160,39 @@ class RackGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): default_redirect_url = 'dcim:rackgroup_list' +# +# Rack roles +# + +class RackRoleListView(ObjectListView): + queryset = RackRole.objects.annotate(rack_count=Count('racks')) + table = tables.RackRoleTable + edit_permissions = ['dcim.change_rackrole', 'dcim.delete_rackrole'] + template_name = 'dcim/rackrole_list.html' + + +class RackRoleEditView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'dcim.change_rackrole' + model = RackRole + form_class = forms.RackRoleForm + success_url = 'dcim:rackrole_list' + cancel_url = 'dcim:rackrole_list' + + +class RackRoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): + permission_required = 'dcim.delete_rackrole' + cls = RackRole + default_redirect_url = 'dcim:rackrole_list' + + # # Racks # class RackListView(ObjectListView): - queryset = Rack.objects.select_related('site').prefetch_related('devices__device_type')\ - .annotate(device_count=Count('devices', distinct=True), u_consumed=Sum('devices__device_type__u_height')) + queryset = Rack.objects.select_related('site', 'group', 'tenant', 'role').prefetch_related('devices__device_type')\ + .annotate(device_count=Count('devices', distinct=True), + u_consumed=Coalesce(Sum('devices__device_type__u_height'), 0)) filter = filters.RackFilter filter_form = forms.RackFilterForm table = tables.RackTable @@ -223,11 +251,12 @@ class RackBulkEditView(PermissionRequiredMixin, BulkEditView): def update_objects(self, pk_list, form): fields_to_update = {} - if form.cleaned_data['tenant'] == 0: - fields_to_update['tenant'] = None - elif form.cleaned_data['tenant']: - fields_to_update['tenant'] = form.cleaned_data['tenant'] - for field in ['site', 'group', 'tenant', 'u_height', 'comments']: + for field in ['group', 'tenant', 'role']: + if form.cleaned_data[field] == 0: + fields_to_update[field] = None + elif form.cleaned_data[field]: + fields_to_update[field] = form.cleaned_data[field] + for field in ['site', 'type', 'width', 'u_height', 'comments']: if form.cleaned_data[field]: fields_to_update[field] = form.cleaned_data[field] @@ -533,8 +562,8 @@ class PlatformBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # class DeviceListView(ObjectListView): - queryset = Device.objects.select_related('device_type__manufacturer', 'device_role', 'rack__site', 'primary_ip4', - 'primary_ip6') + queryset = Device.objects.select_related('device_type__manufacturer', 'device_role', 'tenant', 'rack__site', + 'primary_ip4', 'primary_ip6') filter = filters.DeviceFilter filter_form = forms.DeviceFilterForm table = tables.DeviceTable @@ -680,7 +709,8 @@ class DeviceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): def device_inventory(request, pk): device = get_object_or_404(Device, pk=pk) - modules = Module.objects.filter(device=device, parent=None).prefetch_related('submodules') + modules = Module.objects.filter(device=device, parent=None).select_related('manufacturer')\ + .prefetch_related('submodules') return render(request, 'dcim/device_inventory.html', { 'device': device, diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 666b2ee81..00374ef36 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -182,18 +182,14 @@ class PrefixForm(forms.ModelForm, BootstrapMixin): self.fields['vlan'].choices = [] def clean_prefix(self): - data = self.cleaned_data['prefix'] - try: - prefix = IPNetwork(data) - except: - raise + prefix = self.cleaned_data['prefix'] if prefix.version == 4 and prefix.prefixlen == 32: raise forms.ValidationError("Cannot create host addresses (/32) as prefixes. These should be IPv4 " "addresses instead.") elif prefix.version == 6 and prefix.prefixlen == 128: raise forms.ValidationError("Cannot create host addresses (/128) as prefixes. These should be IPv6 " "addresses instead.") - return data + return prefix class PrefixFromCSVForm(forms.ModelForm): diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index 7c981a8cb..bd49feef1 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -254,12 +254,13 @@ class Prefix(CreatedUpdatedModel): def clean(self): # Disallow host masks - if self.prefix.version == 4 and self.prefix.prefixlen == 32: - raise ValidationError("Cannot create host addresses (/32) as prefixes. These should be IPv4 addresses " - "instead.") - elif self.prefix.version == 6 and self.prefix.prefixlen == 128: - raise ValidationError("Cannot create host addresses (/128) as prefixes. These should be IPv6 addresses " - "instead.") + if self.prefix: + if self.prefix.version == 4 and self.prefix.prefixlen == 32: + raise ValidationError("Cannot create host addresses (/32) as prefixes. These should be IPv4 addresses " + "instead.") + elif self.prefix.version == 6 and self.prefix.prefixlen == 128: + raise ValidationError("Cannot create host addresses (/128) as prefixes. These should be IPv6 addresses " + "instead.") def save(self, *args, **kwargs): if self.prefix: diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index 4c5bbee3a..c669362c5 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -43,12 +43,22 @@ IPADDRESS_LINK = """ {% if record.pk %} {{ record.address }} {% elif perms.ipam.add_ipaddress %} - {% if record.0 <= 65536 %}{{ record.0 }}{% else %}Lots of{% endif %} free IP{{ record.0|pluralize }} + {% if record.0 <= 65536 %}{{ record.0 }}{% else %}Lots of{% endif %} free IP{{ record.0|pluralize }} {% else %} {{ record.0 }} {% endif %} """ +VRF_LINK = """ +{% if record.vrf %} + {{ record.vrf }} +{% elif prefix.vrf %} + {{ prefix.vrf }} +{% else %} + Global +{% endif %} +""" + STATUS_LABEL = """ {% if record.pk %} {{ record.get_status_display }} @@ -149,7 +159,7 @@ class PrefixTable(BaseTable): pk = ToggleColumn() status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status') prefix = tables.TemplateColumn(PREFIX_LINK, verbose_name='Prefix') - vrf = tables.LinkColumn('ipam:vrf', args=[Accessor('vrf.pk')], default='Global', verbose_name='VRF') + vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF') tenant = tables.TemplateColumn(TENANT_LINK, verbose_name='Tenant') site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site') role = tables.Column(verbose_name='Role') @@ -183,7 +193,7 @@ class PrefixBriefTable(BaseTable): class IPAddressTable(BaseTable): pk = ToggleColumn() address = tables.TemplateColumn(IPADDRESS_LINK, verbose_name='IP Address') - vrf = tables.LinkColumn('ipam:vrf', args=[Accessor('vrf.pk')], default='Global', verbose_name='VRF') + vrf = tables.TemplateColumn(VRF_LINK, verbose_name='VRF') tenant = tables.TemplateColumn(TENANT_LINK, verbose_name='Tenant') device = tables.LinkColumn('dcim:device', args=[Accessor('interface.device.pk')], orderable=False, verbose_name='Device') diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 6509f8cae..a2dd6c313 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -305,7 +305,7 @@ class RoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # class PrefixListView(ObjectListView): - queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'role') + queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'role') filter = filters.PrefixFilter filter_form = forms.PrefixFilterForm table = tables.PrefixTable @@ -445,7 +445,7 @@ def prefix_ipaddresses(request, pk): # class IPAddressListView(ObjectListView): - queryset = IPAddress.objects.select_related('vrf__tenant', 'interface__device') + queryset = IPAddress.objects.select_related('vrf__tenant', 'tenant', 'interface__device') filter = filters.IPAddressFilter filter_form = forms.IPAddressFilterForm table = tables.IPAddressTable @@ -550,7 +550,7 @@ class IPAddressBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # class VLANGroupListView(ObjectListView): - queryset = VLANGroup.objects.annotate(vlan_count=Count('vlans')) + queryset = VLANGroup.objects.select_related('site').annotate(vlan_count=Count('vlans')) filter = filters.VLANGroupFilter filter_form = forms.VLANGroupFilterForm table = tables.VLANGroupTable @@ -562,6 +562,7 @@ class VLANGroupEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'ipam.change_vlangroup' model = VLANGroup form_class = forms.VLANGroupForm + success_url = 'ipam:vlangroup_list' cancel_url = 'ipam:vlangroup_list' @@ -576,7 +577,7 @@ class VLANGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # class VLANListView(ObjectListView): - queryset = VLAN.objects.select_related('site', 'role') + queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role') filter = filters.VLANFilter filter_form = forms.VLANFilterForm table = tables.VLANTable diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index d1272f4a2..4891d5ce5 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -12,7 +12,7 @@ except ImportError: "the documentation.") -VERSION = '1.4.2' +VERSION = '1.5.0' # Import local configuration for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']: @@ -172,7 +172,6 @@ MESSAGE_TAGS = { # Authentication URLs LOGIN_URL = '/login/' LOGIN_REDIRECT_URL = '/' -LOGOUT_URL = '/logout/' # Secrets SECRETS_MIN_PUBKEY_SIZE = 2048 diff --git a/netbox/project-static/css/base.css b/netbox/project-static/css/base.css index 13f4e1455..193fddf14 100644 --- a/netbox/project-static/css/base.css +++ b/netbox/project-static/css/base.css @@ -21,6 +21,9 @@ body { margin: 0 auto -61px; /* the bottom margin is the negative value of the footer's height */ padding-bottom: 30px; } +.navbar-brand { + padding: 12px 15px 8px; +} .footer, .push { height: 60px; /* .push must be the same height as .footer */ } @@ -291,27 +294,39 @@ ul.rack_near_face li.empty:hover a { display: block; } -/* Rack elevation colors (from http://flatuicolors.com) */ -.teal { background-color: #1abc9c; border-bottom: 1px solid #16a085; } -.teal:hover { background-color: #16a085; } -.green { background-color: #2ecc71; border-bottom: 1px solid #27ae60; } -.green:hover { background-color: #27ae60; } -.blue { background-color: #3498db; border-bottom: 1px solid #2980b9; } -.blue:hover { background-color: #2980b9; } -.purple { background-color: #9b59b6; border-bottom: 1px solid #8e44ad; } -.purple:hover { background-color: #8e44ad; } -.yellow { background-color: #f1c40f; border-bottom: 1px solid #f39c12; } -.yellow:hover { background-color: #f39c12; } -.orange { background-color: #e67e22; border-bottom: 1px solid #d35400; } -.orange:hover { background-color: #d35400; } -.red { background-color: #e74c3c; border-bottom: 1px solid #c0392b; } -.red:hover { background-color: #c0392b; } -.light_gray { background-color: #dce2e3; border-bottom: 1px solid #bdc3c7; } -.light_gray:hover { background-color: #bdc3c7; } -.medium_gray { background-color: #95a5a6; border-bottom: 1px solid #7f8c8d; } -.medium_gray:hover { background-color: #7f8c8d; } -.dark_gray { background-color: #34495e; border-bottom: 1px solid #2c3e50; } -.dark_gray:hover { background-color: #2c3e50; } +/* Colors (from http://flatuicolors.com) */ +.teal { background-color: #1abc9c; } +.green { background-color: #2ecc71; } +.blue { background-color: #3498db; } +.purple { background-color: #9b59b6; } +.yellow { background-color: #f1c40f; } +.orange { background-color: #e67e22; } +.red { background-color: #e74c3c; } +.light_gray { background-color: #dce2e3; } +.medium_gray { background-color: #95a5a6; } +.dark_gray { background-color: #34495e; } + +/* Rack elevation coloring */ +ul.rack .teal { border-bottom: 1px solid #16a085; } +ul.rack .teal:hover { background-color: #16a085; } +ul.rack .green { border-bottom: 1px solid #27ae60; } +ul.rack .green:hover { background-color: #27ae60; } +ul.rack .blue { border-bottom: 1px solid #2980b9; } +ul.rack .blue:hover { background-color: #2980b9; } +ul.rack .purple { border-bottom: 1px solid #8e44ad; } +ul.rack .purple:hover { background-color: #8e44ad; } +ul.rack .yellow { border-bottom: 1px solid #f39c12; } +ul.rack .yellow:hover { background-color: #f39c12; } +ul.rack .orange { border-bottom: 1px solid #d35400; } +ul.rack .orange:hover { background-color: #d35400; } +ul.rack .red { border-bottom: 1px solid #c0392b; } +ul.rack .red:hover { background-color: #c0392b; } +ul.rack .light_gray { border-bottom: 1px solid #bdc3c7; } +ul.rack .light_gray:hover { background-color: #bdc3c7; } +ul.rack .medium_gray { border-bottom: 1px solid #7f8c8d; } +ul.rack .medium_gray:hover { background-color: #7f8c8d; } +ul.rack .dark_gray { border-bottom: 1px solid #2c3e50; } +ul.rack .dark_gray:hover { background-color: #2c3e50; } /* Misc */ .banner-bottom { diff --git a/netbox/project-static/img/netbox.ico b/netbox/project-static/img/netbox.ico new file mode 100644 index 000000000..bb1051549 Binary files /dev/null and b/netbox/project-static/img/netbox.ico differ diff --git a/netbox/project-static/img/netbox_logo.png b/netbox/project-static/img/netbox_logo.png new file mode 100644 index 000000000..4917b8fb1 Binary files /dev/null and b/netbox/project-static/img/netbox_logo.png differ diff --git a/netbox/templates/_base.html b/netbox/templates/_base.html index 570f73a4d..f0291f250 100644 --- a/netbox/templates/_base.html +++ b/netbox/templates/_base.html @@ -8,6 +8,7 @@ +