diff --git a/docs/data-model/ipam.md b/docs/data-model/ipam.md index c6da1d657..ee54e74d2 100644 --- a/docs/data-model/ipam.md +++ b/docs/data-model/ipam.md @@ -86,3 +86,9 @@ One IP address can be designated as the network address translation (NAT) IP add A VLAN represents an isolated layer two domain, identified by a name and a numeric ID (1-4094). Note that while it is good practice, neither VLAN names nor IDs must be unique within a site. This is to accommodate the fact that many real-world network use less-than-optimal VLAN allocations and may have overlapping VLAN ID assignments in practice. Like prefixes, each VLAN is assigned an operational status and (optionally) a functional role. + +--- + +# Services + +A service represents a TCP or UDP service available on a device. Each service must be defined with a name, protocol, and port number; for example, SSH (TCP/22). A service may optionally be bound to one or more specific IP addresses belonging to a device. (If no IP addresses are bound, the service is assumed to be reachable via any IP address.) diff --git a/docs/installation/netbox.md b/docs/installation/netbox.md index 5b673e845..fa476b865 100644 --- a/docs/installation/netbox.md +++ b/docs/installation/netbox.md @@ -195,7 +195,7 @@ Starting development server at http://0.0.0.0:8000/ Quit the server with CONTROL-C. ``` -Now if we navigate to the name or IP of the server (as defined in `ALLOWED_HOSTS`) we should be greeted with the NetBox home page. Note that this built-in web service is for development and testing purposes only. It is not suited for production use. +Now if we navigate to the name or IP of the server (as defined in `ALLOWED_HOSTS`) we should be greeted with the NetBox home page. Note that this built-in web service is for development and testing purposes only. **It is not suited for production use.** !!! warning If the test service does not run, or you cannot reach the NetBox home page, something has gone wrong. Do not proceed with the rest of this guide until the installation has been corrected. diff --git a/docs/installation/postgresql.md b/docs/installation/postgresql.md index e1d9a49ea..39a8f05cb 100644 --- a/docs/installation/postgresql.md +++ b/docs/installation/postgresql.md @@ -1,4 +1,4 @@ -NetBox requires a PostgreSQL database to store data. MySQL is not supported, as NetBox leverage's PostgreSQL's built-in [network address types](https://www.postgresql.org/docs/9.1/static/datatype-net-types.html). +NetBox requires a PostgreSQL database to store data. (Please note that MySQL is not supported, as NetBox leverages PostgreSQL's built-in [network address types](https://www.postgresql.org/docs/9.1/static/datatype-net-types.html).) # Installation @@ -15,7 +15,7 @@ NetBox requires a PostgreSQL database to store data. MySQL is not supported, as # postgresql-setup initdb ``` -If using CentOS, modify the PostgreSQL configuration to accept password-based authentication by replacing `ident` with `md5` for all host entries within `/var/lib/pgsql/data/pg_hba.conf`. For example: +CentOS users should modify the PostgreSQL configuration to accept password-based authentication by replacing `ident` with `md5` for all host entries within `/var/lib/pgsql/data/pg_hba.conf`. For example: ```no-highlight host all all 127.0.0.1/32 md5 diff --git a/docs/installation/web-server.md b/docs/installation/web-server.md index 10cc4992f..6a058fddc 100644 --- a/docs/installation/web-server.md +++ b/docs/installation/web-server.md @@ -101,7 +101,7 @@ To enable SSL, consider this guide on [securing Apache with Let's Encrypt](https # gunicorn Installation -Save the following configuration file in the root netbox installation path (in this example, `/opt/netbox/`) as `gunicorn_config.py`. Be sure to verify the location of the gunicorn executable (e.g. `which gunicorn`) and to update the `pythonpath` variable if needed. If using CentOS/RHEL change the username from `www-data` to `nginx` or `apache`. +Save the following configuration in the root netbox installation path as `gunicorn_config.py` (e.g. `/opt/netbox/gunicorn_config.py` per our example installation). Be sure to verify the location of the gunicorn executable on your server (e.g. `which gunicorn`) and to update the `pythonpath` variable if needed. If using CentOS/RHEL, change the username from `www-data` to `nginx` or `apache`. ```no-highlight command = '/usr/bin/gunicorn' @@ -113,7 +113,7 @@ user = 'www-data' # supervisord Installation -Save the following as `/etc/supervisor/conf.d/netbox.conf`. Update the `command` and `directory` paths as needed. +Save the following as `/etc/supervisor/conf.d/netbox.conf`. Update the `command` and `directory` paths as needed. If using CentOS/RHEL, change the username from `www-data` to `nginx` or `apache`. ```no-highlight [program:netbox] diff --git a/netbox/circuits/admin.py b/netbox/circuits/admin.py index 97711b7a8..281ed2104 100644 --- a/netbox/circuits/admin.py +++ b/netbox/circuits/admin.py @@ -21,11 +21,9 @@ class CircuitTypeAdmin(admin.ModelAdmin): @admin.register(Circuit) class CircuitAdmin(admin.ModelAdmin): - list_display = ['cid', 'provider', 'type', 'tenant', 'site', 'install_date', 'port_speed_human', - 'upstream_speed_human', 'commit_rate_human', 'xconnect_id'] + list_display = ['cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate_human'] list_filter = ['provider', 'type', 'tenant'] - exclude = ['interface'] def get_queryset(self, request): qs = super(CircuitAdmin, self).get_queryset(request) - return qs.select_related('provider', 'type', 'tenant', 'site') + return qs.select_related('provider', 'type', 'tenant') diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index d7f32f958..1b894afe0 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -1,6 +1,6 @@ from rest_framework import serializers -from circuits.models import Provider, CircuitType, Circuit +from circuits.models import Provider, Circuit, CircuitTermination, CircuitType from dcim.api.serializers import SiteNestedSerializer, InterfaceNestedSerializer from extras.api.serializers import CustomFieldSerializer from tenancy.api.serializers import TenantNestedSerializer @@ -45,17 +45,24 @@ class CircuitTypeNestedSerializer(CircuitTypeSerializer): # Circuits # -class CircuitSerializer(CustomFieldSerializer, serializers.ModelSerializer): - provider = ProviderNestedSerializer() - type = CircuitTypeNestedSerializer() - tenant = TenantNestedSerializer() +class CircuitTerminationSerializer(serializers.ModelSerializer): site = SiteNestedSerializer() interface = InterfaceNestedSerializer() + class Meta: + model = CircuitTermination + fields = ['id', 'term_side', 'site', 'interface', 'port_speed', 'upstream_speed', 'xconnect_id', 'pp_info'] + + +class CircuitSerializer(CustomFieldSerializer, serializers.ModelSerializer): + provider = ProviderNestedSerializer() + type = CircuitTypeNestedSerializer() + tenant = TenantNestedSerializer() + terminations = CircuitTerminationSerializer(many=True) + class Meta: model = Circuit - fields = ['id', 'cid', 'provider', 'type', 'tenant', 'site', 'interface', 'install_date', 'port_speed', - 'upstream_speed', 'commit_rate', 'xconnect_id', 'comments', 'custom_fields'] + fields = ['id', 'cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate', 'comments', 'terminations', 'custom_fields'] class CircuitNestedSerializer(CircuitSerializer): diff --git a/netbox/circuits/api/views.py b/netbox/circuits/api/views.py index 866f9283b..d89286036 100644 --- a/netbox/circuits/api/views.py +++ b/netbox/circuits/api/views.py @@ -43,7 +43,7 @@ class CircuitListView(CustomFieldModelAPIView, generics.ListAPIView): """ List circuits (filterable) """ - queryset = Circuit.objects.select_related('type', 'tenant', 'provider', 'site', 'interface__device')\ + queryset = Circuit.objects.select_related('type', 'tenant', 'provider')\ .prefetch_related('custom_field_values__field') serializer_class = serializers.CircuitSerializer filter_class = CircuitFilter @@ -53,6 +53,6 @@ class CircuitDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView): """ Retrieve a single circuit """ - queryset = Circuit.objects.select_related('type', 'tenant', 'provider', 'site', 'interface__device')\ + queryset = Circuit.objects.select_related('type', 'tenant', 'provider')\ .prefetch_related('custom_field_values__field') serializer_class = serializers.CircuitSerializer diff --git a/netbox/circuits/filters.py b/netbox/circuits/filters.py index 152588c5a..45cbed3a4 100644 --- a/netbox/circuits/filters.py +++ b/netbox/circuits/filters.py @@ -16,12 +16,12 @@ class ProviderFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Search', ) site_id = django_filters.ModelMultipleChoiceFilter( - name='circuits__site', + name='circuits__terminations__site', queryset=Site.objects.all(), label='Site', ) site = django_filters.ModelMultipleChoiceFilter( - name='circuits__site', + name='circuits__terminations__site__slug', queryset=Site.objects.all(), to_field_name='slug', label='Site (slug)', @@ -29,7 +29,7 @@ class ProviderFilter(CustomFieldFilterSet, django_filters.FilterSet): class Meta: model = Provider - fields = ['q', 'name', 'account', 'asn'] + fields = ['name', 'account', 'asn'] def search(self, queryset, value): return queryset.filter( @@ -50,7 +50,7 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Provider (ID)', ) provider = django_filters.ModelMultipleChoiceFilter( - name='provider', + name='provider__slug', queryset=Provider.objects.all(), to_field_name='slug', label='Provider (slug)', @@ -61,7 +61,7 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Circuit type (ID)', ) type = django_filters.ModelMultipleChoiceFilter( - name='type', + name='type__slug', queryset=CircuitType.objects.all(), to_field_name='slug', label='Circuit type (slug)', @@ -78,12 +78,12 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Tenant (slug)', ) site_id = django_filters.ModelMultipleChoiceFilter( - name='site', + name='terminations__site', queryset=Site.objects.all(), label='Site (ID)', ) site = django_filters.ModelMultipleChoiceFilter( - name='site', + name='terminations__site__slug', queryset=Site.objects.all(), to_field_name='slug', label='Site (slug)', @@ -91,12 +91,11 @@ class CircuitFilter(CustomFieldFilterSet, django_filters.FilterSet): class Meta: model = Circuit - fields = ['q', 'provider_id', 'provider', 'type_id', 'type', 'site_id', 'site', 'interface', 'install_date'] + fields = ['install_date'] def search(self, queryset, value): return queryset.filter( Q(cid__icontains=value) | Q(xconnect_id__icontains=value) | - Q(pp_info__icontains=value) | Q(comments__icontains=value) ) diff --git a/netbox/circuits/forms.py b/netbox/circuits/forms.py index c66a8bc40..dc59bc0c7 100644 --- a/netbox/circuits/forms.py +++ b/netbox/circuits/forms.py @@ -9,7 +9,7 @@ from utilities.forms import ( SlugField, ) -from .models import Circuit, CircuitType, Provider +from .models import Circuit, CircuitTermination, CircuitType, Provider # @@ -43,7 +43,7 @@ class ProviderFromCSVForm(forms.ModelForm): fields = ['name', 'slug', 'asn', 'account', 'portal_url'] -class ProviderImportForm(BulkImportForm, BootstrapMixin): +class ProviderImportForm(BootstrapMixin, BulkImportForm): csv = CSVDataField(csv_form=ProviderFromCSVForm) @@ -69,7 +69,7 @@ class ProviderFilterForm(BootstrapMixin, CustomFieldFilterForm): # Circuit types # -class CircuitTypeForm(forms.ModelForm, BootstrapMixin): +class CircuitTypeForm(BootstrapMixin, forms.ModelForm): slug = SlugField() class Meta: @@ -82,6 +82,64 @@ class CircuitTypeForm(forms.ModelForm, BootstrapMixin): # class CircuitForm(BootstrapMixin, CustomFieldForm): + comments = CommentField() + + class Meta: + model = Circuit + fields = ['cid', 'type', 'provider', 'tenant', 'install_date', 'commit_rate', 'comments'] + help_texts = { + 'cid': "Unique circuit ID", + 'install_date': "Format: YYYY-MM-DD", + 'commit_rate': "Committed rate", + } + + +class CircuitFromCSVForm(forms.ModelForm): + provider = forms.ModelChoiceField(Provider.objects.all(), to_field_name='name', + error_messages={'invalid_choice': 'Provider not found.'}) + type = forms.ModelChoiceField(CircuitType.objects.all(), to_field_name='name', + error_messages={'invalid_choice': 'Invalid circuit type.'}) + tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False, + error_messages={'invalid_choice': 'Tenant not found.'}) + + class Meta: + model = Circuit + fields = ['cid', 'provider', 'type', 'tenant', 'install_date', 'commit_rate'] + + +class CircuitImportForm(BootstrapMixin, BulkImportForm): + csv = CSVDataField(csv_form=CircuitFromCSVForm) + + +class CircuitBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): + pk = forms.ModelMultipleChoiceField(queryset=Circuit.objects.all(), widget=forms.MultipleHiddenInput) + type = forms.ModelChoiceField(queryset=CircuitType.objects.all(), required=False) + provider = forms.ModelChoiceField(queryset=Provider.objects.all(), required=False) + tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False) + commit_rate = forms.IntegerField(required=False, label='Commit rate (Kbps)') + comments = CommentField(widget=SmallTextarea) + + class Meta: + nullable_fields = ['tenant', 'commit_rate', 'comments'] + + +class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm): + model = Circuit + type = FilterChoiceField(queryset=CircuitType.objects.annotate(filter_count=Count('circuits')), + to_field_name='slug') + provider = FilterChoiceField(queryset=Provider.objects.annotate(filter_count=Count('circuits')), + to_field_name='slug') + tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('circuits')), to_field_name='slug', + null_option=(0, 'None')) + site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('circuit_terminations')), + to_field_name='slug') + + +# +# Circuit terminations +# + +class CircuitTerminationForm(BootstrapMixin, forms.ModelForm): site = forms.ModelChoiceField(queryset=Site.objects.all(), widget=forms.Select(attrs={'filter-for': 'rack'})) rack = forms.ModelChoiceField(queryset=Rack.objects.all(), required=False, label='Rack', widget=APISelect(api_url='/api/dcim/racks/?site_id={{site}}', @@ -95,28 +153,25 @@ class CircuitForm(BootstrapMixin, CustomFieldForm): interface = forms.ModelChoiceField(queryset=Interface.objects.all(), required=False, label='Interface', widget=APISelect(api_url='/api/dcim/devices/{{device}}/interfaces/?type=physical', disabled_indicator='is_connected')) - comments = CommentField() class Meta: - model = Circuit - fields = [ - 'cid', 'type', 'provider', 'tenant', 'site', 'rack', 'device', 'livesearch', 'interface', 'install_date', - 'port_speed', 'upstream_speed', 'commit_rate', 'xconnect_id', 'pp_info', 'comments' - ] + model = CircuitTermination + fields = ['term_side', 'site', 'rack', 'device', 'livesearch', 'interface', 'port_speed', 'upstream_speed', + 'xconnect_id', 'pp_info'] help_texts = { - 'cid': "Unique circuit ID", - 'install_date': "Format: YYYY-MM-DD", 'port_speed': "Physical circuit speed", - 'commit_rate': "Commited rate", 'xconnect_id': "ID of the local cross-connect", 'pp_info': "Patch panel ID and port number(s)" } + widgets = { + 'term_side': forms.HiddenInput(), + } def __init__(self, *args, **kwargs): - super(CircuitForm, self).__init__(*args, **kwargs) + super(CircuitTerminationForm, self).__init__(*args, **kwargs) - # If this circuit has been assigned to an interface, initialize rack and device + # If an interface has been assigned, initialize rack and device if self.instance.interface: self.initial['rack'] = self.instance.interface.device.rack self.initial['device'] = self.instance.interface.device @@ -140,11 +195,13 @@ class CircuitForm(BootstrapMixin, CustomFieldForm): # Limit interface choices if self.is_bound and self.data.get('device'): interfaces = Interface.objects.filter(device=self.data['device'])\ - .exclude(form_factor=IFACE_FF_VIRTUAL).select_related('circuit', 'connected_as_a', 'connected_as_b') + .exclude(form_factor=IFACE_FF_VIRTUAL).select_related('circuit_termination', 'connected_as_a', + 'connected_as_b') self.fields['interface'].widget.attrs['initial'] = self.data.get('interface') elif self.initial.get('device'): interfaces = Interface.objects.filter(device=self.initial['device'])\ - .exclude(form_factor=IFACE_FF_VIRTUAL).select_related('circuit', 'connected_as_a', 'connected_as_b') + .exclude(form_factor=IFACE_FF_VIRTUAL).select_related('circuit_termination', 'connected_as_a', + 'connected_as_b') self.fields['interface'].widget.attrs['initial'] = self.initial.get('interface') else: interfaces = [] @@ -154,47 +211,3 @@ class CircuitForm(BootstrapMixin, CustomFieldForm): 'disabled': iface.is_connected and iface.id != self.fields['interface'].widget.attrs.get('initial'), }) for iface in interfaces ] - - -class CircuitFromCSVForm(forms.ModelForm): - provider = forms.ModelChoiceField(Provider.objects.all(), to_field_name='name', - error_messages={'invalid_choice': 'Provider not found.'}) - type = forms.ModelChoiceField(CircuitType.objects.all(), to_field_name='name', - error_messages={'invalid_choice': 'Invalid circuit type.'}) - tenant = forms.ModelChoiceField(Tenant.objects.all(), to_field_name='name', required=False, - error_messages={'invalid_choice': 'Tenant not found.'}) - site = forms.ModelChoiceField(Site.objects.all(), to_field_name='name', - error_messages={'invalid_choice': 'Site not found.'}) - - class Meta: - model = Circuit - fields = ['cid', 'provider', 'type', 'tenant', 'site', 'install_date', 'port_speed', 'upstream_speed', - 'commit_rate', 'xconnect_id', 'pp_info'] - - -class CircuitImportForm(BulkImportForm, BootstrapMixin): - csv = CSVDataField(csv_form=CircuitFromCSVForm) - - -class CircuitBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): - pk = forms.ModelMultipleChoiceField(queryset=Circuit.objects.all(), widget=forms.MultipleHiddenInput) - type = forms.ModelChoiceField(queryset=CircuitType.objects.all(), required=False) - provider = forms.ModelChoiceField(queryset=Provider.objects.all(), required=False) - tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False) - port_speed = forms.IntegerField(required=False, label='Port speed (Kbps)') - commit_rate = forms.IntegerField(required=False, label='Commit rate (Kbps)') - comments = CommentField(widget=SmallTextarea) - - class Meta: - nullable_fields = ['tenant', 'port_speed', 'commit_rate', 'comments'] - - -class CircuitFilterForm(BootstrapMixin, CustomFieldFilterForm): - model = Circuit - type = FilterChoiceField(queryset=CircuitType.objects.annotate(filter_count=Count('circuits')), - to_field_name='slug') - provider = FilterChoiceField(queryset=Provider.objects.annotate(filter_count=Count('circuits')), - to_field_name='slug') - tenant = FilterChoiceField(queryset=Tenant.objects.annotate(filter_count=Count('circuits')), to_field_name='slug', - null_option=(0, 'None')) - site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('circuits')), to_field_name='slug') diff --git a/netbox/circuits/migrations/0006_terminations.py b/netbox/circuits/migrations/0006_terminations.py new file mode 100644 index 000000000..e5451498a --- /dev/null +++ b/netbox/circuits/migrations/0006_terminations.py @@ -0,0 +1,99 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10 on 2016-12-13 16:30 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion + + +def circuits_to_terms(apps, schema_editor): + Circuit = apps.get_model('circuits', 'Circuit') + CircuitTermination = apps.get_model('circuits', 'CircuitTermination') + for c in Circuit.objects.all(): + CircuitTermination( + circuit=c, + term_side=b'A', + site=c.site, + interface=c.interface, + port_speed=c.port_speed, + upstream_speed=c.upstream_speed, + xconnect_id=c.xconnect_id, + pp_info=c.pp_info, + ).save() + + +def terms_to_circuits(apps, schema_editor): + CircuitTermination = apps.get_model('circuits', 'CircuitTermination') + for ct in CircuitTermination.objects.filter(term_side='A'): + c = ct.circuit + c.site = ct.site + c.interface = ct.interface + c.port_speed = ct.port_speed + c.upstream_speed = ct.upstream_speed + c.xconnect_id = ct.xconnect_id + c.pp_info = ct.pp_info + c.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0022_color_names_to_rgb'), + ('circuits', '0005_circuit_add_upstream_speed'), + ] + + operations = [ + migrations.CreateModel( + name='CircuitTermination', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('term_side', models.CharField(choices=[(b'A', b'A'), (b'Z', b'Z')], max_length=1, + verbose_name='Termination')), + ('port_speed', models.PositiveIntegerField(verbose_name=b'Port speed (Kbps)')), + ('upstream_speed', + models.PositiveIntegerField(blank=True, help_text=b'Upstream speed, if different from port speed', + null=True, verbose_name=b'Upstream speed (Kbps)')), + ('xconnect_id', models.CharField(blank=True, max_length=50, verbose_name=b'Cross-connect ID')), + ('pp_info', models.CharField(blank=True, max_length=100, verbose_name=b'Patch panel/port(s)')), + ('circuit', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='terminations', + to='circuits.Circuit')), + ('interface', models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, + related_name='circuit_termination', to='dcim.Interface')), + ('site', + models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='circuit_terminations', + to='dcim.Site')), + ], + options={ + 'ordering': ['circuit', 'term_side'], + }, + ), + migrations.AlterUniqueTogether( + name='circuittermination', + unique_together=set([('circuit', 'term_side')]), + ), + migrations.RunPython(circuits_to_terms, terms_to_circuits), + migrations.RemoveField( + model_name='circuit', + name='interface', + ), + migrations.RemoveField( + model_name='circuit', + name='port_speed', + ), + migrations.RemoveField( + model_name='circuit', + name='pp_info', + ), + migrations.RemoveField( + model_name='circuit', + name='site', + ), + migrations.RemoveField( + model_name='circuit', + name='upstream_speed', + ), + migrations.RemoveField( + model_name='circuit', + name='xconnect_id', + ), + ] diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py index fd4fdf634..c4bf86a28 100644 --- a/netbox/circuits/models.py +++ b/netbox/circuits/models.py @@ -3,12 +3,35 @@ from django.core.urlresolvers import reverse from django.db import models from dcim.fields import ASNField -from dcim.models import Site, Interface from extras.models import CustomFieldModel, CustomFieldValue from tenancy.models import Tenant from utilities.models import CreatedUpdatedModel +TERM_SIDE_A = 'A' +TERM_SIDE_Z = 'Z' +TERM_SIDE_CHOICES = ( + (TERM_SIDE_A, 'A'), + (TERM_SIDE_Z, 'Z'), +) + + +def humanize_speed(speed): + """ + Humanize speeds given in Kbps (e.g. 10000000 becomes '10 Gbps') + """ + if speed >= 1000000000 and speed % 1000000000 == 0: + return '{} Tbps'.format(speed / 1000000000) + elif speed >= 1000000 and speed % 1000000 == 0: + return '{} Gbps'.format(speed / 1000000) + elif speed >= 1000 and speed % 1000 == 0: + return '{} Mbps'.format(speed / 1000) + elif speed >= 1000: + return '{} Mbps'.format(float(speed) / 1000) + else: + return '{} Kbps'.format(speed) + + class Provider(CreatedUpdatedModel, CustomFieldModel): """ Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model @@ -71,15 +94,8 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel): provider = models.ForeignKey('Provider', related_name='circuits', on_delete=models.PROTECT) type = models.ForeignKey('CircuitType', related_name='circuits', on_delete=models.PROTECT) tenant = models.ForeignKey(Tenant, related_name='circuits', blank=True, null=True, on_delete=models.PROTECT) - site = models.ForeignKey(Site, related_name='circuits', on_delete=models.PROTECT) - 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)') comments = models.TextField(blank=True) custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') @@ -99,42 +115,61 @@ class Circuit(CreatedUpdatedModel, CustomFieldModel): self.provider.name, self.type.name, self.tenant.name if self.tenant else '', - 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, ]) - def _humanize_speed(self, speed): - """ - Humanize speeds given in Kbps (e.g. 10000000 becomes '10 Gbps') - """ - if speed >= 1000000000 and speed % 1000000000 == 0: - return '{} Tbps'.format(speed / 1000000000) - elif speed >= 1000000 and speed % 1000000 == 0: - return '{} Gbps'.format(speed / 1000000) - elif speed >= 1000 and speed % 1000 == 0: - return '{} Mbps'.format(speed / 1000) - elif speed >= 1000: - return '{} Mbps'.format(float(speed) / 1000) - else: - return '{} Kbps'.format(speed) + def _get_termination(self, side): + for ct in self.terminations.all(): + if ct.term_side == side: + return ct + return None + + @property + def termination_a(self): + return self._get_termination('A') + + @property + def termination_z(self): + return self._get_termination('Z') + + def commit_rate_human(self): + return '' if not self.commit_rate else humanize_speed(self.commit_rate) + commit_rate_human.admin_order_field = 'commit_rate' + + +class CircuitTermination(models.Model): + circuit = models.ForeignKey('Circuit', related_name='terminations', on_delete=models.CASCADE) + term_side = models.CharField(max_length=1, choices=TERM_SIDE_CHOICES, verbose_name='Termination') + site = models.ForeignKey('dcim.Site', related_name='circuit_terminations', on_delete=models.PROTECT) + interface = models.OneToOneField('dcim.Interface', related_name='circuit_termination', blank=True, null=True) + 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') + 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)') + + class Meta: + ordering = ['circuit', 'term_side'] + unique_together = ['circuit', 'term_side'] + + def __unicode__(self): + return u'{} (Side {})'.format(self.circuit, self.get_term_side_display()) + + def get_parent_url(self): + return self.circuit.get_absolute_url() + + def get_peer_termination(self): + peer_side = 'Z' if self.term_side == 'A' else 'A' + try: + return CircuitTermination.objects.select_related('site').get(circuit=self.circuit, term_side=peer_side) + except CircuitTermination.DoesNotExist: + return None def port_speed_human(self): - return self._humanize_speed(self.port_speed) + return 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) + return '' if not self.upstream_speed else humanize_speed(self.upstream_speed) upstream_speed_human.admin_order_field = 'upstream_speed' - - 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/circuits/tables.py b/netbox/circuits/tables.py index f82459890..34236d843 100644 --- a/netbox/circuits/tables.py +++ b/netbox/circuits/tables.py @@ -56,12 +56,13 @@ class CircuitTable(BaseTable): type = tables.Column(verbose_name='Type') provider = tables.LinkColumn('circuits:provider', args=[Accessor('provider.slug')], verbose_name='Provider') tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant') - site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site') - port_speed = tables.Column(accessor=Accessor('port_speed_human'), order_by=Accessor('port_speed'), - verbose_name='Port Speed') + a_side = tables.LinkColumn('dcim:site', accessor=Accessor('termination_a.site'), orderable=False, + args=[Accessor('termination_a.site.slug')]) + z_side = tables.LinkColumn('dcim:site', accessor=Accessor('termination_z.site'), orderable=False, + args=[Accessor('termination_z.site.slug')]) commit_rate = tables.Column(accessor=Accessor('commit_rate_human'), order_by=Accessor('commit_rate'), verbose_name='Commit Rate') class Meta(BaseTable.Meta): model = Circuit - fields = ('pk', 'cid', 'type', 'provider', 'tenant', 'site', 'port_speed', 'commit_rate') + fields = ('pk', 'cid', 'type', 'provider', 'tenant', 'a_side', 'z_side', 'commit_rate') diff --git a/netbox/circuits/urls.py b/netbox/circuits/urls.py index 9ecb1d5ae..7dd00b268 100644 --- a/netbox/circuits/urls.py +++ b/netbox/circuits/urls.py @@ -30,5 +30,11 @@ urlpatterns = [ url(r'^circuits/(?P\d+)/$', views.circuit, name='circuit'), url(r'^circuits/(?P\d+)/edit/$', views.CircuitEditView.as_view(), name='circuit_edit'), url(r'^circuits/(?P\d+)/delete/$', views.CircuitDeleteView.as_view(), name='circuit_delete'), + url(r'^circuits/(?P\d+)/terminations/swap/$', views.circuit_terminations_swap, name='circuit_terminations_swap'), + + # Circuit terminations + url(r'^circuits/(?P\d+)/terminations/add/$', views.CircuitTerminationEditView.as_view(), name='circuittermination_add'), + url(r'^circuit-terminations/(?P\d+)/edit/$', views.CircuitTerminationEditView.as_view(), name='circuittermination_edit'), + url(r'^circuit-terminations/(?P\d+)/delete/$', views.CircuitTerminationDeleteView.as_view(), name='circuittermination_delete'), ] diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index 292328d61..9feb19ef6 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -1,14 +1,18 @@ +from django.contrib import messages +from django.contrib.auth.decorators import permission_required from django.contrib.auth.mixins import PermissionRequiredMixin +from django.db import transaction from django.db.models import Count -from django.shortcuts import get_object_or_404, render +from django.shortcuts import get_object_or_404, redirect, render from extras.models import Graph, GRAPH_TYPE_PROVIDER +from utilities.forms import ConfirmationForm from utilities.views import ( BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView, ) from . import filters, forms, tables -from .models import Circuit, CircuitType, Provider +from .models import Circuit, CircuitTermination, CircuitType, Provider, TERM_SIDE_A, TERM_SIDE_Z # @@ -27,7 +31,7 @@ class ProviderListView(ObjectListView): def provider(request, slug): provider = get_object_or_404(Provider, slug=slug) - circuits = Circuit.objects.filter(provider=provider).select_related('site', 'interface__device') + circuits = Circuit.objects.filter(provider=provider) show_graphs = Graph.objects.filter(type=GRAPH_TYPE_PROVIDER).exists() return render(request, 'circuits/provider.html', { @@ -103,7 +107,7 @@ class CircuitTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # class CircuitListView(ObjectListView): - queryset = Circuit.objects.select_related('provider', 'type', 'tenant', 'site') + queryset = Circuit.objects.select_related('provider', 'type', 'tenant').prefetch_related('terminations__site') filter = filters.CircuitFilter filter_form = forms.CircuitFilterForm table = tables.CircuitTable @@ -114,9 +118,13 @@ class CircuitListView(ObjectListView): def circuit(request, pk): circuit = get_object_or_404(Circuit, pk=pk) + termination_a = CircuitTermination.objects.filter(circuit=circuit, term_side=TERM_SIDE_A).first() + termination_z = CircuitTermination.objects.filter(circuit=circuit, term_side=TERM_SIDE_Z).first() return render(request, 'circuits/circuit.html', { 'circuit': circuit, + 'termination_a': termination_a, + 'termination_z': termination_z, }) @@ -124,7 +132,7 @@ class CircuitEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'circuits.change_circuit' model = Circuit form_class = forms.CircuitForm - fields_initial = ['site'] + fields_initial = ['provider'] template_name = 'circuits/circuit_edit.html' obj_list_url = 'circuits:circuit_list' @@ -155,3 +163,71 @@ class CircuitBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'circuits.delete_circuit' cls = Circuit default_redirect_url = 'circuits:circuit_list' + + +@permission_required('circuits.change_circuittermination') +def circuit_terminations_swap(request, pk): + + circuit = get_object_or_404(Circuit, pk=pk) + termination_a = CircuitTermination.objects.filter(circuit=circuit, term_side=TERM_SIDE_A).first() + termination_z = CircuitTermination.objects.filter(circuit=circuit, term_side=TERM_SIDE_Z).first() + if not termination_a and not termination_z: + messages.error(request, "No terminations have been defined for circuit {}.".format(circuit)) + return redirect('circuits:circuit', pk=circuit.pk) + + if request.method == 'POST': + form = ConfirmationForm(request.POST) + if form.is_valid(): + if termination_a and termination_z: + # Use a placeholder to avoid an IntegrityError on the (circuit, term_side) unique constraint + with transaction.atomic(): + termination_a.term_side = '_' + termination_a.save() + termination_z.term_side = 'A' + termination_z.save() + termination_a.term_side = 'Z' + termination_a.save() + elif termination_a: + termination_a.term_side = 'Z' + termination_a.save() + else: + termination_z.term_side = 'A' + termination_z.save() + messages.success(request, "Swapped terminations for circuit {}.".format(circuit)) + return redirect('circuits:circuit', pk=circuit.pk) + + else: + form = ConfirmationForm() + + return render(request, 'circuits/circuit_terminations_swap.html', { + 'circuit': circuit, + 'termination_a': termination_a, + 'termination_z': termination_z, + 'form': form, + 'panel_class': 'default', + 'button_class': 'primary', + 'cancel_url': circuit.get_absolute_url(), + }) + + +# +# Circuit terminations +# + +class CircuitTerminationEditView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'circuits.change_circuittermination' + model = CircuitTermination + form_class = forms.CircuitTerminationForm + fields_initial = ['term_side'] + template_name = 'circuits/circuittermination_edit.html' + + def alter_obj(self, obj, args, kwargs): + if 'circuit' in kwargs: + circuit = get_object_or_404(Circuit, pk=kwargs['circuit']) + obj.circuit = circuit + return obj + + +class CircuitTerminationDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'circuits.delete_circuittermination' + model = CircuitTermination diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index ef7a4be60..3b1ab720c 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -20,8 +20,9 @@ class SiteSerializer(CustomFieldSerializer, serializers.ModelSerializer): class Meta: model = Site - fields = ['id', 'name', 'slug', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address', 'comments', - 'custom_fields', 'count_prefixes', 'count_vlans', 'count_racks', 'count_devices', 'count_circuits'] + fields = ['id', 'name', 'slug', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address', + 'contact_name', 'contact_phone', 'contact_email', 'comments', 'custom_fields', 'count_prefixes', + 'count_vlans', 'count_racks', 'count_devices', 'count_circuits'] class SiteNestedSerializer(SiteSerializer): @@ -130,14 +131,14 @@ class ManufacturerNestedSerializer(ManufacturerSerializer): # Device types # -class DeviceTypeSerializer(serializers.ModelSerializer): +class DeviceTypeSerializer(CustomFieldSerializer, serializers.ModelSerializer): manufacturer = ManufacturerNestedSerializer() subdevice_role = serializers.SerializerMethodField() class Meta: model = DeviceType fields = ['id', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', - 'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role'] + 'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role', 'comments', 'custom_fields'] def get_subdevice_role(self, obj): return { @@ -197,8 +198,9 @@ class DeviceTypeDetailSerializer(DeviceTypeSerializer): class Meta(DeviceTypeSerializer.Meta): fields = ['id', 'manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', - 'is_console_server', 'is_pdu', 'is_network_device', 'console_port_templates', 'cs_port_templates', - 'power_port_templates', 'power_outlet_templates', 'interface_templates'] + 'is_console_server', 'is_pdu', 'is_network_device', 'subdevice_role', 'comments', 'custom_fields', + 'console_port_templates', 'cs_port_templates', 'power_port_templates', 'power_outlet_templates', + 'interface_templates'] # @@ -381,7 +383,7 @@ class InterfaceNestedSerializer(InterfaceSerializer): class InterfaceDetailSerializer(InterfaceSerializer): - connected_interface = InterfaceSerializer(source='get_connected_interface') + connected_interface = InterfaceSerializer() class Meta(InterfaceSerializer.Meta): fields = ['id', 'device', 'name', 'form_factor', 'mac_address', 'mgmt_only', 'description', 'is_connected', diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 01a8c6f61..53bda6208 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -118,7 +118,11 @@ class RackUnitListView(APIView): rack = get_object_or_404(Rack, pk=pk) face = request.GET.get('face', 0) - elevation = rack.get_rack_units(face) + try: + exclude = int(request.GET.get('exclude', None)) + except ValueError: + exclude = None + elevation = rack.get_rack_units(face, exclude) # Serialize Devices within the rack elevation for u in elevation: @@ -152,20 +156,20 @@ class ManufacturerDetailView(generics.RetrieveAPIView): # Device Types # -class DeviceTypeListView(generics.ListAPIView): +class DeviceTypeListView(CustomFieldModelAPIView, generics.ListAPIView): """ List device types (filterable) """ - queryset = DeviceType.objects.select_related('manufacturer') + queryset = DeviceType.objects.select_related('manufacturer').prefetch_related('custom_field_values__field') serializer_class = serializers.DeviceTypeSerializer filter_class = filters.DeviceTypeFilter -class DeviceTypeDetailView(generics.RetrieveAPIView): +class DeviceTypeDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView): """ Retrieve a single device type """ - queryset = DeviceType.objects.select_related('manufacturer') + queryset = DeviceType.objects.select_related('manufacturer').prefetch_related('custom_field_values__field') serializer_class = serializers.DeviceTypeDetailSerializer @@ -451,7 +455,7 @@ class RelatedConnectionsView(APIView): peer_iface = Interface.objects.get(device__name=peer_device, name=peer_interface) except Interface.DoesNotExist: raise Http404() - local_iface = peer_iface.get_connected_interface() + local_iface = peer_iface.connected_interface if local_iface: device = local_iface.device else: @@ -484,7 +488,7 @@ class RelatedConnectionsView(APIView): # Interface connections interfaces = Interface.objects.filter(device=device).select_related('connected_as_a', 'connected_as_b', - 'circuit') + 'circuit_termination') for iface in interfaces: data = serializers.InterfaceDetailSerializer(instance=iface).data del(data['device']) diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index 4cf53c303..79024b605 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -50,7 +50,7 @@ class RackGroupFilter(django_filters.FilterSet): label='Site (ID)', ) site = django_filters.ModelMultipleChoiceFilter( - name='site', + name='site__slug', queryset=Site.objects.all(), to_field_name='slug', label='Site (slug)', @@ -58,7 +58,6 @@ class RackGroupFilter(django_filters.FilterSet): class Meta: model = RackGroup - fields = ['site_id', 'site'] class RackFilter(CustomFieldFilterSet, django_filters.FilterSet): @@ -72,7 +71,7 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Site (ID)', ) site = django_filters.ModelMultipleChoiceFilter( - name='site', + name='site__slug', queryset=Site.objects.all(), to_field_name='slug', label='Site (slug)', @@ -113,7 +112,7 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet): class Meta: model = Rack - fields = ['q', 'site_id', 'site', 'u_height'] + fields = ['u_height'] def search(self, queryset, value): return queryset.filter( @@ -123,14 +122,18 @@ class RackFilter(CustomFieldFilterSet, django_filters.FilterSet): ) -class DeviceTypeFilter(django_filters.FilterSet): +class DeviceTypeFilter(CustomFieldFilterSet, django_filters.FilterSet): + q = django_filters.MethodFilter( + action='search', + label='Search', + ) manufacturer_id = django_filters.ModelMultipleChoiceFilter( name='manufacturer', queryset=Manufacturer.objects.all(), label='Manufacturer (ID)', ) manufacturer = django_filters.ModelMultipleChoiceFilter( - name='manufacturer', + name='manufacturer__slug', queryset=Manufacturer.objects.all(), to_field_name='slug', label='Manufacturer (slug)', @@ -138,8 +141,16 @@ class DeviceTypeFilter(django_filters.FilterSet): class Meta: model = DeviceType - fields = ['manufacturer_id', 'manufacturer', 'model', 'part_number', 'u_height', 'is_console_server', 'is_pdu', - 'is_network_device'] + fields = ['model', 'part_number', 'u_height', 'is_console_server', 'is_pdu', 'is_network_device', + 'subdevice_role'] + + def search(self, queryset, value): + return queryset.filter( + Q(manufacturer__name__icontains=value) | + Q(model__icontains=value) | + Q(part_number__icontains=value) | + Q(comments__icontains=value) + ) class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet): @@ -157,7 +168,7 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Site (ID)', ) site = django_filters.ModelMultipleChoiceFilter( - name='rack__site', + name='rack__site__slug', queryset=Site.objects.all(), to_field_name='slug', label='Site name (slug)', @@ -178,7 +189,7 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Role (ID)', ) role = django_filters.ModelMultipleChoiceFilter( - name='device_role', + name='device_role__slug', queryset=DeviceRole.objects.all(), to_field_name='slug', label='Role (slug)', @@ -205,13 +216,13 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Manufacturer (ID)', ) manufacturer = django_filters.ModelMultipleChoiceFilter( - name='device_type__manufacturer', + name='device_type__manufacturer__slug', queryset=Manufacturer.objects.all(), to_field_name='slug', label='Manufacturer (slug)', ) model = django_filters.ModelMultipleChoiceFilter( - name='device_type', + name='device_type__slug', queryset=DeviceType.objects.all(), to_field_name='slug', label='Device model (slug)', @@ -246,9 +257,7 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet): class Meta: model = 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'] + fields = ['name', 'serial', 'asset_tag'] def search(self, queryset, value): return queryset.filter( @@ -284,7 +293,7 @@ class ConsolePortFilter(django_filters.FilterSet): class Meta: model = ConsolePort - fields = ['device_id', 'device', 'name'] + fields = ['name'] class ConsoleServerPortFilter(django_filters.FilterSet): @@ -302,7 +311,7 @@ class ConsoleServerPortFilter(django_filters.FilterSet): class Meta: model = ConsoleServerPort - fields = ['device_id', 'device', 'name'] + fields = ['name'] class PowerPortFilter(django_filters.FilterSet): @@ -320,7 +329,7 @@ class PowerPortFilter(django_filters.FilterSet): class Meta: model = PowerPort - fields = ['device_id', 'device', 'name'] + fields = ['name'] class PowerOutletFilter(django_filters.FilterSet): @@ -338,7 +347,7 @@ class PowerOutletFilter(django_filters.FilterSet): class Meta: model = PowerOutlet - fields = ['device_id', 'device', 'name'] + fields = ['name'] class InterfaceFilter(django_filters.FilterSet): @@ -356,7 +365,7 @@ class InterfaceFilter(django_filters.FilterSet): class Meta: model = Interface - fields = ['device_id', 'device', 'name'] + fields = ['name'] class ConsoleConnectionFilter(django_filters.FilterSet): diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 961d22440..d82f28090 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -13,6 +13,7 @@ from utilities.forms import ( SlugField, ) +from formfields import MACAddressFormField from .models import ( DeviceBay, DeviceBayTemplate, CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_PLANNED, CONNECTION_STATUS_CONNECTED, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceRole, DeviceType, @@ -61,7 +62,8 @@ class SiteForm(BootstrapMixin, CustomFieldForm): class Meta: model = Site - fields = ['name', 'slug', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address', 'comments'] + fields = ['name', 'slug', 'tenant', 'facility', 'asn', 'physical_address', 'shipping_address', 'contact_name', + 'contact_phone', 'contact_email', 'comments'] widgets = { 'physical_address': SmallTextarea(attrs={'rows': 3}), 'shipping_address': SmallTextarea(attrs={'rows': 3}), @@ -81,19 +83,20 @@ class SiteFromCSVForm(forms.ModelForm): class Meta: model = Site - fields = ['name', 'slug', 'tenant', 'facility', 'asn'] + fields = ['name', 'slug', 'tenant', 'facility', 'asn', 'contact_name', 'contact_phone', 'contact_email'] -class SiteImportForm(BulkImportForm, BootstrapMixin): +class SiteImportForm(BootstrapMixin, BulkImportForm): csv = CSVDataField(csv_form=SiteFromCSVForm) class SiteBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=Site.objects.all(), widget=forms.MultipleHiddenInput) tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False) + asn = forms.IntegerField(min_value=1, max_value=4294967295, required=False, label='ASN') class Meta: - nullable_fields = ['tenant'] + nullable_fields = ['tenant', 'asn'] class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm): @@ -106,7 +109,7 @@ class SiteFilterForm(BootstrapMixin, CustomFieldFilterForm): # Rack groups # -class RackGroupForm(forms.ModelForm, BootstrapMixin): +class RackGroupForm(BootstrapMixin, forms.ModelForm): slug = SlugField() class Meta: @@ -114,7 +117,7 @@ class RackGroupForm(forms.ModelForm, BootstrapMixin): fields = ['site', 'name', 'slug'] -class RackGroupFilterForm(forms.Form, BootstrapMixin): +class RackGroupFilterForm(BootstrapMixin, forms.Form): site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('rack_groups')), to_field_name='slug') @@ -122,7 +125,7 @@ class RackGroupFilterForm(forms.Form, BootstrapMixin): # Rack roles # -class RackRoleForm(forms.ModelForm, BootstrapMixin): +class RackRoleForm(BootstrapMixin, forms.ModelForm): slug = SlugField() class Meta: @@ -208,7 +211,7 @@ class RackFromCSVForm(forms.ModelForm): )) -class RackImportForm(BulkImportForm, BootstrapMixin): +class RackImportForm(BootstrapMixin, BulkImportForm): csv = CSVDataField(csv_form=RackFromCSVForm) @@ -242,7 +245,7 @@ class RackFilterForm(BootstrapMixin, CustomFieldFilterForm): # Manufacturers # -class ManufacturerForm(forms.ModelForm, BootstrapMixin): +class ManufacturerForm(BootstrapMixin, forms.ModelForm): slug = SlugField() class Meta: @@ -254,16 +257,16 @@ class ManufacturerForm(forms.ModelForm, BootstrapMixin): # Device types # -class DeviceTypeForm(forms.ModelForm, BootstrapMixin): +class DeviceTypeForm(BootstrapMixin, CustomFieldForm): slug = SlugField(slug_source='model') class Meta: model = DeviceType fields = ['manufacturer', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', 'is_console_server', - 'is_pdu', 'is_network_device', 'subdevice_role'] + 'is_pdu', 'is_network_device', 'subdevice_role', 'comments'] -class DeviceTypeBulkEditForm(BulkEditForm, BootstrapMixin): +class DeviceTypeBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=DeviceType.objects.all(), widget=forms.MultipleHiddenInput) manufacturer = forms.ModelChoiceField(queryset=Manufacturer.objects.all(), required=False) u_height = forms.IntegerField(min_value=1, required=False) @@ -272,7 +275,8 @@ class DeviceTypeBulkEditForm(BulkEditForm, BootstrapMixin): nullable_fields = [] -class DeviceTypeFilterForm(forms.Form, BootstrapMixin): +class DeviceTypeFilterForm(BootstrapMixin, CustomFieldFilterForm): + model = DeviceType manufacturer = FilterChoiceField(queryset=Manufacturer.objects.annotate(filter_count=Count('device_types')), to_field_name='slug') @@ -281,44 +285,76 @@ class DeviceTypeFilterForm(forms.Form, BootstrapMixin): # Device component templates # -class ConsolePortTemplateForm(forms.ModelForm, BootstrapMixin): - name_pattern = ExpandableNameField(label='Name') +class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: model = ConsolePortTemplate - fields = ['name_pattern'] + fields = ['device_type', 'name'] + widgets = { + 'device_type': forms.HiddenInput(), + } -class ConsoleServerPortTemplateForm(forms.ModelForm, BootstrapMixin): +class ConsolePortTemplateCreateForm(BootstrapMixin, forms.Form): name_pattern = ExpandableNameField(label='Name') + +class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm): + class Meta: model = ConsoleServerPortTemplate - fields = ['name_pattern'] + fields = ['device_type', 'name'] + widgets = { + 'device_type': forms.HiddenInput(), + } -class PowerPortTemplateForm(forms.ModelForm, BootstrapMixin): +class ConsoleServerPortTemplateCreateForm(BootstrapMixin, forms.Form): name_pattern = ExpandableNameField(label='Name') + +class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm): + class Meta: model = PowerPortTemplate - fields = ['name_pattern'] + fields = ['device_type', 'name'] + widgets = { + 'device_type': forms.HiddenInput(), + } -class PowerOutletTemplateForm(forms.ModelForm, BootstrapMixin): +class PowerPortTemplateCreateForm(BootstrapMixin, forms.Form): name_pattern = ExpandableNameField(label='Name') + +class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm): + class Meta: model = PowerOutletTemplate - fields = ['name_pattern'] + fields = ['device_type', 'name'] + widgets = { + 'device_type': forms.HiddenInput(), + } -class InterfaceTemplateForm(forms.ModelForm, BootstrapMixin): +class PowerOutletTemplateCreateForm(BootstrapMixin, forms.Form): name_pattern = ExpandableNameField(label='Name') + +class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm): + class Meta: model = InterfaceTemplate - fields = ['name_pattern', 'form_factor', 'mgmt_only'] + fields = ['device_type', 'name', 'form_factor', 'mgmt_only'] + widgets = { + 'device_type': forms.HiddenInput(), + } + + +class InterfaceTemplateCreateForm(BootstrapMixin, forms.Form): + name_pattern = ExpandableNameField(label='Name') + form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES) + mgmt_only = forms.BooleanField(required=False, label='OOB Management') class InterfaceTemplateBulkEditForm(BootstrapMixin, BulkEditForm): @@ -329,19 +365,25 @@ class InterfaceTemplateBulkEditForm(BootstrapMixin, BulkEditForm): nullable_fields = [] -class DeviceBayTemplateForm(forms.ModelForm, BootstrapMixin): - name_pattern = ExpandableNameField(label='Name') +class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm): class Meta: model = DeviceBayTemplate - fields = ['name_pattern'] + fields = ['device_type', 'name'] + widgets = { + 'device_type': forms.HiddenInput(), + } + + +class DeviceBayTemplateCreateForm(BootstrapMixin, forms.Form): + name_pattern = ExpandableNameField(label='Name') # # Device roles # -class DeviceRoleForm(forms.ModelForm, BootstrapMixin): +class DeviceRoleForm(BootstrapMixin, forms.ModelForm): slug = SlugField() class Meta: @@ -353,7 +395,7 @@ class DeviceRoleForm(forms.ModelForm, BootstrapMixin): # Platforms # -class PlatformForm(forms.ModelForm, BootstrapMixin): +class PlatformForm(BootstrapMixin, forms.ModelForm): slug = SlugField() class Meta: @@ -417,6 +459,10 @@ class DeviceForm(BootstrapMixin, CustomFieldForm): ip_choices += [(ip.id, u'{} ({} NAT)'.format(ip.address, ip.nat_inside.interface)) for ip in nat_ips] self.fields['primary_ip{}'.format(family)].choices = [(None, '---------')] + ip_choices + # If editing an existing device, exclude it from the list of occupied rack units. This ensures that a device + # can be flipped from one face to another. + self.fields['position'].widget.attrs['api-url'] += '&exclude={}'.format(self.instance.pk) + else: # An object that doesn't exist yet can't have any IPs assigned to it @@ -566,11 +612,11 @@ class ChildDeviceFromCSVForm(BaseDeviceFromCSVForm): self.add_error('device_bay_name', "Parent device/bay ({} {}) not found".format(parent, device_bay_name)) -class DeviceImportForm(BulkImportForm, BootstrapMixin): +class DeviceImportForm(BootstrapMixin, BulkImportForm): csv = CSVDataField(csv_form=DeviceFromCSVForm) -class ChildDeviceImportForm(BulkImportForm, BootstrapMixin): +class ChildDeviceImportForm(BootstrapMixin, BulkImportForm): csv = CSVDataField(csv_form=ChildDeviceFromCSVForm) @@ -587,18 +633,6 @@ class DeviceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): nullable_fields = ['tenant', 'platform'] -class DeviceBulkAddComponentForm(forms.Form, BootstrapMixin): - pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput) - name_pattern = ExpandableNameField(label='Name') - - -class DeviceBulkAddInterfaceForm(forms.ModelForm, DeviceBulkAddComponentForm): - - class Meta: - model = Interface - fields = ['name_pattern', 'form_factor', 'mgmt_only', 'description'] - - class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Device site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('racks__devices')), to_field_name='slug') @@ -615,11 +649,27 @@ class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm): mac_address = forms.CharField(required=False, label='MAC address') +# +# Bulk device component creation +# + +class DeviceBulkAddComponentForm(BootstrapMixin, forms.Form): + pk = forms.ModelMultipleChoiceField(queryset=Device.objects.all(), widget=forms.MultipleHiddenInput) + name_pattern = ExpandableNameField(label='Name') + + +class DeviceBulkAddInterfaceForm(forms.ModelForm, DeviceBulkAddComponentForm): + + class Meta: + model = Interface + fields = ['pk', 'name_pattern', 'form_factor', 'mgmt_only', 'description'] + + # # Console ports # -class ConsolePortForm(forms.ModelForm, BootstrapMixin): +class ConsolePortForm(BootstrapMixin, forms.ModelForm): class Meta: model = ConsolePort @@ -629,7 +679,7 @@ class ConsolePortForm(forms.ModelForm, BootstrapMixin): } -class ConsolePortCreateForm(forms.Form, BootstrapMixin): +class ConsolePortCreateForm(BootstrapMixin, forms.Form): name_pattern = ExpandableNameField(label='Name') @@ -670,7 +720,7 @@ class ConsoleConnectionCSVForm(forms.Form): .format(self.cleaned_data['device'], self.cleaned_data['console_port'])) -class ConsoleConnectionImportForm(BulkImportForm, BootstrapMixin): +class ConsoleConnectionImportForm(BootstrapMixin, BulkImportForm): csv = CSVDataField(csv_form=ConsoleConnectionCSVForm) def clean(self): @@ -700,7 +750,7 @@ class ConsoleConnectionImportForm(BulkImportForm, BootstrapMixin): self.cleaned_data['csv'] = connection_list -class ConsolePortConnectionForm(forms.ModelForm, BootstrapMixin): +class ConsolePortConnectionForm(BootstrapMixin, forms.ModelForm): rack = forms.ModelChoiceField(queryset=Rack.objects.all(), label='Rack', required=False, widget=forms.Select(attrs={'filter-for': 'console_server'})) console_server = forms.ModelChoiceField(queryset=Device.objects.all(), label='Console Server', required=False, @@ -754,7 +804,7 @@ class ConsolePortConnectionForm(forms.ModelForm, BootstrapMixin): # Console server ports # -class ConsoleServerPortForm(forms.ModelForm, BootstrapMixin): +class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm): class Meta: model = ConsoleServerPort @@ -764,11 +814,11 @@ class ConsoleServerPortForm(forms.ModelForm, BootstrapMixin): } -class ConsoleServerPortCreateForm(forms.Form, BootstrapMixin): +class ConsoleServerPortCreateForm(BootstrapMixin, forms.Form): name_pattern = ExpandableNameField(label='Name') -class ConsoleServerPortConnectionForm(forms.Form, BootstrapMixin): +class ConsoleServerPortConnectionForm(BootstrapMixin, forms.Form): rack = forms.ModelChoiceField(queryset=Rack.objects.all(), label='Rack', required=False, widget=forms.Select(attrs={'filter-for': 'device'})) device = forms.ModelChoiceField(queryset=Device.objects.all(), label='Device', required=False, @@ -816,7 +866,7 @@ class ConsoleServerPortConnectionForm(forms.Form, BootstrapMixin): # Power ports # -class PowerPortForm(forms.ModelForm, BootstrapMixin): +class PowerPortForm(BootstrapMixin, forms.ModelForm): class Meta: model = PowerPort @@ -826,7 +876,7 @@ class PowerPortForm(forms.ModelForm, BootstrapMixin): } -class PowerPortCreateForm(forms.Form, BootstrapMixin): +class PowerPortCreateForm(BootstrapMixin, forms.Form): name_pattern = ExpandableNameField(label='Name') @@ -867,7 +917,7 @@ class PowerConnectionCSVForm(forms.Form): .format(self.cleaned_data['device'], self.cleaned_data['power_port'])) -class PowerConnectionImportForm(BulkImportForm, BootstrapMixin): +class PowerConnectionImportForm(BootstrapMixin, BulkImportForm): csv = CSVDataField(csv_form=PowerConnectionCSVForm) def clean(self): @@ -897,7 +947,7 @@ class PowerConnectionImportForm(BulkImportForm, BootstrapMixin): self.cleaned_data['csv'] = connection_list -class PowerPortConnectionForm(forms.ModelForm, BootstrapMixin): +class PowerPortConnectionForm(BootstrapMixin, forms.ModelForm): rack = forms.ModelChoiceField(queryset=Rack.objects.all(), label='Rack', required=False, widget=forms.Select(attrs={'filter-for': 'pdu'})) pdu = forms.ModelChoiceField(queryset=Device.objects.all(), label='PDU', required=False, @@ -950,7 +1000,7 @@ class PowerPortConnectionForm(forms.ModelForm, BootstrapMixin): # Power outlets # -class PowerOutletForm(forms.ModelForm, BootstrapMixin): +class PowerOutletForm(BootstrapMixin, forms.ModelForm): class Meta: model = PowerOutlet @@ -960,11 +1010,11 @@ class PowerOutletForm(forms.ModelForm, BootstrapMixin): } -class PowerOutletCreateForm(forms.Form, BootstrapMixin): +class PowerOutletCreateForm(BootstrapMixin, forms.Form): name_pattern = ExpandableNameField(label='Name') -class PowerOutletConnectionForm(forms.Form, BootstrapMixin): +class PowerOutletConnectionForm(BootstrapMixin, forms.Form): rack = forms.ModelChoiceField(queryset=Rack.objects.all(), label='Rack', required=False, widget=forms.Select(attrs={'filter-for': 'device'})) device = forms.ModelChoiceField(queryset=Device.objects.all(), label='Device', required=False, @@ -1012,7 +1062,7 @@ class PowerOutletConnectionForm(forms.Form, BootstrapMixin): # Interfaces # -class InterfaceForm(forms.ModelForm, BootstrapMixin): +class InterfaceForm(BootstrapMixin, forms.ModelForm): class Meta: model = Interface @@ -1022,12 +1072,12 @@ class InterfaceForm(forms.ModelForm, BootstrapMixin): } -class InterfaceCreateForm(forms.ModelForm, BootstrapMixin): +class InterfaceCreateForm(BootstrapMixin, forms.Form): name_pattern = ExpandableNameField(label='Name') - - class Meta: - model = Interface - fields = ['name_pattern', 'form_factor', 'mac_address', 'mgmt_only', 'description'] + form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES) + mac_address = MACAddressFormField(required=False, label='MAC Address') + mgmt_only = forms.BooleanField(required=False, label='OOB Management') + description = forms.CharField(max_length=100, required=False) class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm): @@ -1043,10 +1093,13 @@ class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm): # Interface connections # -class InterfaceConnectionForm(forms.ModelForm, BootstrapMixin): +class InterfaceConnectionForm(BootstrapMixin, forms.ModelForm): interface_a = forms.ChoiceField(choices=[], widget=SelectWithDisabled, label='Interface') + site_b = forms.ModelChoiceField(queryset=Site.objects.all(), label='Site', required=False, + widget=forms.Select(attrs={'filter-for': 'rack_b'})) rack_b = forms.ModelChoiceField(queryset=Rack.objects.all(), label='Rack', required=False, - widget=forms.Select(attrs={'filter-for': 'device_b'})) + widget=APISelect(api_url='/api/dcim/racks/?site_id={{site_b}}', + attrs={'filter-for': 'device_b'})) device_b = forms.ModelChoiceField(queryset=Device.objects.all(), label='Device', required=False, widget=APISelect(api_url='/api/dcim/devices/?rack_id={{rack_b}}', display_field='display_name', @@ -1060,21 +1113,27 @@ class InterfaceConnectionForm(forms.ModelForm, BootstrapMixin): class Meta: model = InterfaceConnection - fields = ['interface_a', 'rack_b', 'device_b', 'interface_b', 'livesearch', 'connection_status'] + fields = ['interface_a', 'site_b', 'rack_b', 'device_b', 'interface_b', 'livesearch', 'connection_status'] def __init__(self, device_a, *args, **kwargs): super(InterfaceConnectionForm, self).__init__(*args, **kwargs) - self.fields['rack_b'].queryset = Rack.objects.filter(site=device_a.rack.site) - # Initialize interface A choices - device_a_interfaces = Interface.objects.filter(device=device_a).exclude(form_factor=IFACE_FF_VIRTUAL) \ - .select_related('circuit', 'connected_as_a', 'connected_as_b') + device_a_interfaces = Interface.objects.filter(device=device_a).exclude(form_factor=IFACE_FF_VIRTUAL)\ + .select_related('circuit_termination', 'connected_as_a', 'connected_as_b') self.fields['interface_a'].choices = [ (iface.id, {'label': iface.name, 'disabled': iface.is_connected}) for iface in device_a_interfaces ] + # Initialize rack_b choices if site_b is set + if self.is_bound and self.data.get('site_b'): + self.fields['rack_b'].queryset = Rack.objects.filter(site__pk=self.data['site_b']) + elif self.initial.get('site_b'): + self.fields['rack_b'].queryset = Rack.objects.filter(site=self.initial['site_b']) + else: + self.fields['rack_b'].choices = [] + # Initialize device_b choices if rack_b is set if self.is_bound and self.data.get('rack_b'): self.fields['device_b'].queryset = Device.objects.filter(rack__pk=self.data['rack_b']) @@ -1085,11 +1144,13 @@ class InterfaceConnectionForm(forms.ModelForm, BootstrapMixin): # Initialize interface_b choices if device_b is set if self.is_bound: - device_b_interfaces = Interface.objects.filter(device=self.data['device_b']) \ - .exclude(form_factor=IFACE_FF_VIRTUAL).select_related('circuit', 'connected_as_a', 'connected_as_b') + device_b_interfaces = Interface.objects.filter(device=self.data['device_b'])\ + .exclude(form_factor=IFACE_FF_VIRTUAL)\ + .select_related('circuit_termination', 'connected_as_a', 'connected_as_b') elif self.initial.get('device_b'): - device_b_interfaces = Interface.objects.filter(device=self.initial['device_b']) \ - .exclude(form_factor=IFACE_FF_VIRTUAL).select_related('circuit', 'connected_as_a', 'connected_as_b') + device_b_interfaces = Interface.objects.filter(device=self.initial['device_b'])\ + .exclude(form_factor=IFACE_FF_VIRTUAL)\ + .select_related('circuit_termination', 'connected_as_a', 'connected_as_b') else: device_b_interfaces = [] self.fields['interface_b'].choices = [ @@ -1139,7 +1200,7 @@ class InterfaceConnectionCSVForm(forms.Form): pass -class InterfaceConnectionImportForm(BulkImportForm, BootstrapMixin): +class InterfaceConnectionImportForm(BootstrapMixin, BulkImportForm): csv = CSVDataField(csv_form=InterfaceConnectionCSVForm) def clean(self): @@ -1179,7 +1240,7 @@ class InterfaceConnectionImportForm(BulkImportForm, BootstrapMixin): self.cleaned_data['csv'] = connection_list -class InterfaceConnectionDeletionForm(forms.Form, BootstrapMixin): +class InterfaceConnectionDeletionForm(BootstrapMixin, forms.Form): confirm = forms.BooleanField(required=True) # Used for HTTP redirect upon successful deletion device = forms.ModelChoiceField(queryset=Device.objects.all(), widget=forms.HiddenInput(), required=False) @@ -1189,7 +1250,7 @@ class InterfaceConnectionDeletionForm(forms.Form, BootstrapMixin): # Device bays # -class DeviceBayForm(forms.ModelForm, BootstrapMixin): +class DeviceBayForm(BootstrapMixin, forms.ModelForm): class Meta: model = DeviceBay @@ -1199,11 +1260,11 @@ class DeviceBayForm(forms.ModelForm, BootstrapMixin): } -class DeviceBayCreateForm(forms.Form, BootstrapMixin): +class DeviceBayCreateForm(BootstrapMixin, forms.Form): name_pattern = ExpandableNameField(label='Name') -class PopulateDeviceBayForm(forms.Form, BootstrapMixin): +class PopulateDeviceBayForm(BootstrapMixin, forms.Form): installed_device = forms.ModelChoiceField(queryset=Device.objects.all(), label='Child Device', help_text="Child devices must first be created within the rack occupied " "by the parent device. Then they can be assigned to a bay.") @@ -1224,15 +1285,15 @@ class PopulateDeviceBayForm(forms.Form, BootstrapMixin): # Connections # -class ConsoleConnectionFilterForm(forms.Form, BootstrapMixin): +class ConsoleConnectionFilterForm(BootstrapMixin, forms.Form): site = forms.ModelChoiceField(required=False, queryset=Site.objects.all(), to_field_name='slug') -class PowerConnectionFilterForm(forms.Form, BootstrapMixin): +class PowerConnectionFilterForm(BootstrapMixin, forms.Form): site = forms.ModelChoiceField(required=False, queryset=Site.objects.all(), to_field_name='slug') -class InterfaceConnectionFilterForm(forms.Form, BootstrapMixin): +class InterfaceConnectionFilterForm(BootstrapMixin, forms.Form): site = forms.ModelChoiceField(required=False, queryset=Site.objects.all(), to_field_name='slug') @@ -1270,7 +1331,7 @@ class IPAddressForm(BootstrapMixin, CustomFieldForm): # Modules # -class ModuleForm(forms.ModelForm, BootstrapMixin): +class ModuleForm(BootstrapMixin, forms.ModelForm): class Meta: model = Module diff --git a/netbox/dcim/migrations/0023_devicetype_comments.py b/netbox/dcim/migrations/0023_devicetype_comments.py new file mode 100644 index 000000000..677a8af9d --- /dev/null +++ b/netbox/dcim/migrations/0023_devicetype_comments.py @@ -0,0 +1,20 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10 on 2016-12-16 16:08 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0022_color_names_to_rgb'), + ] + + operations = [ + migrations.AddField( + model_name='devicetype', + name='comments', + field=models.TextField(blank=True), + ), + ] diff --git a/netbox/dcim/migrations/0024_site_add_contact_fields.py b/netbox/dcim/migrations/0024_site_add_contact_fields.py new file mode 100644 index 000000000..34e17561f --- /dev/null +++ b/netbox/dcim/migrations/0024_site_add_contact_fields.py @@ -0,0 +1,30 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.4 on 2016-12-29 16:23 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0023_devicetype_comments'), + ] + + operations = [ + migrations.AddField( + model_name='site', + name='contact_email', + field=models.EmailField(blank=True, max_length=254, verbose_name=b'Contact E-mail'), + ), + migrations.AddField( + model_name='site', + name='contact_name', + field=models.CharField(blank=True, max_length=50), + ), + migrations.AddField( + model_name='site', + name='contact_phone', + field=models.CharField(blank=True, max_length=20), + ), + ] diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index a87b778d7..e0f467d17 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -9,6 +9,7 @@ from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.db.models import Count, Q, ObjectDoesNotExist +from circuits.models import Circuit from extras.models import CustomFieldModel, CustomField, CustomFieldValue from extras.rpc import RPC_CLIENTS from tenancy.models import Tenant @@ -244,6 +245,9 @@ class Site(CreatedUpdatedModel, CustomFieldModel): asn = ASNField(blank=True, null=True, verbose_name='ASN') physical_address = models.CharField(max_length=200, blank=True) shipping_address = models.CharField(max_length=200, blank=True) + contact_name = models.CharField(max_length=50, blank=True) + contact_phone = models.CharField(max_length=20, blank=True) + contact_email = models.EmailField(blank=True, verbose_name="Contact E-mail") comments = models.TextField(blank=True) custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') @@ -264,7 +268,10 @@ class Site(CreatedUpdatedModel, CustomFieldModel): self.slug, self.tenant.name if self.tenant else '', self.facility, - str(self.asn), + str(self.asn) if self.asn else '', + self.contact_name, + self.contact_phone, + self.contact_email, ]) @property @@ -285,7 +292,7 @@ class Site(CreatedUpdatedModel, CustomFieldModel): @property def count_circuits(self): - return self.circuits.count() + return Circuit.objects.filter(terminations__site=self).count() # @@ -401,6 +408,7 @@ class Rack(CreatedUpdatedModel, CustomFieldModel): self.get_type_display() if self.type else '', str(self.width), str(self.u_height), + 'True' if self.desc_units else '', ]) @property @@ -520,7 +528,7 @@ class Manufacturer(models.Model): return "{}?manufacturer={}".format(reverse('dcim:devicetype_list'), self.slug) -class DeviceType(models.Model): +class DeviceType(models.Model, CustomFieldModel): """ A DeviceType represents a particular make (Manufacturer) and model of device. It specifies rack height and depth, as well as high-level functional role(s). @@ -552,6 +560,8 @@ class DeviceType(models.Model): choices=SUBDEVICE_ROLE_CHOICES, help_text="Parent devices house child devices in device bays. Select " "\"None\" if this device type is neither a parent nor a child.") + comments = models.TextField(blank=True) + custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') class Meta: ordering = ['manufacturer', 'model'] @@ -1136,7 +1146,7 @@ class Interface(models.Model): @property def is_connected(self): try: - return bool(self.circuit) + return bool(self.circuit_termination) except ObjectDoesNotExist: pass return bool(self.connection) @@ -1153,13 +1163,18 @@ class Interface(models.Model): pass return None - def get_connected_interface(self): - connection = InterfaceConnection.objects.select_related().filter(Q(interface_a=self) | Q(interface_b=self))\ - .first() - if connection and connection.interface_a == self: - return connection.interface_b - elif connection: - return connection.interface_a + @property + def connected_interface(self): + try: + if self.connected_as_a: + return self.connected_as_a.interface_b + except ObjectDoesNotExist: + pass + try: + if self.connected_as_b: + return self.connected_as_b.interface_a + except ObjectDoesNotExist: + pass return None diff --git a/netbox/dcim/tests/test_apis.py b/netbox/dcim/tests/test_apis.py index 352c36899..1305d7e37 100644 --- a/netbox/dcim/tests/test_apis.py +++ b/netbox/dcim/tests/test_apis.py @@ -22,6 +22,9 @@ class SiteTest(APITestCase): 'asn', 'physical_address', 'shipping_address', + 'contact_name', + 'contact_phone', + 'contact_email', 'comments', 'custom_fields', 'count_prefixes', @@ -233,6 +236,8 @@ class DeviceTypeTest(APITestCase): 'is_pdu', 'is_network_device', 'subdevice_role', + 'comments', + 'custom_fields', ] nested_fields = [ diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 3ec018116..58bc91802 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -1,5 +1,6 @@ from django.conf.urls import url +from ipam.views import ServiceEditView from secrets.views import secret_add from . import views @@ -104,9 +105,11 @@ urlpatterns = [ url(r'^devices/(?P\d+)/lldp-neighbors/$', views.device_lldp_neighbors, name='device_lldp_neighbors'), url(r'^devices/(?P\d+)/ip-addresses/assign/$', views.ipaddress_assign, name='ipaddress_assign'), url(r'^devices/(?P\d+)/add-secret/$', secret_add, name='device_addsecret'), + url(r'^devices/(?P\d+)/services/assign/$', ServiceEditView.as_view(), name='service_assign'), # Console ports - url(r'^devices/(?P\d+)/console-ports/add/$', views.consoleport_add, name='consoleport_add'), + url(r'^devices/console-ports/add/$', views.DeviceBulkAddConsolePortView.as_view(), name='device_bulk_add_consoleport'), + url(r'^devices/(?P\d+)/console-ports/add/$', views.ConsolePortAddView.as_view(), name='consoleport_add'), url(r'^devices/(?P\d+)/console-ports/delete/$', views.ConsolePortBulkDeleteView.as_view(), name='consoleport_bulk_delete'), url(r'^console-ports/(?P\d+)/connect/$', views.consoleport_connect, name='consoleport_connect'), url(r'^console-ports/(?P\d+)/disconnect/$', views.consoleport_disconnect, name='consoleport_disconnect'), @@ -114,7 +117,8 @@ urlpatterns = [ url(r'^console-ports/(?P\d+)/delete/$', views.ConsolePortDeleteView.as_view(), name='consoleport_delete'), # Console server ports - url(r'^devices/(?P\d+)/console-server-ports/add/$', views.consoleserverport_add, name='consoleserverport_add'), + url(r'^devices/console-server-ports/add/$', views.DeviceBulkAddConsoleServerPortView.as_view(), name='device_bulk_add_consoleserverport'), + url(r'^devices/(?P\d+)/console-server-ports/add/$', views.ConsoleServerPortAddView.as_view(), name='consoleserverport_add'), url(r'^devices/(?P\d+)/console-server-ports/delete/$', views.ConsoleServerPortBulkDeleteView.as_view(), name='consoleserverport_bulk_delete'), url(r'^console-server-ports/(?P\d+)/connect/$', views.consoleserverport_connect, name='consoleserverport_connect'), url(r'^console-server-ports/(?P\d+)/disconnect/$', views.consoleserverport_disconnect, name='consoleserverport_disconnect'), @@ -122,7 +126,8 @@ urlpatterns = [ url(r'^console-server-ports/(?P\d+)/delete/$', views.ConsoleServerPortDeleteView.as_view(), name='consoleserverport_delete'), # Power ports - url(r'^devices/(?P\d+)/power-ports/add/$', views.powerport_add, name='powerport_add'), + url(r'^devices/power-ports/add/$', views.DeviceBulkAddPowerPortView.as_view(), name='device_bulk_add_powerport'), + url(r'^devices/(?P\d+)/power-ports/add/$', views.PowerPortAddView.as_view(), name='powerport_add'), url(r'^devices/(?P\d+)/power-ports/delete/$', views.PowerPortBulkDeleteView.as_view(), name='powerport_bulk_delete'), url(r'^power-ports/(?P\d+)/connect/$', views.powerport_connect, name='powerport_connect'), url(r'^power-ports/(?P\d+)/disconnect/$', views.powerport_disconnect, name='powerport_disconnect'), @@ -130,15 +135,27 @@ urlpatterns = [ url(r'^power-ports/(?P\d+)/delete/$', views.PowerPortDeleteView.as_view(), name='powerport_delete'), # Power outlets - url(r'^devices/(?P\d+)/power-outlets/add/$', views.poweroutlet_add, name='poweroutlet_add'), + url(r'^devices/power-outlets/add/$', views.DeviceBulkAddPowerOutletView.as_view(), name='device_bulk_add_poweroutlet'), + url(r'^devices/(?P\d+)/power-outlets/add/$', views.PowerOutletAddView.as_view(), name='poweroutlet_add'), url(r'^devices/(?P\d+)/power-outlets/delete/$', views.PowerOutletBulkDeleteView.as_view(), name='poweroutlet_bulk_delete'), url(r'^power-outlets/(?P\d+)/connect/$', views.poweroutlet_connect, name='poweroutlet_connect'), url(r'^power-outlets/(?P\d+)/disconnect/$', views.poweroutlet_disconnect, name='poweroutlet_disconnect'), url(r'^power-outlets/(?P\d+)/edit/$', views.PowerOutletEditView.as_view(), name='poweroutlet_edit'), url(r'^power-outlets/(?P\d+)/delete/$', views.PowerOutletDeleteView.as_view(), name='poweroutlet_delete'), + # Interfaces + url(r'^devices/interfaces/add/$', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'), + url(r'^devices/(?P\d+)/interfaces/add/$', views.InterfaceAddView.as_view(), name='interface_add'), + url(r'^devices/(?P\d+)/interfaces/edit/$', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'), + url(r'^devices/(?P\d+)/interfaces/delete/$', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'), + url(r'^devices/(?P\d+)/interface-connections/add/$', views.interfaceconnection_add, name='interfaceconnection_add'), + url(r'^interface-connections/(?P\d+)/delete/$', views.interfaceconnection_delete, name='interfaceconnection_delete'), + url(r'^interfaces/(?P\d+)/edit/$', views.InterfaceEditView.as_view(), name='interface_edit'), + url(r'^interfaces/(?P\d+)/delete/$', views.InterfaceDeleteView.as_view(), name='interface_delete'), + # Device bays - url(r'^devices/(?P\d+)/bays/add/$', views.devicebay_add, name='devicebay_add'), + url(r'^devices/device-bays/add/$', views.DeviceBulkAddDeviceBayView.as_view(), name='device_bulk_add_devicebay'), + url(r'^devices/(?P\d+)/bays/add/$', views.DeviceBayAddView.as_view(), name='devicebay_add'), url(r'^devices/(?P\d+)/bays/delete/$', views.DeviceBayBulkDeleteView.as_view(), name='devicebay_bulk_delete'), url(r'^device-bays/(?P\d+)/edit/$', views.DeviceBayEditView.as_view(), name='devicebay_edit'), url(r'^device-bays/(?P\d+)/delete/$', views.DeviceBayDeleteView.as_view(), name='devicebay_delete'), @@ -153,18 +170,8 @@ urlpatterns = [ url(r'^interface-connections/$', views.InterfaceConnectionsListView.as_view(), name='interface_connections_list'), url(r'^interface-connections/import/$', views.InterfaceConnectionsBulkImportView.as_view(), name='interface_connections_import'), - # Interfaces - url(r'^devices/interfaces/add/$', views.DeviceBulkAddInterfaceView.as_view(), name='device_bulk_add_interface'), - url(r'^devices/(?P\d+)/interfaces/add/$', views.interface_add, name='interface_add'), - url(r'^devices/(?P\d+)/interfaces/edit/$', views.InterfaceBulkEditView.as_view(), name='interface_bulk_edit'), - url(r'^devices/(?P\d+)/interfaces/delete/$', views.InterfaceBulkDeleteView.as_view(), name='interface_bulk_delete'), - url(r'^devices/(?P\d+)/interface-connections/add/$', views.interfaceconnection_add, name='interfaceconnection_add'), - url(r'^interface-connections/(?P\d+)/delete/$', views.interfaceconnection_delete, name='interfaceconnection_delete'), - url(r'^interfaces/(?P\d+)/edit/$', views.InterfaceEditView.as_view(), name='interface_edit'), - url(r'^interfaces/(?P\d+)/delete/$', views.InterfaceDeleteView.as_view(), name='interface_delete'), - # Modules - url(r'^devices/(?P\d+)/modules/add/$', views.module_add, name='module_add'), + url(r'^devices/(?P\d+)/modules/add/$', views.ModuleEditView.as_view(), name='module_add'), url(r'^modules/(?P\d+)/edit/$', views.ModuleEditView.as_view(), name='module_edit'), url(r'^modules/(?P\d+)/delete/$', views.ModuleDeleteView.as_view(), name='module_delete'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index d7fc2a88b..58c38bfb0 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -6,7 +6,6 @@ from operator import attrgetter from django.contrib import messages from django.contrib.auth.decorators import permission_required 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 from django.http import HttpResponseRedirect @@ -14,7 +13,7 @@ from django.shortcuts import get_object_or_404, redirect, render from django.utils.http import urlencode from django.views.generic import View -from ipam.models import Prefix, IPAddress, VLAN +from ipam.models import Prefix, IPAddress, Service, VLAN from circuits.models import Circuit from extras.models import Graph, TopologyMap, GRAPH_TYPE_INTERFACE, GRAPH_TYPE_SITE from utilities.forms import ConfirmationForm @@ -57,6 +56,66 @@ def expand_pattern(string): yield "{0}{1}".format(lead, i) +class ComponentCreateView(View): + parent_model = None + parent_field = None + model = None + form = None + model_form = None + + def get(self, request, pk): + + parent = get_object_or_404(self.parent_model, pk=pk) + + return render(request, 'dcim/device_component_add.html', { + 'parent': parent, + 'component_type': self.model._meta.verbose_name, + 'form': self.form(initial=request.GET), + 'cancel_url': parent.get_absolute_url(), + }) + + def post(self, request, pk): + + parent = get_object_or_404(self.parent_model, pk=pk) + + form = self.form(request.POST) + if form.is_valid(): + + new_components = [] + data = deepcopy(form.cleaned_data) + + for name in form.cleaned_data['name_pattern']: + component_data = { + self.parent_field: parent.pk, + 'name': name, + } + component_data.update(data) + component_form = self.model_form(component_data) + if component_form.is_valid(): + new_components.append(component_form.save(commit=False)) + else: + for field, errors in component_form.errors.as_data().items(): + for e in errors: + form.add_error(field, u'{}: {}'.format(name, ', '.join(e))) + + if not form.errors: + self.model.objects.bulk_create(new_components) + messages.success(request, u"Added {} {} to {}.".format( + len(new_components), self.model._meta.verbose_name_plural, parent + )) + if '_addanother' in request.POST: + return redirect(request.path) + else: + return redirect(parent.get_absolute_url()) + + return render(request, 'dcim/device_component_add.html', { + 'parent': parent, + 'component_type': self.model._meta.verbose_name, + 'form': form, + 'cancel_url': parent.get_absolute_url(), + }) + + # # Sites # @@ -78,7 +137,7 @@ def site(request, slug): 'device_count': Device.objects.filter(rack__site=site).count(), 'prefix_count': Prefix.objects.filter(site=site).count(), 'vlan_count': VLAN.objects.filter(site=site).count(), - 'circuit_count': Circuit.objects.filter(site=site).count(), + 'circuit_count': Circuit.objects.filter(terminations__site=site).count(), } rack_groups = RackGroup.objects.filter(site=site).annotate(rack_count=Count('racks')) topology_maps = TopologyMap.objects.filter(site=site) @@ -331,6 +390,7 @@ class DeviceTypeEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.change_devicetype' model = DeviceType form_class = forms.DeviceTypeForm + template_name = 'dcim/devicetype_edit.html' obj_list_url = 'dcim:devicetype_list' @@ -358,69 +418,30 @@ class DeviceTypeBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Device type components # -class ComponentTemplateCreateView(View): - model = None - form = None - - def get(self, request, pk): - - devicetype = get_object_or_404(DeviceType, pk=pk) - - return render(request, 'dcim/component_template_add.html', { - 'devicetype': devicetype, - 'component_type': self.model._meta.verbose_name, - 'form': self.form(initial=request.GET), - 'cancel_url': reverse('dcim:devicetype', kwargs={'pk': devicetype.pk}), - }) - - def post(self, request, pk): - - devicetype = get_object_or_404(DeviceType, pk=pk) - - form = self.form(request.POST) - if form.is_valid(): - - component_templates = [] - for name in form.cleaned_data['name_pattern']: - component_template = self.form(request.POST).save(commit=False) - component_template.device_type = devicetype - component_template.name = name - try: - component_template.full_clean() - component_templates.append(component_template) - except ValidationError: - form.add_error('name_pattern', "Duplicate name found: {}".format(name)) - - if not form.errors: - self.model.objects.bulk_create(component_templates) - messages.success(request, u"Added {} component(s) to {}.".format(len(component_templates), devicetype)) - if '_addanother' in request.POST: - return redirect(request.path) - else: - return redirect('dcim:devicetype', pk=devicetype.pk) - - return render(request, 'dcim/component_template_add.html', { - 'devicetype': devicetype, - 'component_type': self.model._meta.verbose_name, - 'form': form, - 'cancel_url': reverse('dcim:devicetype', kwargs={'pk': devicetype.pk}), - }) - - -class ConsolePortTemplateAddView(ComponentTemplateCreateView): +class ConsolePortTemplateAddView(PermissionRequiredMixin, ComponentCreateView): + permission_required = 'dcim.add_consoleporttemplate' + parent_model = DeviceType + parent_field = 'device_type' model = ConsolePortTemplate - form = forms.ConsolePortTemplateForm + form = forms.ConsolePortTemplateCreateForm + model_form = forms.ConsolePortTemplateForm class ConsolePortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'dcim.delete_consoleporttemplate' + parent_model = DeviceType + parent_field = 'device_type' cls = ConsolePortTemplate parent_cls = DeviceType -class ConsoleServerPortTemplateAddView(ComponentTemplateCreateView): +class ConsoleServerPortTemplateAddView(PermissionRequiredMixin, ComponentCreateView): + permission_required = 'dcim.add_consoleserverporttemplate' + parent_model = DeviceType + parent_field = 'device_type' model = ConsoleServerPortTemplate - form = forms.ConsoleServerPortTemplateForm + form = forms.ConsoleServerPortTemplateCreateForm + model_form = forms.ConsoleServerPortTemplateForm class ConsoleServerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): @@ -429,9 +450,13 @@ class ConsoleServerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDelet parent_cls = DeviceType -class PowerPortTemplateAddView(ComponentTemplateCreateView): +class PowerPortTemplateAddView(PermissionRequiredMixin, ComponentCreateView): + permission_required = 'dcim.add_powerporttemplate' + parent_model = DeviceType + parent_field = 'device_type' model = PowerPortTemplate - form = forms.PowerPortTemplateForm + form = forms.PowerPortTemplateCreateForm + model_form = forms.PowerPortTemplateForm class PowerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): @@ -440,9 +465,13 @@ class PowerPortTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): parent_cls = DeviceType -class PowerOutletTemplateAddView(ComponentTemplateCreateView): +class PowerOutletTemplateAddView(PermissionRequiredMixin, ComponentCreateView): + permission_required = 'dcim.add_poweroutlettemplate' + parent_model = DeviceType + parent_field = 'device_type' model = PowerOutletTemplate - form = forms.PowerOutletTemplateForm + form = forms.PowerOutletTemplateCreateForm + model_form = forms.PowerOutletTemplateForm class PowerOutletTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): @@ -451,9 +480,13 @@ class PowerOutletTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView) parent_cls = DeviceType -class InterfaceTemplateAddView(ComponentTemplateCreateView): +class InterfaceTemplateAddView(PermissionRequiredMixin, ComponentCreateView): + permission_required = 'dcim.add_interfacetemplate' + parent_model = DeviceType + parent_field = 'device_type' model = InterfaceTemplate - form = forms.InterfaceTemplateForm + form = forms.InterfaceTemplateCreateForm + model_form = forms.InterfaceTemplateForm class InterfaceTemplateBulkEditView(PermissionRequiredMixin, BulkEditView): @@ -470,9 +503,13 @@ class InterfaceTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): parent_cls = DeviceType -class DeviceBayTemplateAddView(ComponentTemplateCreateView): +class DeviceBayTemplateAddView(PermissionRequiredMixin, ComponentCreateView): + permission_required = 'dcim.add_devicebaytemplate' + parent_model = DeviceType + parent_field = 'device_type' model = DeviceBayTemplate - form = forms.DeviceBayTemplateForm + form = forms.DeviceBayTemplateCreateForm + model_form = forms.DeviceBayTemplateForm class DeviceBayTemplateBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): @@ -560,21 +597,26 @@ def device(request, pk): power_outlets = natsorted( PowerOutlet.objects.filter(device=device).select_related('connected_port'), key=attrgetter('name') ) - interfaces = Interface.objects.filter(device=device, mgmt_only=False)\ - .select_related('connected_as_a', 'connected_as_b', 'circuit') - mgmt_interfaces = Interface.objects.filter(device=device, mgmt_only=True)\ - .select_related('connected_as_a', 'connected_as_b', 'circuit') + interfaces = Interface.objects.filter(device=device, mgmt_only=False).select_related( + 'connected_as_a__interface_b__device', + 'connected_as_b__interface_a__device', + 'circuit_termination__circuit', + ) + mgmt_interfaces = Interface.objects.filter(device=device, mgmt_only=True).select_related( + 'connected_as_a__interface_b__device', + 'connected_as_b__interface_a__device', + 'circuit_termination__circuit', + ) device_bays = natsorted( DeviceBay.objects.filter(device=device).select_related('installed_device__device_type__manufacturer'), key=attrgetter('name') ) - # Gather any secrets which belong to this device - secrets = device.secrets.all() - - # Find all IP addresses assigned to this device + # Gather relevant device objects ip_addresses = IPAddress.objects.filter(interface__device=device).select_related('interface', 'vrf')\ .order_by('address') + services = Service.objects.filter(device=device) + secrets = device.secrets.all() # Find any related devices for convenient linking in the UI related_devices = [] @@ -604,6 +646,7 @@ def device(request, pk): 'mgmt_interfaces': mgmt_interfaces, 'device_bays': device_bays, 'ip_addresses': ip_addresses, + 'services': services, 'secrets': secrets, 'related_devices': related_devices, 'show_graphs': show_graphs, @@ -687,121 +730,17 @@ def device_lldp_neighbors(request, pk): }) -class DeviceBulkAddComponentView(View): - """ - Add one or more components (e.g. interfaces) to a selected set of Devices. - """ - form = None - component_cls = None - component_form = None - - def get(self): - return redirect('dcim:device_list') - - def post(self, request): - - # Are we editing *all* objects in the queryset or just a selected subset? - if request.POST.get('_all'): - pk_list = [int(pk) for pk in request.POST.get('pk_all').split(',') if pk] - else: - pk_list = [int(pk) for pk in request.POST.getlist('pk')] - - if '_create' in request.POST: - form = self.form(request.POST) - if form.is_valid(): - - new_components = [] - data = deepcopy(form.cleaned_data) - for device in data['pk']: - - names = data['name_pattern'] - for name in names: - component_data = { - 'device': device.pk, - 'name': name, - } - component_data.update(data) - component_form = self.component_form(component_data) - if component_form.is_valid(): - new_components.append(component_form.save(commit=False)) - else: - form.add_error('name_pattern', "Duplicate {} name for {}: {}".format( - self.component_cls._meta.verbose_name, device, name - )) - - if not form.errors: - self.component_cls.objects.bulk_create(new_components) - messages.success(request, u"Added {} {} to {} devices.".format( - len(new_components), self.component_cls._meta.verbose_name_plural, len(form.cleaned_data['pk']) - )) - return redirect('dcim:device_list') - - else: - form = self.form(initial={'pk': pk_list}) - - selected_devices = Device.objects.filter(pk__in=pk_list) - if not selected_devices: - messages.warning(request, u"No devices were selected.") - return redirect('dcim:device_list') - - return render(request, 'dcim/device_bulk_add_component.html', { - 'form': form, - 'component_name': self.component_cls._meta.verbose_name_plural, - 'selected_devices': selected_devices, - 'cancel_url': reverse('dcim:device_list'), - }) - - -class DeviceBulkAddInterfaceView(DeviceBulkAddComponentView): - """ - Add one or more components (e.g. interfaces) to a selected set of Devices. - """ - form = forms.DeviceBulkAddInterfaceForm - component_cls = Interface - component_form = forms.InterfaceForm - - # # Console ports # -@permission_required('dcim.add_consoleport') -def consoleport_add(request, pk): - - device = get_object_or_404(Device, pk=pk) - - if request.method == 'POST': - form = forms.ConsolePortCreateForm(request.POST) - if form.is_valid(): - - console_ports = [] - for name in form.cleaned_data['name_pattern']: - cp_form = forms.ConsolePortForm({ - 'device': device.pk, - 'name': name, - }) - if cp_form.is_valid(): - console_ports.append(cp_form.save(commit=False)) - else: - form.add_error('name_pattern', "Duplicate console port name for this device: {}".format(name)) - - if not form.errors: - ConsolePort.objects.bulk_create(console_ports) - messages.success(request, u"Added {} console port(s) to {}.".format(len(console_ports), device)) - if '_addanother' in request.POST: - return redirect('dcim:consoleport_add', pk=device.pk) - else: - return redirect('dcim:device', pk=device.pk) - - else: - form = forms.ConsolePortCreateForm() - - return render(request, 'dcim/device_component_add.html', { - 'device': device, - 'component_type': 'Console Port', - 'form': form, - 'cancel_url': reverse('dcim:device', kwargs={'pk': device.pk}), - }) +class ConsolePortAddView(PermissionRequiredMixin, ComponentCreateView): + permission_required = 'dcim.add_consoleport' + parent_model = Device + parent_field = 'device' + model = ConsolePort + form = forms.ConsolePortCreateForm + model_form = forms.ConsolePortForm @permission_required('dcim.change_consoleport') @@ -891,44 +830,13 @@ class ConsoleConnectionsBulkImportView(PermissionRequiredMixin, BulkImportView): # Console server ports # -@permission_required('dcim.add_consoleserverport') -def consoleserverport_add(request, pk): - - device = get_object_or_404(Device, pk=pk) - - if request.method == 'POST': - form = forms.ConsoleServerPortCreateForm(request.POST) - if form.is_valid(): - - cs_ports = [] - for name in form.cleaned_data['name_pattern']: - csp_form = forms.ConsoleServerPortForm({ - 'device': device.pk, - 'name': name, - }) - if csp_form.is_valid(): - cs_ports.append(csp_form.save(commit=False)) - else: - form.add_error('name_pattern', "Duplicate console server port name for this device: {}" - .format(name)) - - if not form.errors: - ConsoleServerPort.objects.bulk_create(cs_ports) - messages.success(request, u"Added {} console server port(s) to {}.".format(len(cs_ports), device)) - if '_addanother' in request.POST: - return redirect('dcim:consoleserverport_add', pk=device.pk) - else: - return redirect('dcim:device', pk=device.pk) - - else: - form = forms.ConsoleServerPortCreateForm() - - return render(request, 'dcim/device_component_add.html', { - 'device': device, - 'component_type': 'Console Server Port', - 'form': form, - 'cancel_url': reverse('dcim:device', kwargs={'pk': device.pk}), - }) +class ConsoleServerPortAddView(PermissionRequiredMixin, ComponentCreateView): + permission_required = 'dcim.add_consoleserverport' + parent_model = Device + parent_field = 'device' + model = ConsoleServerPort + form = forms.ConsoleServerPortCreateForm + model_form = forms.ConsoleServerPortForm @permission_required('dcim.change_consoleserverport') @@ -1012,43 +920,13 @@ class ConsoleServerPortBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Power ports # -@permission_required('dcim.add_powerport') -def powerport_add(request, pk): - - device = get_object_or_404(Device, pk=pk) - - if request.method == 'POST': - form = forms.PowerPortCreateForm(request.POST) - if form.is_valid(): - - power_ports = [] - for name in form.cleaned_data['name_pattern']: - pp_form = forms.PowerPortForm({ - 'device': device.pk, - 'name': name, - }) - if pp_form.is_valid(): - power_ports.append(pp_form.save(commit=False)) - else: - form.add_error('name_pattern', "Duplicate power port name for this device: {}".format(name)) - - if not form.errors: - PowerPort.objects.bulk_create(power_ports) - messages.success(request, u"Added {} power port(s) to {}.".format(len(power_ports), device)) - if '_addanother' in request.POST: - return redirect('dcim:powerport_add', pk=device.pk) - else: - return redirect('dcim:device', pk=device.pk) - - else: - form = forms.PowerPortCreateForm() - - return render(request, 'dcim/device_component_add.html', { - 'device': device, - 'component_type': 'Power Port', - 'form': form, - 'cancel_url': reverse('dcim:device', kwargs={'pk': device.pk}), - }) +class PowerPortAddView(PermissionRequiredMixin, ComponentCreateView): + permission_required = 'dcim.add_powerport' + parent_model = Device + parent_field = 'device' + model = PowerPort + form = forms.PowerPortCreateForm + model_form = forms.PowerPortForm @permission_required('dcim.change_powerport') @@ -1138,43 +1016,13 @@ class PowerConnectionsBulkImportView(PermissionRequiredMixin, BulkImportView): # Power outlets # -@permission_required('dcim.add_poweroutlet') -def poweroutlet_add(request, pk): - - device = get_object_or_404(Device, pk=pk) - - if request.method == 'POST': - form = forms.PowerOutletCreateForm(request.POST) - if form.is_valid(): - - power_outlets = [] - for name in form.cleaned_data['name_pattern']: - po_form = forms.PowerOutletForm({ - 'device': device.pk, - 'name': name, - }) - if po_form.is_valid(): - power_outlets.append(po_form.save(commit=False)) - else: - form.add_error('name_pattern', "Duplicate power outlet name for this device: {}".format(name)) - - if not form.errors: - PowerOutlet.objects.bulk_create(power_outlets) - messages.success(request, u"Added {} power outlet(s) to {}.".format(len(power_outlets), device)) - if '_addanother' in request.POST: - return redirect('dcim:poweroutlet_add', pk=device.pk) - else: - return redirect('dcim:device', pk=device.pk) - - else: - form = forms.PowerOutletCreateForm() - - return render(request, 'dcim/device_component_add.html', { - 'device': device, - 'component_type': 'Power Outlet', - 'form': form, - 'cancel_url': reverse('dcim:device', kwargs={'pk': device.pk}), - }) +class PowerOutletAddView(PermissionRequiredMixin, ComponentCreateView): + permission_required = 'dcim.add_poweroutlet' + parent_model = Device + parent_field = 'device' + model = PowerOutlet + form = forms.PowerOutletCreateForm + model_form = forms.PowerOutletForm @permission_required('dcim.change_poweroutlet') @@ -1257,47 +1105,13 @@ class PowerOutletBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Interfaces # -@permission_required('dcim.add_interface') -def interface_add(request, pk): - - device = get_object_or_404(Device, pk=pk) - - if request.method == 'POST': - form = forms.InterfaceCreateForm(request.POST) - if form.is_valid(): - - interfaces = [] - for name in form.cleaned_data['name_pattern']: - iface_form = forms.InterfaceForm({ - 'device': device.pk, - 'name': name, - 'form_factor': form.cleaned_data['form_factor'], - 'mac_address': form.cleaned_data['mac_address'], - 'mgmt_only': form.cleaned_data['mgmt_only'], - 'description': form.cleaned_data['description'], - }) - if iface_form.is_valid(): - interfaces.append(iface_form.save(commit=False)) - else: - form.add_error('name_pattern', "Duplicate interface name for this device: {}".format(name)) - - if not form.errors: - Interface.objects.bulk_create(interfaces) - messages.success(request, u"Added {} interface(s) to {}.".format(len(interfaces), device)) - if '_addanother' in request.POST: - return redirect('dcim:interface_add', pk=device.pk) - else: - return redirect('dcim:device', pk=device.pk) - - else: - form = forms.InterfaceCreateForm(initial={'mgmt_only': request.GET.get('mgmt_only')}) - - return render(request, 'dcim/device_component_add.html', { - 'device': device, - 'component_type': 'Interface', - 'form': form, - 'cancel_url': reverse('dcim:device', kwargs={'pk': device.pk}), - }) +class InterfaceAddView(PermissionRequiredMixin, ComponentCreateView): + permission_required = 'dcim.add_interface' + parent_model = Device + parent_field = 'device' + model = Interface + form = forms.InterfaceCreateForm + model_form = forms.InterfaceForm class InterfaceEditView(PermissionRequiredMixin, ObjectEditView): @@ -1329,44 +1143,13 @@ class InterfaceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Device bays # -@permission_required('dcim.add_devicebay') -def devicebay_add(request, pk): - - device = get_object_or_404(Device, pk=pk) - - if request.method == 'POST': - form = forms.DeviceBayCreateForm(request.POST) - if form.is_valid(): - - device_bays = [] - for name in form.cleaned_data['name_pattern']: - devicebay_form = forms.DeviceBayForm({ - 'device': device.pk, - 'name': name, - }) - if devicebay_form.is_valid(): - device_bays.append(devicebay_form.save(commit=False)) - else: - for err in devicebay_form.errors.get('__all__', []): - form.add_error('name_pattern', err) - - if not form.errors: - DeviceBay.objects.bulk_create(device_bays) - messages.success(request, u"Added {} device bay(s) to {}.".format(len(device_bays), device)) - if '_addanother' in request.POST: - return redirect('dcim:devicebay_add', pk=device.pk) - else: - return redirect('dcim:device', pk=device.pk) - - else: - form = forms.DeviceBayCreateForm() - - return render(request, 'dcim/device_component_add.html', { - 'device': device, - 'component_type': 'Device Bay', - 'form': form, - 'cancel_url': reverse('dcim:device', kwargs={'pk': device.pk}), - }) +class DeviceBayAddView(PermissionRequiredMixin, ComponentCreateView): + permission_required = 'dcim.add_devicebay' + parent_model = Device + parent_field = 'device' + model = DeviceBay + form = forms.DeviceBayCreateForm + model_form = forms.DeviceBayForm class DeviceBayEditView(PermissionRequiredMixin, ObjectEditView): @@ -1436,6 +1219,112 @@ class DeviceBayBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): parent_cls = Device +# +# Bulk device component creation +# + +class DeviceBulkAddComponentView(View): + """ + Add one or more components (e.g. interfaces) to a selected set of Devices. + """ + form = forms.DeviceBulkAddComponentForm + model = None + model_form = None + + def get(self): + return redirect('dcim:device_list') + + def post(self, request): + + # Are we editing *all* objects in the queryset or just a selected subset? + if request.POST.get('_all'): + pk_list = [int(pk) for pk in request.POST.get('pk_all').split(',') if pk] + else: + pk_list = [int(pk) for pk in request.POST.getlist('pk')] + + if '_create' in request.POST: + form = self.form(request.POST) + if form.is_valid(): + + new_components = [] + data = deepcopy(form.cleaned_data) + for device in data['pk']: + + names = data['name_pattern'] + for name in names: + component_data = { + 'device': device.pk, + 'name': name, + } + component_data.update(data) + component_form = self.model_form(component_data) + if component_form.is_valid(): + new_components.append(component_form.save(commit=False)) + else: + for field, errors in component_form.errors.as_data().items(): + for e in errors: + form.add_error(field, u'{} {}: {}'.format(device, name, ', '.join(e))) + + if not form.errors: + self.model.objects.bulk_create(new_components) + messages.success(request, u"Added {} {} to {} devices.".format( + len(new_components), self.model._meta.verbose_name_plural, len(form.cleaned_data['pk']) + )) + return redirect('dcim:device_list') + + else: + form = self.form(initial={'pk': pk_list}) + + selected_devices = Device.objects.filter(pk__in=pk_list) + if not selected_devices: + messages.warning(request, u"No devices were selected.") + return redirect('dcim:device_list') + + return render(request, 'dcim/device_bulk_add_component.html', { + 'form': form, + 'component_name': self.model._meta.verbose_name_plural, + 'selected_devices': selected_devices, + 'cancel_url': reverse('dcim:device_list'), + }) + + +class DeviceBulkAddConsolePortView(PermissionRequiredMixin, DeviceBulkAddComponentView): + permission_required = 'dcim.add_consoleport' + model = ConsolePort + model_form = forms.ConsolePortForm + + +class DeviceBulkAddConsoleServerPortView(PermissionRequiredMixin, DeviceBulkAddComponentView): + permission_required = 'dcim.add_consoleserverport' + model = ConsoleServerPort + model_form = forms.ConsoleServerPortForm + + +class DeviceBulkAddPowerPortView(PermissionRequiredMixin, DeviceBulkAddComponentView): + permission_required = 'dcim.add_powerport' + model = PowerPort + model_form = forms.PowerPortForm + + +class DeviceBulkAddPowerOutletView(PermissionRequiredMixin, DeviceBulkAddComponentView): + permission_required = 'dcim.add_poweroutlet' + model = PowerOutlet + model_form = forms.PowerOutletForm + + +class DeviceBulkAddInterfaceView(PermissionRequiredMixin, DeviceBulkAddComponentView): + permission_required = 'dcim.add_interface' + form = forms.DeviceBulkAddInterfaceForm + model = Interface + model_form = forms.InterfaceForm + + +class DeviceBulkAddDeviceBayView(PermissionRequiredMixin, DeviceBulkAddComponentView): + permission_required = 'dcim.add_devicebay' + model = DeviceBay + model_form = forms.DeviceBayForm + + # # Interface connections # @@ -1467,9 +1356,11 @@ def interfaceconnection_add(request, pk): else: form = forms.InterfaceConnectionForm(device, initial={ - 'interface_a': request.GET.get('interface', None), + 'interface_a': request.GET.get('interface_a', None), + 'site_b': request.GET.get('site_b', device.rack.site), 'rack_b': request.GET.get('rack_b', None), 'device_b': request.GET.get('device_b', None), + 'interface_b': request.GET.get('interface_b', None), }) return render(request, 'dcim/interfaceconnection_edit.html', { @@ -1602,39 +1493,17 @@ def ipaddress_assign(request, pk): # Modules # -@permission_required('dcim.add_module') -def module_add(request, pk): - - device = get_object_or_404(Device, pk=pk) - - if request.method == 'POST': - form = forms.ModuleForm(request.POST) - if form.is_valid(): - module = form.save(commit=False) - module.device = device - module.save() - messages.success(request, u"Added module {} to {}".format(module.name, module.device.name)) - if '_addanother' in request.POST: - return redirect('dcim:module_add', pk=module.device.pk) - else: - return redirect('dcim:device_inventory', pk=module.device.pk) - - else: - form = forms.ModuleForm() - - return render(request, 'dcim/device_component_add.html', { - 'device': device, - 'component_type': 'Module', - 'form': form, - 'cancel_url': reverse('dcim:device_inventory', kwargs={'pk': device.pk}), - }) - - class ModuleEditView(PermissionRequiredMixin, ObjectEditView): permission_required = 'dcim.change_module' model = Module form_class = forms.ModuleForm + def alter_obj(self, obj, args, kwargs): + if 'device' in kwargs: + device = get_object_or_404(Device, pk=kwargs['device']) + obj.device = device + return obj + class ModuleDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'dcim.delete_module' diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py index 9d738219d..497d204a6 100644 --- a/netbox/extras/forms.py +++ b/netbox/extras/forms.py @@ -44,7 +44,7 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F # Date elif cf.type == CF_TYPE_DATE: - field = forms.DateField(required=cf.required, initial=cf.default) + field = forms.DateField(required=cf.required, initial=cf.default, help_text="Date format: YYYY-MM-DD") # Select elif cf.type == CF_TYPE_SELECT: @@ -63,7 +63,8 @@ def get_custom_fields_for_model(content_type, filterable_only=False, bulk_edit=F field.model = cf field.label = cf.label if cf.label else cf.name.replace('_', ' ').capitalize() - field.help_text = cf.description + if cf.description: + field.help_text = cf.description field_dict[field_name] = field diff --git a/netbox/extras/models.py b/netbox/extras/models.py index a65e90834..d45e4846f 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -12,7 +12,7 @@ from django.utils.safestring import mark_safe CUSTOMFIELD_MODELS = ( - 'site', 'rack', 'device', # DCIM + 'site', 'rack', 'devicetype', 'device', # DCIM 'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', # IPAM 'provider', 'circuit', # Circuits 'tenant', # Tenants diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 742eba9ea..e3f902605 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -1,8 +1,8 @@ from rest_framework import serializers -from dcim.api.serializers import SiteNestedSerializer, InterfaceNestedSerializer +from dcim.api.serializers import DeviceNestedSerializer, InterfaceNestedSerializer, SiteNestedSerializer from extras.api.serializers import CustomFieldSerializer -from ipam.models import VRF, Role, RIR, Aggregate, Prefix, IPAddress, VLAN, VLANGroup +from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF from tenancy.api.serializers import TenantNestedSerializer @@ -138,7 +138,7 @@ class PrefixSerializer(CustomFieldSerializer, serializers.ModelSerializer): class Meta: model = Prefix - fields = ['id', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'description', + fields = ['id', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'description', 'custom_fields'] @@ -170,3 +170,22 @@ class IPAddressNestedSerializer(IPAddressSerializer): IPAddressSerializer._declared_fields['nat_inside'] = IPAddressNestedSerializer() IPAddressSerializer._declared_fields['nat_outside'] = IPAddressNestedSerializer() + + +# +# Services +# + +class ServiceSerializer(serializers.ModelSerializer): + device = DeviceNestedSerializer() + ipaddresses = IPAddressNestedSerializer(many=True) + + class Meta: + model = Service + fields = ['id', 'device', 'name', 'port', 'protocol', 'ipaddresses', 'description'] + + +class ServiceNestedSerializer(ServiceSerializer): + + class Meta(ServiceSerializer.Meta): + fields = ['id', 'name', 'port', 'protocol'] diff --git a/netbox/ipam/api/urls.py b/netbox/ipam/api/urls.py index 0c0ac9495..598545ddf 100644 --- a/netbox/ipam/api/urls.py +++ b/netbox/ipam/api/urls.py @@ -37,4 +37,8 @@ urlpatterns = [ url(r'^vlans/$', VLANListView.as_view(), name='vlan_list'), url(r'^vlans/(?P\d+)/$', VLANDetailView.as_view(), name='vlan_detail'), + # Services + url(r'^services/$', ServiceListView.as_view(), name='service_list'), + url(r'^services/(?P\d+)/$', ServiceDetailView.as_view(), name='service_detail'), + ] diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index 21ab9335c..10b9c46e4 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -1,6 +1,6 @@ from rest_framework import generics -from ipam.models import VRF, Role, RIR, Aggregate, Prefix, IPAddress, VLAN, VLANGroup +from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF from ipam import filters from extras.api.views import CustomFieldModelAPIView @@ -177,3 +177,24 @@ class VLANDetailView(CustomFieldModelAPIView, generics.RetrieveAPIView): queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role')\ .prefetch_related('custom_field_values__field') serializer_class = serializers.VLANSerializer + + +# +# Services +# + +class ServiceListView(generics.ListAPIView): + """ + List services (filterable) + """ + queryset = Service.objects.select_related('device').prefetch_related('ipaddresses') + serializer_class = serializers.ServiceSerializer + filter_class = filters.ServiceFilter + + +class ServiceDetailView(generics.RetrieveAPIView): + """ + Retrieve a single service + """ + queryset = Service.objects.select_related('device').prefetch_related('ipaddresses') + serializer_class = serializers.ServiceSerializer diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py index bb04ca78e..7b7b15eec 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -9,7 +9,7 @@ from extras.filters import CustomFieldFilterSet from tenancy.models import Tenant from utilities.filters import NullableModelMultipleChoiceFilter -from .models import RIR, Aggregate, VRF, Prefix, IPAddress, VLAN, VLANGroup, Role +from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet): @@ -43,7 +43,7 @@ class VRFFilter(CustomFieldFilterSet, django_filters.FilterSet): class Meta: model = VRF - fields = ['name', 'rd'] + fields = ['rd'] class RIRFilter(django_filters.FilterSet): @@ -64,7 +64,7 @@ class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet): label='RIR (ID)', ) rir = django_filters.ModelMultipleChoiceFilter( - name='rir', + name='rir__slug', queryset=RIR.objects.all(), to_field_name='slug', label='RIR (slug)', @@ -72,7 +72,7 @@ class AggregateFilter(CustomFieldFilterSet, django_filters.FilterSet): class Meta: model = Aggregate - fields = ['family', 'rir_id', 'rir', 'date_added'] + fields = ['family', 'date_added'] def search(self, queryset, value): qs_filter = Q(description__icontains=value) @@ -149,7 +149,7 @@ class PrefixFilter(CustomFieldFilterSet, django_filters.FilterSet): class Meta: model = Prefix - fields = ['family', 'site_id', 'site', 'vlan_id', 'vlan_vid', 'status', 'role_id', 'role'] + fields = ['family', 'status'] def search(self, queryset, value): qs_filter = Q(description__icontains=value) @@ -226,7 +226,7 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Device (ID)', ) device = django_filters.ModelMultipleChoiceFilter( - name='interface__device', + name='interface__device__name', queryset=Device.objects.all(), to_field_name='name', label='Device (name)', @@ -239,7 +239,7 @@ class IPAddressFilter(CustomFieldFilterSet, django_filters.FilterSet): class Meta: model = IPAddress - fields = ['q', 'family', 'status', 'device_id', 'device', 'interface_id'] + fields = ['q', 'family', 'status'] def search(self, queryset, value): qs_filter = Q(description__icontains=value) @@ -268,7 +268,7 @@ class VLANGroupFilter(django_filters.FilterSet): label='Site (ID)', ) site = django_filters.ModelMultipleChoiceFilter( - name='site', + name='site__slug', queryset=Site.objects.all(), to_field_name='slug', label='Site (slug)', @@ -276,7 +276,6 @@ class VLANGroupFilter(django_filters.FilterSet): class Meta: model = VLANGroup - fields = ['site_id', 'site'] class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet): @@ -290,7 +289,7 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet): label='Site (ID)', ) site = django_filters.ModelMultipleChoiceFilter( - name='site', + name='site__slug', queryset=Site.objects.all(), to_field_name='slug', label='Site (slug)', @@ -340,7 +339,7 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet): class Meta: model = VLAN - fields = ['site_id', 'site', 'vid', 'name', 'status', 'role_id', 'role'] + fields = ['status'] def search(self, queryset, value): qs_filter = Q(name__icontains=value) | Q(description__icontains=value) @@ -349,3 +348,21 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet): except ValueError: pass return queryset.filter(qs_filter) + + +class ServiceFilter(django_filters.FilterSet): + device_id = django_filters.ModelMultipleChoiceFilter( + name='device', + queryset=Device.objects.all(), + label='Device (ID)', + ) + device = django_filters.ModelMultipleChoiceFilter( + name='device__name', + queryset=Device.objects.all(), + to_field_name='name', + label='Device (name)', + ) + + class Meta: + model = Service + fields = ['name', 'protocol', 'port'] diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 08bb5db04..7cb04cc60 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -5,12 +5,13 @@ from dcim.models import Site, Rack, Device, Interface from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm from tenancy.models import Tenant from utilities.forms import ( - APISelect, BootstrapMixin, CSVDataField, BulkImportForm, FilterChoiceField, Livesearch, SlugField, add_blank_choice, + APISelect, BootstrapMixin, BulkImportForm, CSVDataField, ExpandableIPAddressField, FilterChoiceField, Livesearch, + SlugField, add_blank_choice, ) from .models import ( - Aggregate, IPAddress, IPADDRESS_STATUS_CHOICES, Prefix, PREFIX_STATUS_CHOICES, RIR, Role, VLAN, VLANGroup, - VLAN_STATUS_CHOICES, VRF, + Aggregate, IPAddress, IPADDRESS_STATUS_CHOICES, Prefix, PREFIX_STATUS_CHOICES, RIR, Role, Service, VLAN, + VLANGroup, VLAN_STATUS_CHOICES, VRF, ) @@ -47,7 +48,7 @@ class VRFFromCSVForm(forms.ModelForm): fields = ['name', 'rd', 'tenant', 'enforce_unique', 'description'] -class VRFImportForm(BulkImportForm, BootstrapMixin): +class VRFImportForm(BootstrapMixin, BulkImportForm): csv = CSVDataField(csv_form=VRFFromCSVForm) @@ -70,7 +71,7 @@ class VRFFilterForm(BootstrapMixin, CustomFieldFilterForm): # RIRs # -class RIRForm(forms.ModelForm, BootstrapMixin): +class RIRForm(BootstrapMixin, forms.ModelForm): slug = SlugField() class Meta: @@ -78,7 +79,7 @@ class RIRForm(forms.ModelForm, BootstrapMixin): fields = ['name', 'slug', 'is_private'] -class RIRFilterForm(forms.Form, BootstrapMixin): +class RIRFilterForm(BootstrapMixin, forms.Form): is_private = forms.NullBooleanField(required=False, label='Private', widget=forms.Select(choices=[ ('', '---------'), ('True', 'Yes'), @@ -111,7 +112,7 @@ class AggregateFromCSVForm(forms.ModelForm): fields = ['prefix', 'rir', 'date_added', 'description'] -class AggregateImportForm(BulkImportForm, BootstrapMixin): +class AggregateImportForm(BootstrapMixin, BulkImportForm): csv = CSVDataField(csv_form=AggregateFromCSVForm) @@ -136,7 +137,7 @@ class AggregateFilterForm(BootstrapMixin, CustomFieldFilterForm): # Roles # -class RoleForm(forms.ModelForm, BootstrapMixin): +class RoleForm(BootstrapMixin, forms.ModelForm): slug = SlugField() class Meta: @@ -157,15 +158,7 @@ class PrefixForm(BootstrapMixin, CustomFieldForm): class Meta: model = Prefix - fields = ['prefix', 'vrf', 'tenant', 'site', 'vlan', 'status', 'role', 'description'] - help_texts = { - 'prefix': "IPv4 or IPv6 network", - 'vrf': "VRF (if applicable)", - 'site': "The site to which this prefix is assigned (if applicable)", - 'vlan': "The VLAN to which this prefix is assigned (if applicable)", - 'status': "Operational status of this prefix", - 'role': "The primary function of this prefix", - } + fields = ['prefix', 'vrf', 'tenant', 'site', 'vlan', 'status', 'role', 'is_pool', 'description'] def __init__(self, *args, **kwargs): super(PrefixForm, self).__init__(*args, **kwargs) @@ -196,7 +189,7 @@ class PrefixFromCSVForm(forms.ModelForm): class Meta: model = Prefix - fields = ['prefix', 'vrf', 'tenant', 'site', 'vlan_group_name', 'vlan_vid', 'status_name', 'role', + fields = ['prefix', 'vrf', 'tenant', 'site', 'vlan_group_name', 'vlan_vid', 'status_name', 'role', 'is_pool', 'description'] def clean(self): @@ -235,7 +228,7 @@ class PrefixFromCSVForm(forms.ModelForm): return super(PrefixFromCSVForm, self).save(*args, **kwargs) -class PrefixImportForm(BulkImportForm, BootstrapMixin): +class PrefixImportForm(BootstrapMixin, BulkImportForm): csv = CSVDataField(csv_form=PrefixFromCSVForm) @@ -339,6 +332,14 @@ class IPAddressForm(BootstrapMixin, CustomFieldForm): self.fields['nat_inside'].choices = [] +class IPAddressBulkAddForm(BootstrapMixin, forms.Form): + address = ExpandableIPAddressField() + vrf = forms.ModelChoiceField(queryset=VRF.objects.all(), required=False, label='VRF') + tenant = forms.ModelChoiceField(queryset=Tenant.objects.all(), required=False) + status = forms.ChoiceField(choices=IPADDRESS_STATUS_CHOICES) + description = forms.CharField(max_length=100, required=False) + + class IPAddressAssignForm(BootstrapMixin, forms.Form): site = forms.ModelChoiceField(queryset=Site.objects.all(), label='Site', required=False, widget=forms.Select(attrs={'filter-for': 'rack'})) @@ -417,7 +418,7 @@ class IPAddressFromCSVForm(forms.ModelForm): return super(IPAddressFromCSVForm, self).save(*args, **kwargs) -class IPAddressImportForm(BulkImportForm, BootstrapMixin): +class IPAddressImportForm(BootstrapMixin, BulkImportForm): csv = CSVDataField(csv_form=IPAddressFromCSVForm) @@ -456,7 +457,7 @@ class IPAddressFilterForm(BootstrapMixin, CustomFieldFilterForm): # VLAN groups # -class VLANGroupForm(forms.ModelForm, BootstrapMixin): +class VLANGroupForm(BootstrapMixin, forms.ModelForm): slug = SlugField() class Meta: @@ -464,7 +465,7 @@ class VLANGroupForm(forms.ModelForm, BootstrapMixin): fields = ['site', 'name', 'slug'] -class VLANGroupFilterForm(forms.Form, BootstrapMixin): +class VLANGroupFilterForm(BootstrapMixin, forms.Form): site = FilterChoiceField(queryset=Site.objects.annotate(filter_count=Count('vlan_groups')), to_field_name='slug') @@ -529,7 +530,7 @@ class VLANFromCSVForm(forms.ModelForm): return m -class VLANImportForm(BulkImportForm, BootstrapMixin): +class VLANImportForm(BootstrapMixin, BulkImportForm): csv = CSVDataField(csv_form=VLANFromCSVForm) @@ -563,3 +564,25 @@ class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm): status = forms.MultipleChoiceField(choices=vlan_status_choices, required=False) role = FilterChoiceField(queryset=Role.objects.annotate(filter_count=Count('vlans')), to_field_name='slug', null_option=(0, 'None')) + + +# +# Services +# + +class ServiceForm(BootstrapMixin, forms.ModelForm): + + class Meta: + model = Service + fields = ['name', 'protocol', 'port', 'ipaddresses', 'description'] + help_texts = { + 'ipaddresses': "IP address assignment is optional. If no IPs are selected, the service is assumed to be " + "reachable via all IPs assigned to the device.", + } + + def __init__(self, *args, **kwargs): + + super(ServiceForm, self).__init__(*args, **kwargs) + + # Limit IP address choices to those assigned to interfaces of the parent device + self.fields['ipaddresses'].queryset = IPAddress.objects.filter(interface__device=self.instance.device) diff --git a/netbox/ipam/migrations/0012_services.py b/netbox/ipam/migrations/0012_services.py new file mode 100644 index 000000000..bb6274408 --- /dev/null +++ b/netbox/ipam/migrations/0012_services.py @@ -0,0 +1,39 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10 on 2016-12-15 20:22 +from __future__ import unicode_literals + +import django.core.validators +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0022_color_names_to_rgb'), + ('ipam', '0011_rir_add_is_private'), + ] + + operations = [ + migrations.CreateModel( + name='Service', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateField(auto_now_add=True)), + ('last_updated', models.DateTimeField(auto_now=True)), + ('name', models.CharField(max_length=30)), + ('protocol', models.PositiveSmallIntegerField(choices=[(6, b'TCP'), (17, b'UDP')])), + ('port', models.PositiveIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(65535)], verbose_name=b'Port number')), + ('description', models.CharField(blank=True, max_length=100)), + ('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='services', to='dcim.Device', verbose_name=b'device')), + ('ipaddresses', models.ManyToManyField(blank=True, related_name='services', to='ipam.IPAddress', verbose_name=b'IP addresses')), + ], + options={ + 'ordering': ['device', 'protocol', 'port'], + }, + ), + migrations.AlterUniqueTogether( + name='service', + unique_together=set([('device', 'protocol', 'port')]), + ), + ] diff --git a/netbox/ipam/migrations/0013_prefix_add_is_pool.py b/netbox/ipam/migrations/0013_prefix_add_is_pool.py new file mode 100644 index 000000000..fd1493610 --- /dev/null +++ b/netbox/ipam/migrations/0013_prefix_add_is_pool.py @@ -0,0 +1,37 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.4 on 2016-12-27 19:34 +from __future__ import unicode_literals + +from django.db import migrations, models +import django.db.models.deletion +import ipam.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0012_services'), + ] + + operations = [ + migrations.AddField( + model_name='prefix', + name='is_pool', + field=models.BooleanField(default=False, help_text=b'All IP addresses within this prefix are considered usable', verbose_name=b'Is a pool'), + ), + migrations.AlterField( + model_name='prefix', + name='prefix', + field=ipam.fields.IPNetworkField(help_text=b'IPv4 or IPv6 network with mask'), + ), + migrations.AlterField( + model_name='prefix', + name='role', + field=models.ForeignKey(blank=True, help_text=b'The primary function of this prefix', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='prefixes', to='ipam.Role'), + ), + migrations.AlterField( + model_name='prefix', + name='status', + field=models.PositiveSmallIntegerField(choices=[(0, b'Container'), (1, b'Active'), (2, b'Reserved'), (3, b'Deprecated')], default=1, help_text=b'Operational status of this prefix', verbose_name=b'Status'), + ), + ] diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index 5f28acaed..ecc62bf5f 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -61,6 +61,14 @@ STATUS_CHOICE_CLASSES = { } +IP_PROTOCOL_TCP = 6 +IP_PROTOCOL_UDP = 17 +IP_PROTOCOL_CHOICES = ( + (IP_PROTOCOL_TCP, 'TCP'), + (IP_PROTOCOL_UDP, 'UDP'), +) + + class VRF(CreatedUpdatedModel, CustomFieldModel): """ A virtual routing and forwarding (VRF) table represents a discrete layer three forwarding domain (e.g. a routing @@ -261,15 +269,19 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel): assigned to a VLAN where appropriate. """ family = models.PositiveSmallIntegerField(choices=AF_CHOICES, editable=False) - prefix = IPNetworkField() + prefix = IPNetworkField(help_text="IPv4 or IPv6 network with mask") site = models.ForeignKey('dcim.Site', related_name='prefixes', on_delete=models.PROTECT, blank=True, null=True) vrf = models.ForeignKey('VRF', related_name='prefixes', on_delete=models.PROTECT, blank=True, null=True, verbose_name='VRF') tenant = models.ForeignKey(Tenant, related_name='prefixes', blank=True, null=True, on_delete=models.PROTECT) vlan = models.ForeignKey('VLAN', related_name='prefixes', on_delete=models.PROTECT, blank=True, null=True, verbose_name='VLAN') - status = models.PositiveSmallIntegerField('Status', choices=PREFIX_STATUS_CHOICES, default=1) - role = models.ForeignKey('Role', related_name='prefixes', on_delete=models.SET_NULL, blank=True, null=True) + status = models.PositiveSmallIntegerField('Status', choices=PREFIX_STATUS_CHOICES, default=PREFIX_STATUS_ACTIVE, + help_text="Operational status of this prefix") + role = models.ForeignKey('Role', related_name='prefixes', on_delete=models.SET_NULL, blank=True, null=True, + help_text="The primary function of this prefix") + is_pool = models.BooleanField(verbose_name='Is a pool', default=False, + help_text="All IP addresses within this prefix are considered usable") description = models.CharField(max_length=100, blank=True) custom_field_values = GenericRelation(CustomFieldValue, content_type_field='obj_type', object_id_field='obj_id') @@ -312,8 +324,11 @@ class Prefix(CreatedUpdatedModel, CustomFieldModel): self.vrf.rd if self.vrf else '', self.tenant.name if self.tenant else '', self.site.name if self.site else '', + self.vlan.group.name if self.vlan and self.vlan.group else '', + str(self.vlan.vid) if self.vlan else '', self.get_status_display(), self.role.name if self.role else '', + 'True' if self.is_pool else '', self.description, ]) @@ -525,3 +540,28 @@ class VLAN(CreatedUpdatedModel, CustomFieldModel): def get_status_class(self): return STATUS_CHOICE_CLASSES[self.status] + + +class Service(CreatedUpdatedModel): + """ + A Service represents a layer-four service (e.g. HTTP or SSH) running on a Device. A Service may optionally be tied + to one or more specific IPAddresses belonging to the Device. + """ + device = models.ForeignKey('dcim.Device', related_name='services', on_delete=models.CASCADE, verbose_name='device') + name = models.CharField(max_length=30) + protocol = models.PositiveSmallIntegerField(choices=IP_PROTOCOL_CHOICES) + port = models.PositiveIntegerField(validators=[MinValueValidator(1), MaxValueValidator(65535)], + verbose_name='Port number') + ipaddresses = models.ManyToManyField('ipam.IPAddress', related_name='services', blank=True, + verbose_name='IP addresses') + description = models.CharField(max_length=100, blank=True) + + class Meta: + ordering = ['device', 'protocol', 'port'] + unique_together = ['device', 'protocol', 'port'] + + def __unicode__(self): + return u'{} ({}/{})'.format(self.name, self.port, self.get_protocol_display()) + + def get_parent_url(self): + return self.device.get_absolute_url() diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index 81953a348..2c04b97c3 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -58,6 +58,14 @@ PREFIX_LINK_BRIEF = """ """ +PREFIX_ROLE_LINK = """ +{% if record.role %} + {{ record.role }} +{% else %} + — +{% endif %} +""" + IPADDRESS_LINK = """ {% if record.pk %} {{ record.address }} @@ -86,6 +94,22 @@ STATUS_LABEL = """ {% endif %} """ +VLAN_PREFIXES = """ +{% for prefix in record.prefixes.all %} + {{ prefix }}{% if not forloop.last %}
{% endif %} +{% empty %} + — +{% endfor %} +""" + +VLAN_ROLE_LINK = """ +{% if record.role %} + {{ record.role }} +{% else %} + — +{% endif %} +""" + VLANGROUP_ACTIONS = """ {% if perms.ipam.change_vlangroup %} @@ -189,16 +213,17 @@ class RoleTable(BaseTable): class PrefixTable(BaseTable): pk = ToggleColumn() status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status') - prefix = tables.TemplateColumn(PREFIX_LINK, verbose_name='Prefix') + prefix = tables.TemplateColumn(PREFIX_LINK, verbose_name='Prefix', attrs={'th': {'style': 'padding-left: 17px'}}) 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') + vlan = tables.LinkColumn('ipam:vlan', args=[Accessor('vlan.pk')], verbose_name='VLAN') + role = tables.TemplateColumn(PREFIX_ROLE_LINK, verbose_name='Role') description = tables.Column(orderable=False, verbose_name='Description') class Meta(BaseTable.Meta): model = Prefix - fields = ('pk', 'prefix', 'status', 'vrf', 'tenant', 'site', 'role', 'description') + fields = ('pk', 'prefix', 'status', 'vrf', 'tenant', 'site', 'vlan', 'role', 'description') row_attrs = { 'class': lambda record: 'success' if not record.pk else '', } @@ -281,10 +306,11 @@ class VLANTable(BaseTable): site = tables.LinkColumn('dcim:site', args=[Accessor('site.slug')], verbose_name='Site') group = tables.Column(accessor=Accessor('group.name'), verbose_name='Group') name = tables.Column(verbose_name='Name') + prefixes = tables.TemplateColumn(VLAN_PREFIXES, orderable=False, verbose_name='Prefixes') tenant = tables.LinkColumn('tenancy:tenant', args=[Accessor('tenant.slug')], verbose_name='Tenant') status = tables.TemplateColumn(STATUS_LABEL, verbose_name='Status') - role = tables.Column(verbose_name='Role') + role = tables.TemplateColumn(VLAN_ROLE_LINK, verbose_name='Role') class Meta(BaseTable.Meta): model = VLAN - fields = ('pk', 'vid', 'site', 'group', 'name', 'tenant', 'status', 'role') + fields = ('pk', 'vid', 'site', 'group', 'name', 'prefixes', 'tenant', 'status', 'role') diff --git a/netbox/ipam/urls.py b/netbox/ipam/urls.py index dc5fcc964..5ef052b37 100644 --- a/netbox/ipam/urls.py +++ b/netbox/ipam/urls.py @@ -51,6 +51,7 @@ urlpatterns = [ # IP addresses url(r'^ip-addresses/$', views.IPAddressListView.as_view(), name='ipaddress_list'), url(r'^ip-addresses/add/$', views.IPAddressEditView.as_view(), name='ipaddress_add'), + url(r'^ip-addresses/bulk-add/$', views.IPAddressBulkAddView.as_view(), name='ipaddress_bulk_add'), url(r'^ip-addresses/import/$', views.IPAddressBulkImportView.as_view(), name='ipaddress_import'), url(r'^ip-addresses/edit/$', views.IPAddressBulkEditView.as_view(), name='ipaddress_bulk_edit'), url(r'^ip-addresses/delete/$', views.IPAddressBulkDeleteView.as_view(), name='ipaddress_bulk_delete'), @@ -76,4 +77,8 @@ urlpatterns = [ url(r'^vlans/(?P\d+)/edit/$', views.VLANEditView.as_view(), name='vlan_edit'), url(r'^vlans/(?P\d+)/delete/$', views.VLANDeleteView.as_view(), name='vlan_delete'), + # Services + url(r'^services/(?P\d+)/edit/$', views.ServiceEditView.as_view(), name='service_edit'), + url(r'^services/(?P\d+)/delete/$', views.ServiceDeleteView.as_view(), name='service_delete'), + ] diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 0ad11a38c..c7fc4ea31 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -12,11 +12,14 @@ from dcim.models import Device from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator from utilities.views import ( - BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView, + BulkAddView, BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView, ) from . import filters, forms, tables -from .models import Aggregate, IPAddress, PREFIX_STATUS_ACTIVE, PREFIX_STATUS_DEPRECATED, PREFIX_STATUS_RESERVED, Prefix, RIR, Role, VLAN, VLANGroup, VRF +from .models import ( + Aggregate, IPAddress, PREFIX_STATUS_ACTIVE, PREFIX_STATUS_DEPRECATED, PREFIX_STATUS_RESERVED, Prefix, RIR, Role, + Service, VLAN, VLANGroup, VRF, +) def add_available_prefixes(parent, prefix_list): @@ -35,24 +38,21 @@ def add_available_prefixes(parent, prefix_list): return prefix_list -def add_available_ipaddresses(prefix, ipaddress_list): +def add_available_ipaddresses(prefix, ipaddress_list, is_pool=False): """ - Annotate ranges of available IP addresses within a given prefix. + Annotate ranges of available IP addresses within a given prefix. If is_pool is True, the first and last IP will be + considered usable (regardless of mask length). """ output = [] prev_ip = None - # Ignore the "network address" for IPv4 prefixes larger than /31 - if prefix.version == 4 and prefix.prefixlen < 31: + # Ignore the network and broadcast addresses for non-pool IPv4 prefixes larger than /31. + if prefix.version == 4 and prefix.prefixlen < 31 and not is_pool: first_ip_in_prefix = netaddr.IPAddress(prefix.first + 1) - else: - first_ip_in_prefix = netaddr.IPAddress(prefix.first) - - # Ignore the broadcast address for IPv4 prefixes larger than /31 - if prefix.version == 4 and prefix.prefixlen < 31: last_ip_in_prefix = netaddr.IPAddress(prefix.last - 1) else: + first_ip_in_prefix = netaddr.IPAddress(prefix.first) last_ip_in_prefix = netaddr.IPAddress(prefix.last) if not ipaddress_list: @@ -290,7 +290,6 @@ def aggregate(request, pk): child_prefixes = add_available_prefixes(aggregate.prefix, child_prefixes) prefix_table = tables.PrefixTable(child_prefixes) - prefix_table.model = Prefix if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'): prefix_table.base_columns['pk'].visible = True RequestConfig(request, paginate={'klass': EnhancedPaginator}).configure(prefix_table) @@ -367,7 +366,7 @@ class RoleBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # class PrefixListView(ObjectListView): - queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'role') + queryset = Prefix.objects.select_related('site', 'vrf__tenant', 'tenant', 'vlan', 'role') filter = filters.PrefixFilter filter_form = forms.PrefixFilterForm table = tables.PrefixTable @@ -416,7 +415,6 @@ def prefix(request, pk): if child_prefixes: child_prefixes = add_available_prefixes(prefix.prefix, child_prefixes) child_prefix_table = tables.PrefixTable(child_prefixes) - child_prefix_table.model = Prefix if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'): child_prefix_table.base_columns['pk'].visible = True RequestConfig(request, paginate={'klass': EnhancedPaginator}).configure(child_prefix_table) @@ -475,10 +473,9 @@ def prefix_ipaddresses(request, pk): # Find all IPAddresses belonging to this Prefix ipaddresses = IPAddress.objects.filter(vrf=prefix.vrf, address__net_contained_or_equal=str(prefix.prefix))\ .select_related('vrf', 'interface__device', 'primary_ip4_for', 'primary_ip6_for') - ipaddresses = add_available_ipaddresses(prefix.prefix, ipaddresses) + ipaddresses = add_available_ipaddresses(prefix.prefix, ipaddresses, prefix.is_pool) ip_table = tables.IPAddressTable(ipaddresses) - ip_table.model = IPAddress if request.user.has_perm('ipam.change_ipaddress') or request.user.has_perm('ipam.delete_ipaddress'): ip_table.base_columns['pk'].visible = True RequestConfig(request, paginate={'klass': EnhancedPaginator}).configure(ip_table) @@ -610,6 +607,14 @@ class IPAddressDeleteView(PermissionRequiredMixin, ObjectDeleteView): redirect_url = 'ipam:ipaddress_list' +class IPAddressBulkAddView(PermissionRequiredMixin, BulkAddView): + permission_required = 'ipam.add_ipaddress' + form = forms.IPAddressBulkAddForm + model = IPAddress + template_name = 'ipam/ipaddress_bulk_add.html' + redirect_url = 'ipam:ipaddress_list' + + class IPAddressBulkImportView(PermissionRequiredMixin, BulkImportView): permission_required = 'ipam.add_ipaddress' form = forms.IPAddressImportForm @@ -679,7 +684,7 @@ class VLANGroupBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # class VLANListView(ObjectListView): - queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role') + queryset = VLAN.objects.select_related('site', 'group', 'tenant', 'role').prefetch_related('prefixes') filter = filters.VLANFilter filter_form = forms.VLANFilterForm table = tables.VLANTable @@ -733,3 +738,24 @@ class VLANBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): permission_required = 'ipam.delete_vlan' cls = VLAN default_redirect_url = 'ipam:vlan_list' + + +# +# Services +# + +class ServiceEditView(PermissionRequiredMixin, ObjectEditView): + permission_required = 'ipam.change_service' + model = Service + form_class = forms.ServiceForm + template_name = 'ipam/service_edit.html' + + def alter_obj(self, obj, args, kwargs): + if 'device' in kwargs: + obj.device = get_object_or_404(Device, pk=kwargs['device']) + return obj + + +class ServiceDeleteView(PermissionRequiredMixin, ObjectDeleteView): + permission_required = 'ipam.delete_service' + model = Service diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index f4b0ccc2c..005126e39 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -12,7 +12,7 @@ except ImportError: "the documentation.") -VERSION = '1.7.3' +VERSION = '1.8.0' # Import local configuration for setting in ['ALLOWED_HOSTS', 'DATABASE', 'SECRET_KEY']: @@ -117,7 +117,8 @@ INSTALLED_APPS = ( ) # Middleware -MIDDLEWARE_CLASSES = ( +MIDDLEWARE = ( + 'debug_toolbar.middleware.DebugToolbarMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', @@ -193,6 +194,12 @@ SWAGGER_SETTINGS = { 'base_path': '{}/{}api/docs'.format(ALLOWED_HOSTS[0], BASE_PATH), } +# Django debug toolbar +INTERNAL_IPS = ( + '127.0.0.1', + '::1', +) + try: HOSTNAME = socket.gethostname() diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py index b579671bf..9d01f1773 100644 --- a/netbox/netbox/urls.py +++ b/netbox/netbox/urls.py @@ -42,6 +42,12 @@ _patterns = [ ] +if settings.DEBUG: + import debug_toolbar + _patterns += [ + url(r'^__debug__/', include(debug_toolbar.urls)), + ] + # Prepend BASE_PATH urlpatterns = [ url(r'^{}'.format(settings.BASE_PATH), include(_patterns)) diff --git a/netbox/project-static/css/base.css b/netbox/project-static/css/base.css index ff9eb98c1..4f569edea 100644 --- a/netbox/project-static/css/base.css +++ b/netbox/project-static/css/base.css @@ -13,7 +13,7 @@ body { } .container { width: auto; - max-width: 1340px; + max-width: 1600px; } .wrapper { min-height: 100%; @@ -35,7 +35,8 @@ footer p { margin: 20px 0; } -@media (max-width: 1200px) { +/* Collapse the nav menu on displays less than 1200px wide */ +@media (max-width: 1199px) { .navbar-header { float: none; } @@ -58,7 +59,7 @@ footer p { max-height: none; } .navbar-nav { - float: none!important; + float: none !important; margin-top: 7.5px; } .navbar-nav>li { @@ -88,10 +89,17 @@ th.pk, td.pk { tfoot td { font-weight: bold; } +table.attr-table td:nth-child(1) { + width: 25%; +} /* Paginator */ +div.paginator { + margin-bottom: 20px; +} nav ul.pagination { margin-top: 0; + margin-bottom: 8px !important; } /* Racks */ @@ -322,4 +330,4 @@ td .progress { } textarea { font-family: Consolas, Lucida Console, monospace; -} +} \ No newline at end of file diff --git a/netbox/project-static/js/forms.js b/netbox/project-static/js/forms.js index 439cf8701..3adc9c13f 100644 --- a/netbox/project-static/js/forms.js +++ b/netbox/project-static/js/forms.js @@ -51,6 +51,14 @@ $(document).ready(function() { $('#id_' + this.value).toggle('disabled'); }); + // Set formaction and submit using a link + $('a.formaction').click(function (event) { + event.preventDefault(); + var form = $(this).closest('form'); + form.attr('action', $(this).attr('href')); + form.submit(); + }); + // API select widget $('select[filter-for]').change(function () { diff --git a/netbox/secrets/filters.py b/netbox/secrets/filters.py index a821402cf..af6f62fbd 100644 --- a/netbox/secrets/filters.py +++ b/netbox/secrets/filters.py @@ -17,7 +17,7 @@ class SecretFilter(django_filters.FilterSet): label='Role (ID)', ) role = django_filters.ModelMultipleChoiceFilter( - name='role', + name='role__slug', queryset=SecretRole.objects.all(), to_field_name='slug', label='Role (slug)', @@ -31,7 +31,7 @@ class SecretFilter(django_filters.FilterSet): class Meta: model = Secret - fields = ['name', 'role_id', 'role', 'device'] + fields = ['name'] def search(self, queryset, value): return queryset.filter( diff --git a/netbox/secrets/forms.py b/netbox/secrets/forms.py index 3f3d397a3..8012e2c55 100644 --- a/netbox/secrets/forms.py +++ b/netbox/secrets/forms.py @@ -34,7 +34,7 @@ def validate_rsa_key(key, is_secret=True): # Secret roles # -class SecretRoleForm(forms.ModelForm, BootstrapMixin): +class SecretRoleForm(BootstrapMixin, forms.ModelForm): slug = SlugField() class Meta: @@ -46,7 +46,7 @@ class SecretRoleForm(forms.ModelForm, BootstrapMixin): # Secrets # -class SecretForm(forms.ModelForm, BootstrapMixin): +class SecretForm(BootstrapMixin, forms.ModelForm): private_key = forms.CharField(required=False, widget=forms.HiddenInput()) plaintext = forms.CharField(max_length=65535, required=False, label='Plaintext', widget=forms.PasswordInput(attrs={'class': 'requires-private-key'})) @@ -85,12 +85,12 @@ class SecretFromCSVForm(forms.ModelForm): return s -class SecretImportForm(BulkImportForm, BootstrapMixin): +class SecretImportForm(BootstrapMixin, BulkImportForm): private_key = forms.CharField(widget=forms.HiddenInput()) csv = CSVDataField(csv_form=SecretFromCSVForm, widget=forms.Textarea(attrs={'class': 'requires-private-key'})) -class SecretBulkEditForm(BulkEditForm, BootstrapMixin): +class SecretBulkEditForm(BootstrapMixin, BulkEditForm): pk = forms.ModelMultipleChoiceField(queryset=Secret.objects.all(), widget=forms.MultipleHiddenInput) role = forms.ModelChoiceField(queryset=SecretRole.objects.all(), required=False) name = forms.CharField(max_length=100, required=False) @@ -99,7 +99,7 @@ class SecretBulkEditForm(BulkEditForm, BootstrapMixin): nullable_fields = ['name'] -class SecretFilterForm(forms.Form, BootstrapMixin): +class SecretFilterForm(BootstrapMixin, forms.Form): role = FilterChoiceField(queryset=SecretRole.objects.annotate(filter_count=Count('secrets')), to_field_name='slug') @@ -107,7 +107,7 @@ class SecretFilterForm(forms.Form, BootstrapMixin): # UserKeys # -class UserKeyForm(forms.ModelForm, BootstrapMixin): +class UserKeyForm(BootstrapMixin, forms.ModelForm): class Meta: model = UserKey diff --git a/netbox/templates/circuits/circuit.html b/netbox/templates/circuits/circuit.html index 5b9f8381f..5591c64b9 100644 --- a/netbox/templates/circuits/circuit.html +++ b/netbox/templates/circuits/circuit.html @@ -5,14 +5,14 @@ {% block content %}
-
+
-
+
@@ -40,13 +40,14 @@ {% endif %}

{{ circuit.provider }} - {{ circuit.cid }}

+{% include 'inc/created_updated.html' with obj=circuit %}
Circuit
- +
- - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Provider @@ -81,17 +82,6 @@ {% endif %}
Speed - {% if circuit.upstream_speed %} - {{ circuit.port_speed_human }}   - {{ circuit.upstream_speed_human }} - {% else %} - {{ circuit.port_speed_human }} - {% endif %} -
Commit Rate @@ -107,67 +97,6 @@ {% with circuit.get_custom_fields as custom_fields %} {% include 'inc/custom_fields_panel.html' %} {% endwith %} - {% include 'inc/created_updated.html' with obj=circuit %} - -
-
-
- Termination -
- - - - - - - - - - - - - - - - - - - - - -
Site - {{ circuit.site }} -
Termination - {% if circuit.interface %} - {{ circuit.interface.device }} {{ circuit.interface }} - {% else %} - Not defined - {% endif %} -
IP Addressing - {% if circuit.interface %} - {% for ip in circuit.interface.ip_addresses.all %} - {% if not forloop.first %}
{% endif %} - {{ ip }} ({{ ip.vrf|default:"Global" }}) - {% empty %} - None - {% endfor %} - {% else %} - N/A - {% endif %} -
Cross-Connect - {% if circuit.xconnect_id %} - {{ circuit.xconnect_id }} - {% else %} - N/A - {% endif %} -
Patch Panel/Port - {% if circuit.pp_info %} - {{ circuit.pp_info }} - {% else %} - N/A - {% endif %} -
-
Comments @@ -180,6 +109,10 @@ {% endif %}
+
+
+ {% include 'circuits/inc/circuit_termination.html' with termination=termination_a side='A' %} + {% include 'circuits/inc/circuit_termination.html' with termination=termination_z side='Z' %}
{% endblock %} diff --git a/netbox/templates/circuits/circuit_edit.html b/netbox/templates/circuits/circuit_edit.html index 863b0a0a2..67d18d1ae 100644 --- a/netbox/templates/circuits/circuit_edit.html +++ b/netbox/templates/circuits/circuit_edit.html @@ -1,5 +1,4 @@ {% extends 'utilities/obj_edit.html' %} -{% load static from staticfiles %} {% load form_helpers %} {% block form %} @@ -11,15 +10,6 @@ {% render_field form.type %} {% render_field form.tenant %} {% render_field form.install_date %} - {% render_field form.xconnect_id %} - {% render_field form.pp_info %} - - -
-
Bandwidth
-
- {% render_field form.port_speed %} - {% render_field form.upstream_speed %} {% render_field form.commit_rate %}
@@ -31,26 +21,6 @@ {% endif %} -
-
Termination
-
- {% render_field form.site %} - -
-
- {% render_field form.rack %} - {% render_field form.device %} -
- -
- {% render_field form.interface %} -
-
Comments
@@ -58,7 +28,3 @@
{% endblock %} - -{% block javascript %} - -{% endblock %} diff --git a/netbox/templates/circuits/circuit_import.html b/netbox/templates/circuits/circuit_import.html index 269005218..fec364b60 100644 --- a/netbox/templates/circuits/circuit_import.html +++ b/netbox/templates/circuits/circuit_import.html @@ -48,45 +48,20 @@
Name of tenant (optional) Strickland Propane
SiteSite nameASH-4
Install Date Date in YYYY-MM-DD format (optional) 2016-02-23
Port SpeedPhysical speed in Kbps100000
Upstream SpeedUpstream speed in Kbps (optional)20000
Commit rate Commited rate in Kbps (optional) 2000
Cross-connect IDID of cross-connect (optional)937649
Patch PanelPatch panel/port ID (optional)PP8371 ports 13/14

Example

-
IC-603122,TeliaSonera,Transit,Strickland Propane,ASH-4,2016-02-23,100000,,2000,937649,PP8371 ports 13/14
+
IC-603122,TeliaSonera,Transit,Strickland Propane,2016-02-23,2000
{% endblock %} diff --git a/netbox/templates/circuits/circuit_terminations_swap.html b/netbox/templates/circuits/circuit_terminations_swap.html new file mode 100644 index 000000000..9fda09481 --- /dev/null +++ b/netbox/templates/circuits/circuit_terminations_swap.html @@ -0,0 +1,25 @@ +{% extends 'utilities/confirmation_form.html' %} + +{% block title %}Swap Circuit Terminations{% endblock %} + +{% block message %} +

Swap these terminations for circuit {{ circuit }}?

+
    +
  • + A side: + {% if termination_a %} + {{ termination_a.site }} {% if termination_a.interface %}- {{ termination_a.interface.device }} {{ termination_a.interface }}{% endif %} + {% else %} + None + {% endif %} +
  • +
  • + Z side: + {% if termination_z %} + {{ termination_z.site }} {% if termination_z.interface %}- {{ termination_z.interface.device }} {{ termination_z.interface }}{% endif %} + {% else %} + None + {% endif %} +
  • +
+{% endblock %} diff --git a/netbox/templates/circuits/circuittermination_edit.html b/netbox/templates/circuits/circuittermination_edit.html new file mode 100644 index 000000000..186b0e56c --- /dev/null +++ b/netbox/templates/circuits/circuittermination_edit.html @@ -0,0 +1,94 @@ +{% extends '_base.html' %} +{% load staticfiles %} +{% load form_helpers %} + +{% block title %} + Circuit {{ obj.circuit }} - Side {{ form.term_side.value }} +{% endblock %} + +{% block content %} + + {% csrf_token %} + {% for field in form.hidden_fields %} + {{ field }} + {% endfor %} +
+
+

Circuit {{ obj.circuit }} - Side {{ form.term_side.value }}

+ {% if form.non_field_errors %} +
+
Errors
+
+ {{ form.non_field_errors }} +
+
+ {% endif %} +
+
Location
+
+
+ +
+

{{ obj.circuit.provider }}

+
+
+
+ +
+

{{ obj.circuit.cid }}

+
+
+
+ +
+

{{ form.term_side.value }}

+
+
+ {% render_field form.site %} +
+
+ +
+
+
+
+ {% render_field form.rack %} + {% render_field form.device %} +
+ +
+ {% render_field form.interface %} +
+
+
+
Termination Details
+
+ {% render_field form.port_speed %} + {% render_field form.upstream_speed %} + {% render_field form.xconnect_id %} + {% render_field form.pp_info %} +
+
+
+
+
+
+ {% if obj.pk %} + + {% else %} + + {% endif %} + Cancel +
+
+ +{% endblock %} + +{% block javascript %} + +{% endblock %} diff --git a/netbox/templates/circuits/inc/circuit_termination.html b/netbox/templates/circuits/inc/circuit_termination.html new file mode 100644 index 000000000..7c641975c --- /dev/null +++ b/netbox/templates/circuits/inc/circuit_termination.html @@ -0,0 +1,95 @@ +
+
+
+ {% if not termination and perms.circuits.add_circuittermination %} + + Add + + {% endif %} + {% if termination and perms.circuits.change_circuittermination %} + + Edit + + + Swap + + {% endif %} + {% if termination and perms.circuits.delete_circuittermination %} + + Delete + + {% endif %} +
+ Termination - {{ side }} Side +
+ {% if termination %} + + + + + + + + + + + + + + + + + + + + + + + + + +
Site + {{ termination.site }} +
Termination + {% if termination.interface %} + {{ termination.interface.device }} {{ termination.interface }} + {% else %} + Not defined + {% endif %} +
Speed + {% if termination.upstream_speed %} + {{ termination.port_speed_human }}   + {{ termination.upstream_speed_human }} + {% else %} + {{ termination.port_speed_human }} + {% endif %} +
IP Addressing + {% if termination.interface %} + {% for ip in termination.interface.ip_addresses.all %} + {% if not forloop.first %}
{% endif %} + {{ ip }} ({{ ip.vrf|default:"Global" }}) + {% empty %} + None + {% endfor %} + {% else %} + N/A + {% endif %} +
Cross-Connect + {% if termination.xconnect_id %} + {{ termination.xconnect_id }} + {% else %} + N/A + {% endif %} +
Patch Panel/Port + {% if termination.pp_info %} + {{ termination.pp_info }} + {% else %} + N/A + {% endif %} +
+ {% else %} +
+ None +
+ {% endif %} +
diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html index c59858626..c9c8b9742 100644 --- a/netbox/templates/circuits/provider.html +++ b/netbox/templates/circuits/provider.html @@ -6,13 +6,13 @@ {% block content %}
-
+
-
+
@@ -46,13 +46,14 @@ {% endif %}

{{ provider }}

+{% include 'inc/created_updated.html' with obj=provider %}
Provider
- +
- - {% empty %} @@ -149,6 +143,13 @@ {% endfor %}
ASN @@ -120,7 +121,6 @@ {% endif %} - {% include 'inc/created_updated.html' with obj=provider %}
@@ -134,14 +134,8 @@ {{ c.cid }}
- {{ c.site }} + {{ c.type }} - {% if c.interface %} - {{ c.interface.device }} - {% endif %} - {{ c.port_speed_human }}
+ {% if perms.circuits.add_circuit %} + + {% endif %}
diff --git a/netbox/templates/dcim/component_template_delete.html b/netbox/templates/dcim/component_template_delete.html deleted file mode 100644 index a1302feb7..000000000 --- a/netbox/templates/dcim/component_template_delete.html +++ /dev/null @@ -1,13 +0,0 @@ -{% extends 'utilities/confirmation_form.html' %} -{% load form_helpers %} - -{% block title %}Delete device type components?{% endblock %} - -{% block message %} -

Are you sure you want to delete these components from {{ devicetype }}?

-
    - {% for o in selected_objects %} -
  • {{ o }}
  • - {% endfor %} -
-{% endblock %} diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 785938970..5bfb70cd3 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -8,12 +8,12 @@ {% block content %} {% include 'dcim/inc/_device_header.html' with active_tab='info' %}
-
+
Device
- +
{% if not iface.is_physical %} - + {% elif iface.connection %} - {% with iface.get_connected_interface as connected_iface %} + {% with iface.connected_interface as connected_iface %} @@ -24,10 +24,16 @@ {{ connected_iface }} {% endwith %} - {% elif iface.circuit %} - + {% elif iface.circuit_termination %} + {% with iface.circuit_termination.get_peer_termination as peer_termination %} + + {% endwith %} {% else %} + + + + + + diff --git a/netbox/templates/dcim/inc/device_table.html b/netbox/templates/dcim/inc/device_table.html index 08344706e..b11527748 100644 --- a/netbox/templates/dcim/inc/device_table.html +++ b/netbox/templates/dcim/inc/device_table.html @@ -1,9 +1,19 @@ {% extends 'utilities/obj_table.html' %} {% block extra_actions %} - {% if perms.dcim.add_interface %} - + {% if perms.dcim.change_device %} +
+ + +
{% endif %} {% endblock %} diff --git a/netbox/templates/dcim/inc/devicetype_component_table.html b/netbox/templates/dcim/inc/devicetype_component_table.html index 9954fec23..00ed12b5a 100644 --- a/netbox/templates/dcim/inc/devicetype_component_table.html +++ b/netbox/templates/dcim/inc/devicetype_component_table.html @@ -6,7 +6,7 @@
{{ title }}
- {% if table.rows|length > 3 %} + {% if table.rows|length > 1 %} diff --git a/netbox/templates/dcim/interfaceconnection_edit.html b/netbox/templates/dcim/interfaceconnection_edit.html index e477ba26d..5da19d4fa 100644 --- a/netbox/templates/dcim/interfaceconnection_edit.html +++ b/netbox/templates/dcim/interfaceconnection_edit.html @@ -27,6 +27,12 @@ A Side
+
+ +
+

{{ device.rack.site }}

+
+
@@ -61,6 +67,7 @@ {% render_field form.livesearch %}
+ {% render_field form.site_b %} {% render_field form.rack_b %} {% render_field form.device_b %}
@@ -77,8 +84,8 @@
- - + + Cancel
diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index f2bafe43b..2d0e065fd 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -6,14 +6,14 @@ {% block content %}
-
+
-
+
@@ -53,13 +53,14 @@ {% endif %}

Rack {{ rack.name }}

+{% include 'inc/created_updated.html' with obj=rack %}
Rack
-
Tenant @@ -85,7 +85,7 @@
Management
- +
{% if iface.connection %} - {% with iface.get_connected_interface as connected_iface %} + {% with iface.connected_interface as connected_iface %} diff --git a/netbox/templates/dcim/devicetype.html b/netbox/templates/dcim/devicetype.html index 0492bd2cc..9bc16d146 100644 --- a/netbox/templates/dcim/devicetype.html +++ b/netbox/templates/dcim/devicetype.html @@ -39,7 +39,7 @@
Chassis
-
Role @@ -205,6 +205,29 @@ {% endif %} {% endif %} +
+
+ Services +
+ {% if services %} + + {% for service in services %} + {% include 'dcim/inc/_service.html' %} + {% endfor %} +
+ {% else %} +
+ None +
+ {% endif %} + {% if perms.dcim.add_service %} + + {% endif %} +
Critical Connections @@ -301,9 +324,8 @@
None found
{% endif %}
- {% include 'inc/created_updated.html' with obj=device %}
-
+
{% if device_bays or device.device_type.is_parent_device %} {% if perms.dcim.delete_devicebay %} @@ -313,9 +335,11 @@
Device Bays
- + {% if perms.dcim.change_devicebay and device_bays|length > 1 %} + + {% endif %} {% if perms.dcim.add_devicebay and device_bays|length > 10 %} Add device bays @@ -363,9 +387,11 @@
Interfaces
- + {% if perms.dcim.change_interface and interfaces|length > 1 %} + + {% endif %} {% if perms.dcim.add_interface and interfaces|length > 10 %} Add interfaces @@ -418,9 +444,11 @@
Console Server Ports
- + {% if perms.dcim.change_consoleserverport and cs_ports|length > 1 %} + + {% endif %} {% if perms.dcim.add_consoleserverport and cs_ports|length > 10 %} Add console server ports @@ -468,9 +496,11 @@
Power Outlets
- + {% if perms.dcim.change_poweroutlet and cs_ports|length > 1 %} + + {% endif %} {% if perms.dcim.add_poweroutlet and power_outlets|length > 10 %} Add power outlets diff --git a/netbox/templates/dcim/device_component_add.html b/netbox/templates/dcim/device_component_add.html index f678877a1..06b04a326 100644 --- a/netbox/templates/dcim/device_component_add.html +++ b/netbox/templates/dcim/device_component_add.html @@ -1,9 +1,9 @@ {% extends '_base.html' %} {% load form_helpers %} -{% block title %}Create {{ component_type }} ({{ device }}){% endblock %} +{% block title %}Create {{ component_type }} ({{ parent }}){% endblock %} -{% block content %} +{% block content %}{{ form.errors }} {% csrf_token %}
{{ iface }} {{ connected_iface.device }}
+
@@ -145,6 +145,21 @@
Manufacturer {{ devicetype.manufacturer }}
+ {% with devicetype.get_custom_fields as custom_fields %} + {% include 'inc/custom_fields_panel.html' %} + {% endwith %} +
+
+ Comments +
+
+ {% if devicetype.comments %} + {{ devicetype.comments|gfm }} + {% else %} + None + {% endif %} +
+
{% include 'dcim/inc/devicetype_component_table.html' with table=consoleport_table title='Console Ports' add_url='dcim:devicetype_add_consoleport' delete_url='dcim:devicetype_delete_consoleport' %} {% include 'dcim/inc/devicetype_component_table.html' with table=powerport_table title='Power Ports' add_url='dcim:devicetype_add_powerport' delete_url='dcim:devicetype_delete_powerport' %} {% include 'dcim/inc/devicetype_component_table.html' with table=mgmt_interface_table title='Management Interfaces' add_url='dcim:devicetype_add_interface' add_url_extra='?mgmt_only=1' edit_url='dcim:devicetype_bulkedit_interface' delete_url='dcim:devicetype_delete_interface' %} diff --git a/netbox/templates/dcim/component_template_add.html b/netbox/templates/dcim/devicetype_component_add.html similarity index 90% rename from netbox/templates/dcim/component_template_add.html rename to netbox/templates/dcim/devicetype_component_add.html index d305debf5..d64dd4775 100644 --- a/netbox/templates/dcim/component_template_add.html +++ b/netbox/templates/dcim/devicetype_component_add.html @@ -1,7 +1,7 @@ {% extends '_base.html' %} {% load form_helpers %} -{% block title %}Add {{ component_type }} to {{ devicetype }}{% endblock %} +{% block title %}Add {{ component_type }} to {{ parent }}{% endblock %} {% block content %} @@ -24,7 +24,7 @@
-

{{ devicetype }}

+

{{ parent }}

{% render_form form %} diff --git a/netbox/templates/dcim/devicetype_edit.html b/netbox/templates/dcim/devicetype_edit.html new file mode 100644 index 000000000..929da06b8 --- /dev/null +++ b/netbox/templates/dcim/devicetype_edit.html @@ -0,0 +1,34 @@ +{% extends 'utilities/obj_edit.html' %} +{% load form_helpers %} + +{% block form %} +
+
Device Type
+
+ {% render_field form.manufacturer %} + {% render_field form.model %} + {% render_field form.slug %} + {% render_field form.part_number %} + {% render_field form.u_height %} + {% render_field form.is_full_depth %} + {% render_field form.is_console_server %} + {% render_field form.is_pdu %} + {% render_field form.is_network_device %} + {% render_field form.subdevice_role %} +
+
+ {% if form.custom_fields %} +
+
Custom Fields
+
+ {% render_custom_fields form %} +
+
+ {% endif %} +
+
Comments
+
+ {% render_field form.comments %} +
+
+{% endblock %} diff --git a/netbox/templates/dcim/devicetype_list.html b/netbox/templates/dcim/devicetype_list.html index b16de0799..b8ce0e719 100644 --- a/netbox/templates/dcim/devicetype_list.html +++ b/netbox/templates/dcim/devicetype_list.html @@ -18,6 +18,7 @@ {% include 'utilities/obj_table.html' with bulk_edit_url='dcim:devicetype_bulk_edit' bulk_delete_url='dcim:devicetype_bulk_delete' %}
+ {% include 'inc/search_panel.html' %} {% include 'inc/filter_panel.html' %}
diff --git a/netbox/templates/dcim/inc/_device_header.html b/netbox/templates/dcim/inc/_device_header.html index 7b0b69d61..74b453e1d 100644 --- a/netbox/templates/dcim/inc/_device_header.html +++ b/netbox/templates/dcim/inc/_device_header.html @@ -1,5 +1,5 @@
-
+
{% if device.rack %} {% endif %}
-
+
@@ -41,6 +41,7 @@ {% endif %}

{{ device }}

+{% include 'inc/created_updated.html' with obj=device %}
VirtualVirtual interface {{ connected_iface.device }} - {{ iface.circuit }} - + + {% if peer_termination %} + {{ peer_termination.site }} via + {% endif %} + {{ iface.circuit_termination.circuit }} + Not connected @@ -35,7 +41,7 @@ {% endif %} {% if show_graphs %} - {% if iface.circuit or iface.connection %} + {% if iface.circuit_termination or iface.connection %} @@ -56,12 +62,15 @@ - {% elif iface.circuit and perms.circuits.change_circuit %} - + {% elif iface.circuit_termination and perms.circuits.change_circuittermination %} + + {% else %} - + {% endif %} @@ -71,7 +80,7 @@ {% endif %} {% if perms.dcim.delete_interface %} - {% if iface.connection or iface.circuit %} + {% if iface.connection or iface.circuit_termination %} diff --git a/netbox/templates/dcim/inc/_service.html b/netbox/templates/dcim/inc/_service.html new file mode 100644 index 000000000..28cd64094 --- /dev/null +++ b/netbox/templates/dcim/inc/_service.html @@ -0,0 +1,26 @@ +
{{ service.name }} + {{ service.get_protocol_display }}/{{ service.port }} + + {% for ip in service.ipaddresses.all %} + {{ ip.address.ip }}
+ {% empty %} + All IPs + {% endfor %} +
{{ service.description }} + {% if perms.ipam.change_service %} + + + + {% endif %} + {% if perms.ipam.delete_service %} + + + + {% endif %} +
+
+ + + + + + + + + + + + + + +
Site @@ -188,7 +189,6 @@ {% endif %} - {% include 'inc/created_updated.html' with obj=rack %}
diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index ccf2c9673..210ec0c82 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -7,13 +7,13 @@ {% block content %}
-
+
-
+
@@ -47,13 +47,14 @@ {% endif %}

{{ site.name }}

+{% include 'inc/created_updated.html' with obj=site %}
Site
- +
+ + + + + + + + + + + +
Tenant @@ -109,6 +110,36 @@ {% endif %}
Contact Name + {% if site.contact_name %} + {{ site.contact_name }} + {% else %} + N/A + {% endif %} +
Contact Phone + {% if site.contact_phone %} + {{ site.contact_phone }} + {% else %} + N/A + {% endif %} +
Contact E-Mail + {% if site.contact_email %} + {{ site.contact_email }} + {% else %} + N/A + {% endif %} +
{% with site.get_custom_fields as custom_fields %} @@ -126,7 +157,6 @@ {% endif %}
- {% include 'inc/created_updated.html' with obj=site %}
diff --git a/netbox/templates/dcim/site_edit.html b/netbox/templates/dcim/site_edit.html index e3911fc1f..d1f211adb 100644 --- a/netbox/templates/dcim/site_edit.html +++ b/netbox/templates/dcim/site_edit.html @@ -10,8 +10,16 @@ {% render_field form.tenant %} {% render_field form.facility %} {% render_field form.asn %} +
+
+
+
Contact Info
+
{% render_field form.physical_address %} {% render_field form.shipping_address %} + {% render_field form.contact_name %} + {% render_field form.contact_phone %} + {% render_field form.contact_email %}
{% if form.custom_fields %} diff --git a/netbox/templates/dcim/site_import.html b/netbox/templates/dcim/site_import.html index 030a3aaa0..7f58e6396 100644 --- a/netbox/templates/dcim/site_import.html +++ b/netbox/templates/dcim/site_import.html @@ -53,10 +53,25 @@
Autonomous system number (optional) 65000
Contact NameName of administrative contact (optional)Hank Hill
Contact PhonePhone number (optional)+1-214-555-1234
Contact E-mailE-mail address (optional)hhill@example.com

Example

-
ASH-4 South,ash4-south,Pied Piper,Equinix DC6,65000
+
ASH-4 South,ash4-south,Pied Piper,Equinix DC6,65000,Hank Hill,+1-214-555-1234,hhill@example.com
{% endblock %} diff --git a/netbox/templates/home.html b/netbox/templates/home.html index f502bfa11..043526332 100644 --- a/netbox/templates/home.html +++ b/netbox/templates/home.html @@ -3,7 +3,7 @@ {% block content %}