Compare commits

..

44 Commits

Author SHA1 Message Date
Jeremy Stretch
58e3d5ae09 Merge pull request #474 from digitalocean/develop
Release v1.5.2
2016-08-16 09:33:57 -04:00
Jeremy Stretch
2eb8b4fe71 Release v1.5.2 2016-08-16 09:32:53 -04:00
Jeremy Stretch
989ec721d3 Fixes #472: Hide the connection button for interfaces which have a circuit terminated to them 2016-08-16 09:29:20 -04:00
Jeremy Stretch
d045429b51 Fixes #469: Added missing import buttons to list views 2016-08-15 17:57:17 -04:00
Jeremy Stretch
78c3b25f0a Fixes #467: Include prefixes and IPs which inherit tenancy from their VRF in tenant stats 2016-08-15 16:11:17 -04:00
Jeremy Stretch
13136d0ccb Fixes #468: Added validation to prevent a connected interface from having its form factor set to 'virtual' 2016-08-15 15:52:06 -04:00
Jeremy Stretch
8faa16c831 Fixes #460: For real this time 2016-08-15 15:39:48 -04:00
Jeremy Stretch
4f774f8ba6 Fixes #460: Corrected ordering of IP addresses with differing prefix lengths 2016-08-13 01:02:03 -04:00
Jeremy Stretch
bf1b8ab9b8 Enable custom export templates for Tenants 2016-08-12 13:29:24 -04:00
Jeremy Stretch
b74f338aa1 Fixes #463: Prevent prepopulation of livesearch field with '---------' 2016-08-12 11:24:29 -04:00
Jeremy Stretch
35aa8acd09 Post-release version bump 2016-08-11 13:54:07 -04:00
Jeremy Stretch
6a48b310d2 Merge pull request #459 from digitalocean/develop
Release v1.5.1
2016-08-11 13:53:34 -04:00
Jeremy Stretch
0b4d3446bf Release v1.5.1 2016-08-11 13:47:09 -04:00
Jeremy Stretch
2b8e06faa2 Added Device asset_tag field to API tests 2016-08-11 13:46:44 -04:00
Jeremy Stretch
c86a1123f0 Closes #421: Added asset_tag field to devices 2016-08-11 11:58:21 -04:00
Jeremy Stretch
e2ad1d4be0 Closes #456: Added IP search box to home page 2016-08-11 11:06:56 -04:00
Jeremy Stretch
219f084805 Fixes #457: Added role field to rack edit form 2016-08-11 10:19:50 -04:00
Jeremy Stretch
6f12297dcf Fixes #441: Added openssl-devel to list of dependencies 2016-08-10 22:37:51 -04:00
Jeremy Stretch
098ff961e3 Fixes #454: Correct typecasting on rack export 2016-08-10 22:27:27 -04:00
Jeremy Stretch
74528c6036 Colorized roles in rack and device lists 2016-08-10 22:20:09 -04:00
Jeremy Stretch
3324f397d9 Post-release version bump 2016-08-10 17:48:14 -04:00
Jeremy Stretch
2509405465 Merge pull request #453 from digitalocean/develop
Release v1.5.0
2016-08-10 17:47:48 -04:00
Jeremy Stretch
0f779dd682 Release v1.5.0 2016-08-10 17:41:08 -04:00
Jeremy Stretch
6bf8a7707c Reverting to psycopg2==2.6.1 due to pip installation issues 2016-08-10 17:30:31 -04:00
Jeremy Stretch
8ff752a58b Updated requirements for v1.5 2016-08-10 16:42:42 -04:00
Jeremy Stretch
7938c9211f Removed obsolete setting LOGOUT_URL 2016-08-10 16:08:02 -04:00
Jeremy Stretch
1f4d5b84b3 Added status field to DeviceTestCase tests (Django 1.10 BooleanField issue) 2016-08-10 16:04:44 -04:00
Jeremy Stretch
3a018888c8 Updated docs for new features 2016-08-10 14:09:38 -04:00
Jeremy Stretch
a89fb734a0 Added colored labels for device and rack roles 2016-08-10 13:35:55 -04:00
Jeremy Stretch
b2f03dfb16 Fixed redirect after editing RackGroups and VLANGroups 2016-08-10 13:13:26 -04:00
Jeremy Stretch
cbde6e8321 Miscellaneous query optimizations 2016-08-10 13:07:11 -04:00
Jeremy Stretch
ed03449164 Closes #241: Introduced rack roles 2016-08-10 11:52:27 -04:00
Jeremy Stretch
47a89999b8 Extended DCIM API to include a device modules list 2016-08-10 10:44:37 -04:00
Jeremy Stretch
a7dd2695a2 Closes #157: Added manufacturer field to module model 2016-08-10 10:24:47 -04:00
Jeremy Stretch
8035538b74 Fixes #445: Limit rack height to between 1U and 100U (inclusive) 2016-08-09 17:19:23 -04:00
Jeremy Stretch
474b19d927 We have a logo! 2016-08-09 14:57:14 -04:00
Jeremy Stretch
31ebbb3324 Fixes #444: Corrected prefix model validation 2016-08-09 09:50:50 -04:00
Jeremy Stretch
bddd29c99f Fixes #443: Correctly display and initialize VRF for creation of new IP addresses 2016-08-08 21:44:54 -04:00
Jeremy Stretch
e7116b81a4 #180: Added type and width fields to Rack model 2016-08-08 18:01:15 -04:00
Jeremy Stretch
d463161619 Closes #149: Added upstream_speed field to Circuit 2016-08-08 16:51:19 -04:00
Jeremy Stretch
5116db3344 Fixes #442: Correct child device import instructions 2016-08-08 12:28:38 -04:00
Jeremy Stretch
b131fbd774 Corred typo in HTML 2016-08-08 12:04:20 -04:00
Jeremy Stretch
324a5e10d7 Fixes #433: Correct form validation when editing child devices 2016-08-08 09:45:44 -04:00
Jeremy Stretch
69debfdefa Post-release version bump 2016-08-06 16:32:54 -04:00
59 changed files with 881 additions and 183 deletions

View File

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

View File

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

View File

@@ -10,9 +10,10 @@ NetBox requires following system dependencies:
* libffi-dev
* graphviz
* libpq-dev
* libssl-dev
```
# sudo apt-get install -y python2.7 python-dev git python-pip libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev
# sudo apt-get install -y python2.7 python-dev python-pip libxml2-dev libxslt1-dev libffi-dev graphviz libpq-dev libssl-dev
```
You may opt to install NetBox either from a numbered release or by cloning the master branch of its repository on GitHub.

BIN
docs/netbox_logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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']
#
@@ -175,7 +183,8 @@ class DeviceAdmin(admin.ModelAdmin):
DeviceBayAdmin,
ModuleAdmin,
]
list_display = ['display_name', 'device_type', 'device_role', 'primary_ip', 'rack', 'position', 'serial']
list_display = ['display_name', 'device_type', 'device_role', 'primary_ip', 'rack', 'position', 'asset_tag',
'serial']
list_filter = ['device_role']
def get_queryset(self, request):

View File

@@ -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)
@@ -231,8 +250,9 @@ class DeviceSerializer(serializers.ModelSerializer):
class Meta:
model = Device
fields = ['id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'rack',
'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4', 'primary_ip6', 'comments']
fields = ['id', 'name', 'display_name', 'device_type', 'device_role', 'tenant', 'platform', 'serial',
'asset_tag', 'rack', 'position', 'face', 'parent_device', 'status', 'primary_ip', 'primary_ip4',
'primary_ip6', 'comments']
def get_parent_device(self, obj):
try:
@@ -384,6 +404,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
#

View File

@@ -18,6 +18,10 @@ urlpatterns = [
url(r'^rack-groups/$', RackGroupListView.as_view(), name='rackgroup_list'),
url(r'^rack-groups/(?P<pk>\d+)/$', RackGroupDetailView.as_view(), name='rackgroup_detail'),
# Rack roles
url(r'^rack-roles/$', RackRoleListView.as_view(), name='rackrole_list'),
url(r'^rack-roles/(?P<pk>\d+)/$', RackRoleDetailView.as_view(), name='rackrole_detail'),
# Racks
url(r'^racks/$', RackListView.as_view(), name='rack_list'),
url(r'^racks/(?P<pk>\d+)/$', RackDetailView.as_view(), name='rack_detail'),
@@ -50,6 +54,7 @@ urlpatterns = [
url(r'^devices/(?P<pk>\d+)/power-outlets/$', PowerOutletListView.as_view(), name='device_poweroutlets'),
url(r'^devices/(?P<pk>\d+)/interfaces/$', InterfaceListView.as_view(), name='device_interfaces'),
url(r'^devices/(?P<pk>\d+)/device-bays/$', DeviceBayListView.as_view(), name='device_devicebays'),
url(r'^devices/(?P<pk>\d+)/modules/$', ModuleListView.as_view(), name='device_modules'),
# Console ports
url(r'^console-ports/(?P<pk>\d+)/$', ConsolePortView.as_view(), name='consoleport'),

View File

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

View File

@@ -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
@@ -228,15 +239,16 @@ class DeviceFilter(django_filters.FilterSet):
class Meta:
model = Device
fields = ['q', 'name', 'site_id', 'site', 'rack_id', 'role_id', 'role', 'device_type_id', 'manufacturer_id',
'manufacturer', 'model', 'platform_id', 'platform', 'status', 'is_console_server', 'is_pdu',
'is_network_device']
fields = ['q', 'name', 'serial', 'asset_tag', 'site_id', 'site', 'rack_id', 'role_id', 'role', 'device_type_id',
'manufacturer_id', 'manufacturer', 'model', 'platform_id', 'platform', 'status', 'is_console_server',
'is_pdu', 'is_network_device']
def search(self, queryset, value):
return queryset.filter(
Q(name__icontains=value) |
Q(serial__icontains=value) |
Q(modules__serial__icontains=value) |
Q(serial__icontains=value.strip()) |
Q(modules__serial__icontains=value.strip()) |
Q(asset_tag=value.strip()) |
Q(comments__icontains=value)
).distinct()

View File

@@ -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}))
#
@@ -362,8 +425,8 @@ class DeviceForm(forms.ModelForm, BootstrapMixin):
class Meta:
model = Device
fields = ['name', 'device_role', 'tenant', 'device_type', 'serial', 'site', 'rack', 'position', 'face', 'status',
'platform', 'primary_ip4', 'primary_ip6', 'comments']
fields = ['name', 'device_role', 'tenant', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'position',
'face', 'status', 'platform', 'primary_ip4', 'primary_ip6', 'comments']
help_texts = {
'device_role': "The function this device serves",
'serial': "Chassis serial number",
@@ -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):
@@ -481,8 +546,8 @@ class DeviceFromCSVForm(BaseDeviceFromCSVForm):
face = forms.CharField(required=False)
class Meta(BaseDeviceFromCSVForm.Meta):
fields = ['name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'site',
'rack_name', 'position', 'face']
fields = ['name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag',
'site', 'rack_name', 'position', 'face']
def clean(self):
@@ -517,8 +582,8 @@ class ChildDeviceFromCSVForm(BaseDeviceFromCSVForm):
device_bay_name = forms.CharField(required=False)
class Meta(BaseDeviceFromCSVForm.Meta):
fields = ['name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'parent',
'device_bay_name']
fields = ['name', 'device_role', 'tenant', 'manufacturer', 'model_name', 'platform', 'serial', 'asset_tag',
'parent', 'device_bay_name']
def clean(self):
@@ -1254,4 +1319,4 @@ class ModuleForm(forms.ModelForm, BootstrapMixin):
class Meta:
model = Module
fields = ['name', 'part_id', 'serial']
fields = ['name', 'manufacturer', 'part_id', 'serial']

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,21 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10 on 2016-08-11 15:42
from __future__ import unicode_literals
from django.db import migrations
import utilities.fields
class Migration(migrations.Migration):
dependencies = [
('dcim', '0017_rack_add_role'),
]
operations = [
migrations.AddField(
model_name='device',
name='asset_tag',
field=utilities.fields.NullableCharField(blank=True, help_text=b'A unique tag used to identify this device', max_length=50, null=True, unique=True, verbose_name=b'Asset tag'),
),
]

View File

@@ -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 '',
str(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']
@@ -683,6 +737,8 @@ class Device(CreatedUpdatedModel):
platform = models.ForeignKey('Platform', related_name='devices', blank=True, null=True, on_delete=models.SET_NULL)
name = NullableCharField(max_length=50, blank=True, null=True, unique=True)
serial = models.CharField(max_length=50, blank=True, verbose_name='Serial number')
asset_tag = NullableCharField(max_length=50, blank=True, null=True, unique=True, verbose_name='Asset tag',
help_text='A unique tag used to identify this device')
rack = models.ForeignKey('Rack', related_name='devices', on_delete=models.PROTECT)
position = models.PositiveSmallIntegerField(blank=True, null=True, validators=[MinValueValidator(1)],
verbose_name='Position (U)',
@@ -778,6 +834,7 @@ class Device(CreatedUpdatedModel):
self.device_type.model,
self.platform.name if self.platform else '',
self.serial,
self.asset_tag if self.asset_tag else '',
self.rack.site.name,
self.rack.name,
str(self.position) if self.position else '',
@@ -975,6 +1032,13 @@ class Interface(models.Model):
def __unicode__(self):
return self.name
def clean(self):
if self.form_factor == IFACE_FF_VIRTUAL and self.is_connected:
raise ValidationError({'form_factor': "Virtual interfaces cannot be connected to another interface or "
"circuit. Disconnect the interface or choose a physical form "
"factor."})
@property
def is_physical(self):
return self.form_factor != IFACE_FF_VIRTUAL
@@ -1073,6 +1137,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')

View File

@@ -10,6 +10,10 @@ from .models import (
)
COLOR_LABEL = """
<label class="label {{ record.color }}">{{ record }}</label>
"""
DEVICE_LINK = """
<a href="{% url 'dcim:device' pk=record.pk %}">
{{ record.name|default:'<span class="label label-info">Unnamed device</span>' }}
@@ -22,6 +26,20 @@ RACKGROUP_ACTIONS = """
{% endif %}
"""
RACKROLE_ACTIONS = """
{% if perms.dcim.change_rackrole %}
<a href="{% url 'dcim:rackrole_edit' pk=record.pk %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
{% endif %}
"""
RACK_ROLE = """
{% if record.role %}
<label class="label {{ record.role.color }}">{{ value }}</label>
{% else %}
&mdash;
{% endif %}
"""
DEVICEROLE_ACTIONS = """
{% if perms.dcim.change_devicerole %}
<a href="{% url 'dcim:devicerole_edit' slug=record.slug %}" class="btn btn-xs btn-warning"><i class="glyphicon glyphicon-pencil" aria-hidden="true"></i></a>
@@ -40,6 +58,10 @@ PLATFORM_ACTIONS = """
{% endif %}
"""
DEVICE_ROLE = """
<label class="label {{ record.device_role.color }}">{{ value }}</label>
"""
STATUS_ICON = """
{% if record.status %}
<span class="glyphicon glyphicon-ok-sign text-success" title="Active" aria-hidden="true"></span>
@@ -94,6 +116,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 = tables.TemplateColumn(COLOR_LABEL, 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 +145,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.TemplateColumn(RACK_ROLE, 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 +153,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 +274,14 @@ class DeviceRoleTable(BaseTable):
pk = ToggleColumn()
name = tables.LinkColumn(verbose_name='Name')
device_count = tables.Column(verbose_name='Devices')
color = tables.TemplateColumn(COLOR_LABEL, 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')
#
@@ -270,7 +311,7 @@ class DeviceTable(BaseTable):
tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant')
site = tables.Column(accessor=Accessor('rack.site'), verbose_name='Site')
rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')], verbose_name='Rack')
device_role = tables.Column(verbose_name='Role')
device_role = tables.TemplateColumn(DEVICE_ROLE, verbose_name='Role')
device_type = tables.Column(verbose_name='Type')
primary_ip = tables.TemplateColumn(orderable=False, verbose_name='IP Address',
template_code="{{ record.primary_ip.address.ip }}")

View File

@@ -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',
@@ -318,6 +327,7 @@ class DeviceTest(APITestCase):
'tenant',
'platform',
'serial',
'asset_tag',
'rack',
'position',
'face',
@@ -361,6 +371,7 @@ class DeviceTest(APITestCase):
def test_get_list_flat(self, endpoint='/api/dcim/devices/?format=json_flat'):
flat_fields = [
'asset_tag',
'comments',
'device_role_id',
'device_role_name',

View File

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

View File

@@ -26,6 +26,12 @@ urlpatterns = [
url(r'^rack-groups/delete/$', views.RackGroupBulkDeleteView.as_view(), name='rackgroup_bulk_delete'),
url(r'^rack-groups/(?P<pk>\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<pk>\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'),

View File

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

View File

@@ -18,9 +18,10 @@ GRAPH_TYPE_CHOICES = (
)
EXPORTTEMPLATE_MODELS = [
'site', 'rack', 'device', 'consoleport', 'powerport', 'interfaceconnection',
'aggregate', 'prefix', 'ipaddress', 'vlan',
'provider', 'circuit'
'site', 'rack', 'device', 'consoleport', 'powerport', 'interfaceconnection', # DCIM
'aggregate', 'prefix', 'ipaddress', 'vlan', # IPAM
'provider', 'circuit', # Circuits
'tenant', # Tenants
]
ACTION_CREATE = 1

View File

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

View File

@@ -5,6 +5,7 @@ from django.core.exceptions import ValidationError
from django.core.urlresolvers import reverse
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.db.models.expressions import RawSQL
from dcim.models import Interface
from tenancy.models import Tenant
@@ -254,12 +255,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:
@@ -294,6 +296,20 @@ class Prefix(CreatedUpdatedModel):
return STATUS_CHOICE_CLASSES[self.status]
class IPAddressManager(models.Manager):
def get_queryset(self):
"""
By default, PostgreSQL will order INETs with shorter (larger) prefix lengths ahead of those with longer
(smaller) masks. This makes no sense when ordering IPs, which should be ordered solely by family and host
address. We can use HOST() to extract just the host portion of the address (ignoring its mask), but we must
then re-cast this value to INET() so that records will be ordered properly. We are essentially re-casting each
IP address as a /32 or /128.
"""
qs = super(IPAddressManager, self).get_queryset()
return qs.annotate(host=RawSQL('INET(HOST(ipam_ipaddress.address))', [])).order_by('family', 'host')
class IPAddress(CreatedUpdatedModel):
"""
An IPAddress represents an individual IPv4 or IPv6 address and its mask. The mask length should match what is
@@ -316,6 +332,8 @@ class IPAddress(CreatedUpdatedModel):
null=True, verbose_name='NAT IP (inside)')
description = models.CharField(max_length=100, blank=True)
objects = IPAddressManager()
class Meta:
ordering = ['family', 'address']
verbose_name = 'IP address'

View File

@@ -43,12 +43,22 @@ IPADDRESS_LINK = """
{% if record.pk %}
<a href="{{ record.get_absolute_url }}">{{ record.address }}</a>
{% elif perms.ipam.add_ipaddress %}
<a href="{% url 'ipam:ipaddress_add' %}?address={{ record.1 }}" class="btn btn-xs btn-success">{% if record.0 <= 65536 %}{{ record.0 }}{% else %}Lots of{% endif %} free IP{{ record.0|pluralize }}</a>
<a href="{% url 'ipam:ipaddress_add' %}?address={{ record.1 }}{% if prefix.vrf %}&vrf={{ prefix.vrf.pk }}{% endif %}" class="btn btn-xs btn-success">{% if record.0 <= 65536 %}{{ record.0 }}{% else %}Lots of{% endif %} free IP{{ record.0|pluralize }}</a>
{% else %}
{{ record.0 }}
{% endif %}
"""
VRF_LINK = """
{% if record.vrf %}
<a href="{{ record.vrf.get_absolute_url }}">{{ record.vrf }}</a>
{% elif prefix.vrf %}
{{ prefix.vrf }}
{% else %}
Global
{% endif %}
"""
STATUS_LABEL = """
{% if record.pk %}
<span class="label label-{{ record.get_status_class }}">{{ record.get_status_display }}</span>
@@ -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')

View File

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

View File

@@ -12,7 +12,7 @@ except ImportError:
"the documentation.")
VERSION = '1.4.2'
VERSION = '1.5.2'
# 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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -8,9 +8,15 @@ $(document).ready(function() {
}
// Update livesearch text when real field changes
search_field.val(real_field.children('option:selected').text());
real_field.change(function() {
if (real_field.val()) {
search_field.val(real_field.children('option:selected').text());
}
real_field.change(function() {
if (real_field.val()) {
search_field.val(real_field.children('option:selected').text());
} else {
search_field.val('');
}
});
search_field.autocomplete({

View File

@@ -8,6 +8,7 @@
<link rel="stylesheet" href="{% static 'font-awesome-4.6.3/css/font-awesome.min.css' %}">
<link rel="stylesheet" href="{% static 'jquery-ui-1.11.4/jquery-ui.css' %}">
<link rel="stylesheet" href="{% static 'css/base.css' %}">
<link rel="icon" type="image/png" href="{% static 'img/netbox.ico' %}" />
</head>
<body>
<nav class="navbar navbar-default navbar-fixed-top">
@@ -19,7 +20,9 @@
<span class="icon-bar"></span>
<span class="icon-bar"></span>
</button>
<a class="navbar-brand" href="/">NetBox</a>
<a class="navbar-brand" href="/">
<img src="{% static 'img/netbox_logo.png' %}" />
</a>
</div>
<div id="navbar" class="navbar-collapse collapse">
{% if request.user.is_authenticated or not settings.LOGIN_REQUIRED %}
@@ -58,6 +61,11 @@
{% if perms.dcim.add_rackgroup %}
<li><a href="{% url 'dcim:rackgroup_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add a Rack Group</a></li>
{% endif %}
<li class="divider"></li>
<li><a href="{% url 'dcim:rackrole_list' %}"><i class="fa fa-search" aria-hidden="true"></i> Rack Roles</a></li>
{% if perms.dcim.add_rackrole %}
<li><a href="{% url 'dcim:rackrole_add' %}"><i class="fa fa-plus" aria-hidden="true"></i> Add a Rack Role</a></li>
{% endif %}
</ul>
</li>
<li class="dropdown{% if request.path|startswith:'/dcim/device' or request.path|startswith:'/dcim/manufacturers/' or request.path|startswith:'/dcim/platforms/' %} active{% endif %}">

View File

@@ -82,12 +82,13 @@
</td>
</tr>
<tr>
<td>Port Speed</td>
<td>Speed</td>
<td>
{% if circuit.port_speed %}
{{ circuit.port_speed_human }}
{% if circuit.upstream_speed %}
<i class="fa fa-arrow-down" title="Downstream"></i> {{ circuit.port_speed_human }} &nbsp;
<i class="fa fa-arrow-up" title="Upstream"></i> {{ circuit.upstream_speed_human }}
{% else %}
<span class="text-muted">N/A</span>
{{ circuit.port_speed_human }}
{% endif %}
</td>
</tr>

View File

@@ -19,6 +19,7 @@
<div class="panel-heading"><strong>Bandwidth</strong></div>
<div class="panel-body">
{% render_field form.port_speed %}
{% render_field form.upstream_speed %}
{% render_field form.commit_rate %}
</div>
</div>

View File

@@ -60,8 +60,13 @@
</tr>
<tr>
<td>Port Speed</td>
<td>Physical speed in Kbps/td>
<td>10000</td>
<td>Physical speed in Kbps</td>
<td>100000</td>
</tr>
<tr>
<td>Upstream Speed</td>
<td>Upstream speed in Kbps (optional)</td>
<td>20000</td>
</tr>
<tr>
<td>Commit rate</td>
@@ -81,7 +86,7 @@
</tbody>
</table>
<h4>Example</h4>
<pre>IC-603122,TeliaSonera,Transit,Strickland Propane,ASH-4,2016-02-23,10000,2000,937649,PP8371 ports 13/14</pre>
<pre>IC-603122,TeliaSonera,Transit,Strickland Propane,ASH-4,2016-02-23,100000,,2000,937649,PP8371 ports 13/14</pre>
</div>
</div>
{% endblock %}

View File

@@ -10,6 +10,10 @@
<span class="fa fa-plus" aria-hidden="true"></span>
Add a circuit
</a>
<a href="{% url 'circuits:circuit_import' %}" class="btn btn-info">
<span class="fa fa-download" aria-hidden="true"></span>
Import circuits
</a>
{% endif %}
{% include 'inc/export_button.html' with obj_type='circuits' %}
</div>

View File

@@ -9,6 +9,10 @@
<span class="fa fa-plus" aria-hidden="true"></span>
Add a provider
</a>
<a href="{% url 'circuits:provider_import' %}" class="btn btn-info">
<span class="fa fa-download" aria-hidden="true"></span>
Import providers
</a>
{% endif %}
{% include 'inc/export_button.html' with obj_type='providers' %}
</div>

View File

@@ -60,7 +60,7 @@
</td>
</tr>
<tr>
<td>Serial</td>
<td>Serial Number</td>
<td>
{% if device.serial %}
<span>{{ device.serial }}</span>
@@ -69,6 +69,16 @@
{% endif %}
</td>
</tr>
<tr>
<td>Asset Tag</td>
<td>
{% if device.asset_tag %}
<span>{{ device.asset_tag }}</span>
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</td>
</tr>
<tr>
<td>Created</td>
<td>{{ device.created }}</td>
@@ -87,7 +97,7 @@
<tr>
<td>Role</td>
<td>
<a href="{% url 'dcim:device_list' %}?role={{ device.device_role.slug }}">{{ device.device_role }}</a>
<a href="{{ device.device_role.get_absolute_url }}">{{ device.device_role }}</a>
</td>
</tr>
<tr>

View File

@@ -16,6 +16,7 @@
{% render_field form.manufacturer %}
{% render_field form.device_type %}
{% render_field form.serial %}
{% render_field form.asset_tag %}
</div>
</div>
<div class="panel panel-default">

View File

@@ -57,10 +57,15 @@
<td>Juniper Junos</td>
</tr>
<tr>
<td>Serial</td>
<td>Serial number (optional)</td>
<td>Serial number</td>
<td>Physical serial number (optional)</td>
<td>CAB00577291</td>
</tr>
<tr>
<td>Asset tag</td>
<td>Unique alphanumeric tag (optional)</td>
<td>ABC123456</td>
</tr>
<tr>
<td>Site</td>
<td>Site name</td>
@@ -84,7 +89,7 @@
</tbody>
</table>
<h4>Example</h4>
<pre>rack101_sw1,ToR Switch,Pied Piper,Juniper,EX4300-48T,Juniper Junos,CAB00577291,Ashburn-VA,R101,21,Rear</pre>
<pre>rack101_sw1,ToR Switch,Pied Piper,Juniper,EX4300-48T,Juniper Junos,CAB00577291,ABC123456,Ashburn-VA,R101,21,Rear</pre>
</div>
</div>
{% endblock %}

View File

@@ -36,6 +36,11 @@
<td>Functional role of device</td>
<td>Blade Server</td>
</tr>
<tr>
<td>Tenant</td>
<td>Name of tenant (optional)</td>
<td>Pied Piper</td>
</tr>
<tr>
<td>Device manufacturer</td>
<td>Hardware manufacturer</td>
@@ -52,10 +57,15 @@
<td>Linux</td>
</tr>
<tr>
<td>Serial</td>
<td>Serial number (optional)</td>
<td>Serial number</td>
<td>Physical serial number (optional)</td>
<td>CAB00577291</td>
</tr>
<tr>
<td>Asset tag</td>
<td>Unique alphanumeric tag (optional)</td>
<td>ABC123456</td>
</tr>
<tr>
<td>Parent device</td>
<td>Parent device</td>
@@ -69,7 +79,7 @@
</tbody>
</table>
<h4>Example</h4>
<pre>Blade12,Blade Server,Dell,BS2000T,Linux,CAB00577291,Server101,Slot4</pre>
<pre>Blade12,Blade Server,Pied Piper,Dell,BS2000T,Linux,CAB00577291,ABC123456,Server101,Slot4</pre>
</div>
</div>
{% endblock %}

View File

@@ -17,7 +17,23 @@
</tr>
<tr>
<td>Serial Number</td>
<td>{{ device.serial }}</td>
<td>
{% if device.serial %}
<span>{{ device.serial }}</span>
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</td>
</tr>
<tr>
<td>Asset Tag</td>
<td>
{% if device.asset_tag %}
<span>{{ device.asset_tag }}</span>
{% else %}
<span class="text-muted">N/A</span>
{% endif %}
</td>
</tr>
</table>
</div>
@@ -32,6 +48,7 @@
<tr>
<th>Module</th>
<th></th>
<th>Manufacturer</th>
<th>Part Number</th>
<th>Serial Number</th>
<th></th>
@@ -42,6 +59,7 @@
<tr>
<td>{{ m.name }}</td>
<td>{% if not m.discovered %}<i class="fa fa-asterisk" title="Manually created"></i>{% endif %}</td>
<td>{{ m.manufacturer|default:'' }}</td>
<td>{{ m.part_id }}</td>
<td>{{ m.serial }}</td>
<td class="text-right">
@@ -57,6 +75,7 @@
<tr>
<td style="padding-left: 20px">{{ m2.name }}</td>
<td>{% if not m2.discovered %}<i class="fa fa-asterisk" title="Manually created"></i>{% endif %}</td>
<td>{{ m2.manufacturer|default:'' }}</td>
<td>{{ m2.part_id }}</td>
<td>{{ m2.serial }}</td>
<td class="text-right">
@@ -72,6 +91,7 @@
<tr>
<td style="padding-left: 40px">{{ m3.name }}</td>
<td>{% if not m3.discovered %}<i class="fa fa-asterisk" title="Manually created"></i>{% endif %}</td>
<td>{{ m3.manufacturer|default:'' }}</td>
<td>{{ m3.part_id }}</td>
<td>{{ m3.serial }}</td>
<td class="text-right">
@@ -87,6 +107,7 @@
<tr>
<td style="padding-left: 60px">{{ m4.name }}</td>
<td>{% if not m4.discovered %}<i class="fa fa-asterisk" title="Manually created"></i>{% endif %}</td>
<td>{{ m4.manufacturer|default:'' }}</td>
<td>{{ m4.part_id }}</td>
<td>{{ m4.serial }}</td>
<td class="text-right">

View File

@@ -56,6 +56,10 @@
<a href="{% url 'dcim:interfaceconnection_delete' pk=iface.connection.pk %}?device={{ device.pk }}" class="btn btn-danger btn-xs" title="Delete connection">
<i class="glyphicon glyphicon-remove" aria-hidden="true"></i>
</a>
{% elif iface.circuit and perms.circuits.change_circuit %}
<a href="{% url 'circuits:circuit_edit' pk=iface.circuit.pk %}" class="btn btn-danger btn-xs" title="Edit circuit">
<i class="glyphicon glyphicon-remove" aria-hidden="true"></i>
</a>
{% else %}
<a href="{% url 'dcim:interfaceconnection_add' pk=device.pk %}?interface={{ iface.pk }}" class="btn btn-success btn-xs" title="Connect">
<i class="glyphicon glyphicon-plus" aria-hidden="true"></i>

View File

@@ -96,6 +96,30 @@
{% endif %}
</td>
</tr>
<tr>
<td>Role</td>
<td>
{% if rack.role %}
<a href="{{ rack.role.get_absolute_url }}">{{ rack.role }}</a>
{% else %}
<span class="text-muted">None</span>
{% endif %}
</td>
</tr>
<tr>
<td>Type</td>
<td>
{% if rack.type %}
{{ rack.get_type_display }}
{% else %}
<span class="text-muted">None</span>
{% endif %}
</td>
</tr>
<tr>
<td>Width</td>
<td>{{ rack.get_width_display }}</td>
</tr>
<tr>
<td>Height</td>
<td>{{ rack.u_height }}U</td>

View File

@@ -4,13 +4,24 @@
{% block title %}Rack Bulk Edit{% endblock %}
{% block select_objects_table %}
<tr>
<th>Name</th>
<th>Site</th>
<th>Group</th>
<th>Tenant</th>
<th>Type</th>
<th>Width</th>
<th>Height</th>
</tr>
{% for rack in selected_objects %}
<tr>
<td><a href="{% url 'dcim:rack' pk=rack.pk %}">{{ rack }}</a></td>
<td>{{ rack.facility_id }}</td>
<td>{{ rack.site }}</td>
<td>{{ rack.group }}</td>
<td>{{ rack.tenant }}</td>
<td>{{ rack.u_height }}</td>
<td>{{ rack.get_type_display }}</td>
<td>{{ rack.get_width_display }}</td>
<td>{{ rack.u_height }}U</td>
</tr>
{% endfor %}
{% endblock %}

View File

@@ -10,6 +10,9 @@
{% render_field form.name %}
{% render_field form.facility_id %}
{% render_field form.tenant %}
{% render_field form.role %}
{% render_field form.type %}
{% render_field form.width %}
{% render_field form.u_height %}
</div>
</div>

View File

@@ -53,6 +53,16 @@
<td>Name of tenant (optional)</td>
<td>Pied Piper</td>
</tr>
<tr>
<td>Type</td>
<td>Rack type (optional)</td>
<td>4-post cabinet</td>
</tr>
<tr>
<td>Width</td>
<td>Rail-to-rail width (19 or 23 inches)</td>
<td>19</td>
</tr>
<tr>
<td>Height</td>
<td>Height in rack units</td>
@@ -61,7 +71,7 @@
</tbody>
</table>
<h4>Example</h4>
<pre>DC-4,Cage 1400,R101,J12.100,Pied Piper,42</pre>
<pre>DC-4,Cage 1400,R101,J12.100,Pied Piper,4-post cabinet,19,42</pre>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,21 @@
{% extends '_base.html' %}
{% load helpers %}
{% block title %}Rack Role{% endblock %}
{% block content %}
<div class="pull-right">
{% if perms.dcim.add_rackrole %}
<a href="{% url 'dcim:rackrole_add' %}" class="btn btn-primary">
<span class="fa fa-plus" aria-hidden="true"></span>
Add a rack role
</a>
{% endif %}
</div>
<h1>Rack Roles</h1>
<div class="row">
<div class="col-md-12">
{% include 'utilities/obj_table.html' with bulk_delete_url='dcim:rackrole_bulk_delete' %}
</div>
</div>
{% endblock %}

View File

@@ -3,9 +3,9 @@
{% block content %}
<div class="row home-search" style="padding: 15px 0px 20px">
<div class="col-md-4">
<div class="col-md-3">
<form action="{% url 'dcim:device_list' %}" method="get">
<div class="input-group input-group-lg">
<div class="input-group">
<input type="text" name="q" placeholder="Search devices" class="form-control" />
<span class="input-group-btn">
<button type="submit" class="btn btn-primary">
@@ -17,9 +17,9 @@
</form>
<p></p>
</div>
<div class="col-md-4">
<div class="col-md-3">
<form action="{% url 'ipam:prefix_list' %}" method="get">
<div class="input-group input-group-lg">
<div class="input-group">
<input type="text" name="q" placeholder="Search prefixes" class="form-control" />
<span class="input-group-btn">
<button type="submit" class="btn btn-primary">
@@ -31,9 +31,23 @@
</form>
<p></p>
</div>
<div class="col-md-4">
<div class="col-md-3">
<form action="{% url 'ipam:ipaddress_list' %}" method="get">
<div class="input-group">
<input type="text" name="q" placeholder="Search IPs" class="form-control" />
<span class="input-group-btn">
<button type="submit" class="btn btn-primary">
<span class="fa fa-search" aria-hidden="true"></span>
IPs
</button>
</span>
</div>
</form>
<p></p>
</div>
<div class="col-md-3">
<form action="{% url 'circuits:circuit_list' %}" method="get">
<div class="input-group input-group-lg">
<div class="input-group">
<input type="text" name="q" placeholder="Search circuits" class="form-control" />
<span class="input-group-btn">
<button type="submit" class="btn btn-primary">

View File

@@ -11,6 +11,10 @@
<span class="fa fa-plus" aria-hidden="true"></span>
Add an aggregate
</a>
<a href="{% url 'ipam:aggregate_import' %}" class="btn btn-info">
<span class="fa fa-download" aria-hidden="true"></span>
Import aggregates
</a>
{% endif %}
{% include 'inc/export_button.html' with obj_type='aggregates' %}
</div>

View File

@@ -10,6 +10,10 @@
<span class="fa fa-plus" aria-hidden="true"></span>
Add a tenant
</a>
<a href="{% url 'tenancy:tenant_import' %}" class="btn btn-info">
<span class="fa fa-download" aria-hidden="true"></span>
Import tenants
</a>
{% endif %}
{% include 'inc/export_button.html' with obj_type='tenants' %}
</div>

View File

@@ -1,5 +1,5 @@
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.db.models import Count
from django.db.models import Count, Q
from django.shortcuts import get_object_or_404, render
from circuits.models import Circuit
@@ -59,8 +59,14 @@ def tenant(request, slug):
'rack_count': Rack.objects.filter(tenant=tenant).count(),
'device_count': Device.objects.filter(tenant=tenant).count(),
'vrf_count': VRF.objects.filter(tenant=tenant).count(),
'prefix_count': Prefix.objects.filter(tenant=tenant).count(),
'ipaddress_count': IPAddress.objects.filter(tenant=tenant).count(),
'prefix_count': Prefix.objects.filter(
Q(tenant=tenant) |
Q(tenant__isnull=True, vrf__tenant=tenant)
).count(),
'ipaddress_count': IPAddress.objects.filter(
Q(tenant=tenant) |
Q(tenant__isnull=True, vrf__tenant=tenant)
).count(),
'vlan_count': VLAN.objects.filter(tenant=tenant).count(),
'circuit_count': Circuit.objects.filter(tenant=tenant).count(),
}

View File

@@ -27,6 +27,13 @@ def expand_pattern(string):
yield "{}{}{}".format(lead, i, remnant)
def add_blank_choice(choices):
"""
Add a blank choice to the beginning of a choices list.
"""
return ((None, '---------'),) + choices
#
# Widgets
#

View File

@@ -1,13 +1,14 @@
cryptography==1.4
Django==1.9.8
Django==1.10
django-debug-toolbar==1.4
django-filter==0.13.0
django-rest-swagger==0.3.7
django-rest-swagger==0.3.10
django-tables2==1.2.1
djangorestframework==3.3.3
djangorestframework==3.4.3
graphviz==0.4.10
Markdown==2.6.6
ncclient==0.4.7
natsort>=5.0.0
ncclient==0.5.2
netaddr==0.7.18
paramiko==2.0.0
psycopg2==2.6.1
@@ -15,4 +16,3 @@ py-gfm==0.1.3
pycrypto==2.6.1
sqlparse==0.1.19
xmltodict==0.10.2
natsort>=5.0.0