diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 8a8824138..d0e134b71 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -1,7 +1,5 @@ from __future__ import unicode_literals -from collections import OrderedDict - from rest_framework import serializers from rest_framework.validators import UniqueTogetherValidator from taggit.models import Tag @@ -63,9 +61,9 @@ class SiteSerializer(CustomFieldModelSerializer): model = Site fields = [ 'id', 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', - 'physical_address', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments', - 'tags', 'custom_fields', 'created', 'last_updated', 'count_prefixes', 'count_vlans', 'count_racks', - 'count_devices', 'count_circuits', + 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', + 'contact_email', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'count_prefixes', + 'count_vlans', 'count_racks', 'count_devices', 'count_circuits', ] diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 6460d5d2c..807caa5ce 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -115,8 +115,8 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm): model = Site fields = [ 'name', 'slug', 'status', 'region', 'tenant_group', 'tenant', 'facility', 'asn', 'time_zone', 'description', - 'physical_address', 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments', - 'tags', + 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', + 'contact_email', 'comments', 'tags', ] widgets = { 'physical_address': SmallTextarea(attrs={'rows': 3}), @@ -129,7 +129,9 @@ class SiteForm(BootstrapMixin, TenancyForm, CustomFieldForm): 'time_zone': "Local time zone", 'description': "Short description (will appear in sites list)", 'physical_address': "Physical location of the building (e.g. for GPS)", - 'shipping_address': "If different from the physical address" + 'shipping_address': "If different from the physical address", + 'latitude': "Latitude in decimal format (xx.yyyyyy)", + 'longitude': "Longitude in decimal format (xx.yyyyyy)" } diff --git a/netbox/dcim/migrations/0059_site_latitude_longitude.py b/netbox/dcim/migrations/0059_site_latitude_longitude.py new file mode 100644 index 000000000..15e666f35 --- /dev/null +++ b/netbox/dcim/migrations/0059_site_latitude_longitude.py @@ -0,0 +1,25 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.12 on 2018-06-21 18:45 +from __future__ import unicode_literals + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0058_relax_rack_naming_constraints'), + ] + + operations = [ + migrations.AddField( + model_name='site', + name='latitude', + field=models.DecimalField(blank=True, decimal_places=6, max_digits=8, null=True), + ), + migrations.AddField( + model_name='site', + name='longitude', + field=models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True), + ), + ] diff --git a/netbox/dcim/migrations/0059_change_logging.py b/netbox/dcim/migrations/0060_change_logging.py similarity index 98% rename from netbox/dcim/migrations/0059_change_logging.py rename to netbox/dcim/migrations/0060_change_logging.py index b64e2570b..8a40f4e4e 100644 --- a/netbox/dcim/migrations/0059_change_logging.py +++ b/netbox/dcim/migrations/0060_change_logging.py @@ -8,7 +8,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('dcim', '0058_relax_rack_naming_constraints'), + ('dcim', '0059_site_latitude_longitude'), ] operations = [ diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index f430eb095..5ffac3f5c 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -136,6 +136,18 @@ class Site(ChangeLoggedModel, CustomFieldModel): max_length=200, blank=True ) + latitude = models.DecimalField( + max_digits=8, + decimal_places=6, + blank=True, + null=True + ) + longitude = models.DecimalField( + max_digits=9, + decimal_places=6, + blank=True, + null=True + ) contact_name = models.CharField( max_length=50, blank=True @@ -166,7 +178,7 @@ class Site(ChangeLoggedModel, CustomFieldModel): serializer = 'dcim.api.serializers.SiteSerializer' csv_headers = [ 'name', 'slug', 'status', 'region', 'tenant', 'facility', 'asn', 'time_zone', 'description', 'physical_address', - 'shipping_address', 'contact_name', 'contact_phone', 'contact_email', 'comments', + 'shipping_address', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'contact_email', 'comments', ] class Meta: @@ -191,6 +203,8 @@ class Site(ChangeLoggedModel, CustomFieldModel): self.description, self.physical_address, self.shipping_address, + self.latitude, + self.longitude, self.contact_name, self.contact_phone, self.contact_email, diff --git a/netbox/extras/constants.py b/netbox/extras/constants.py index 84c84dfcf..6807af9d9 100644 --- a/netbox/extras/constants.py +++ b/netbox/extras/constants.py @@ -3,11 +3,11 @@ from __future__ import unicode_literals # Models which support custom fields CUSTOMFIELD_MODELS = ( - 'provider', 'circuit', # Circuits - 'site', 'rack', 'devicetype', 'device', # DCIM - 'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', # IPAM - 'tenant', # Tenancy - 'cluster', 'virtualmachine', # Virtualization + 'provider', 'circuit', # Circuits + 'site', 'rack', 'devicetype', 'device', # DCIM + 'aggregate', 'prefix', 'ipaddress', 'vlan', 'vrf', 'service', # IPAM + 'tenant', # Tenancy + 'cluster', 'virtualmachine', # Virtualization ) # Custom field types diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index f7969fbc3..bfbc66be3 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -301,7 +301,7 @@ class AvailableIPSerializer(serializers.Serializer): # Services # -class ServiceSerializer(ValidatedModelSerializer): +class ServiceSerializer(CustomFieldModelSerializer): device = NestedDeviceSerializer(required=False, allow_null=True) virtual_machine = NestedVirtualMachineSerializer(required=False, allow_null=True) protocol = ChoiceFieldSerializer(choices=IP_PROTOCOL_CHOICES) @@ -315,6 +315,6 @@ class ServiceSerializer(ValidatedModelSerializer): class Meta: model = Service fields = [ - 'id', 'device', 'virtual_machine', 'name', 'port', 'protocol', 'ipaddresses', 'description', 'created', - 'last_updated', + 'id', 'device', 'virtual_machine', 'name', 'port', 'protocol', 'ipaddresses', 'description', + 'custom_fields', 'created', 'last_updated', ] diff --git a/netbox/ipam/filters.py b/netbox/ipam/filters.py index db2806b77..487970b49 100644 --- a/netbox/ipam/filters.py +++ b/netbox/ipam/filters.py @@ -426,6 +426,10 @@ class VLANFilter(CustomFieldFilterSet, django_filters.FilterSet): class ServiceFilter(django_filters.FilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) device_id = django_filters.ModelMultipleChoiceFilter( queryset=Device.objects.all(), label='Device (ID)', @@ -450,3 +454,9 @@ class ServiceFilter(django_filters.FilterSet): class Meta: model = Service fields = ['name', 'protocol', 'port'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + qs_filter = Q(name__icontains=value) | Q(description__icontains=value) + return queryset.filter(qs_filter) diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index 82ebfe724..2b5f8ed7a 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -2,6 +2,7 @@ from __future__ import unicode_literals from django import forms from django.core.exceptions import MultipleObjectsReturned +from django.core.validators import MaxValueValidator, MinValueValidator from django.db.models import Count from taggit.forms import TagField @@ -15,7 +16,9 @@ from utilities.forms import ( SlugField, add_blank_choice, ) from virtualization.models import VirtualMachine -from .constants import IPADDRESS_ROLE_CHOICES, IPADDRESS_STATUS_CHOICES, PREFIX_STATUS_CHOICES, VLAN_STATUS_CHOICES +from .constants import ( + IP_PROTOCOL_CHOICES, IPADDRESS_ROLE_CHOICES, IPADDRESS_STATUS_CHOICES, PREFIX_STATUS_CHOICES, VLAN_STATUS_CHOICES, +) from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF IP_FAMILY_CHOICES = [ @@ -914,7 +917,7 @@ class VLANFilterForm(BootstrapMixin, CustomFieldFilterForm): # Services # -class ServiceForm(BootstrapMixin, forms.ModelForm): +class ServiceForm(BootstrapMixin, CustomFieldForm): class Meta: model = Service @@ -940,3 +943,28 @@ class ServiceForm(BootstrapMixin, forms.ModelForm): ) else: self.fields['ipaddresses'].choices = [] + + +class ServiceFilterForm(BootstrapMixin, CustomFieldFilterForm): + model = Service + q = forms.CharField( + required=False, + label='Search' + ) + protocol = forms.ChoiceField( + choices=add_blank_choice(IP_PROTOCOL_CHOICES), + required=False + ) + port = forms.IntegerField( + required=False + ) + + +class ServiceBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm): + pk = forms.ModelMultipleChoiceField(queryset=Service.objects.all(), widget=forms.MultipleHiddenInput) + protocol = forms.ChoiceField(choices=add_blank_choice(IP_PROTOCOL_CHOICES), required=False) + port = forms.IntegerField(validators=[MinValueValidator(1), MaxValueValidator(65535)], required=False) + description = forms.CharField(max_length=100, required=False) + + class Meta: + nullable_fields = ['site', 'group', 'tenant', 'role', 'description'] diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index f1414bd27..e1bd93f97 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -831,7 +831,7 @@ class VLAN(ChangeLoggedModel, CustomFieldModel): @python_2_unicode_compatible -class Service(ChangeLoggedModel): +class Service(ChangeLoggedModel, CustomFieldModel): """ A Service represents a layer-four service (e.g. HTTP or SSH) running on a Device or VirtualMachine. A Service may optionally be tied to one or more specific IPAddresses belonging to its parent. @@ -871,6 +871,11 @@ class Service(ChangeLoggedModel): max_length=100, blank=True ) + custom_field_values = GenericRelation( + to='extras.CustomFieldValue', + content_type_field='obj_type', + object_id_field='obj_id' + ) serializer = 'ipam.api.serializers.ServiceSerializer' @@ -880,6 +885,9 @@ class Service(ChangeLoggedModel): def __str__(self): return '{} ({}/{})'.format(self.name, self.port, self.get_protocol_display()) + def get_absolute_url(self): + return reverse('ipam:service', args=[self.pk]) + @property def parent(self): return self.device or self.virtual_machine diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py index e6310475f..2cb1c6606 100644 --- a/netbox/ipam/tables.py +++ b/netbox/ipam/tables.py @@ -6,7 +6,7 @@ from django_tables2.utils import Accessor from dcim.models import Interface from tenancy.tables import COL_TENANT from utilities.tables import BaseTable, ToggleColumn -from .models import Aggregate, IPAddress, Prefix, RIR, Role, VLAN, VLANGroup, VRF +from .models import Aggregate, IPAddress, Prefix, RIR, Role, Service, VLAN, VLANGroup, VRF RIR_UTILIZATION = """
@@ -401,3 +401,19 @@ class VLANMemberTable(BaseTable): class Meta(BaseTable.Meta): model = Interface fields = ('parent', 'name', 'untagged', 'actions') + + +# +# Services +# + +class ServiceTable(BaseTable): + pk = ToggleColumn() + name = tables.LinkColumn( + viewname='ipam:service', + args=[Accessor('pk')] + ) + + class Meta(BaseTable.Meta): + model = Service + fields = ('pk', 'name', 'parent', 'protocol', 'port', 'description') diff --git a/netbox/ipam/urls.py b/netbox/ipam/urls.py index 2313fedb9..5b5f7df33 100644 --- a/netbox/ipam/urls.py +++ b/netbox/ipam/urls.py @@ -95,6 +95,10 @@ urlpatterns = [ url(r'^vlans/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='vlan_changelog', kwargs={'model': VLAN}), # Services + url(r'^services/$', views.ServiceListView.as_view(), name='service_list'), + url(r'^services/edit/$', views.ServiceBulkEditView.as_view(), name='service_bulk_edit'), + url(r'^services/delete/$', views.ServiceBulkDeleteView.as_view(), name='service_bulk_delete'), + url(r'^services/(?P\d+)/$', views.ServiceView.as_view(), name='service'), 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'), url(r'^services/(?P\d+)/changelog/$', ObjectChangeLogView.as_view(), name='service_changelog', kwargs={'model': Service}), diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 70ef83f49..3c88da5fd 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -923,6 +923,25 @@ class VLANBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): # Services # +class ServiceListView(ObjectListView): + queryset = Service.objects.select_related('device', 'virtual_machine') + filter = filters.ServiceFilter + filter_form = forms.ServiceFilterForm + table = tables.ServiceTable + template_name = 'ipam/service_list.html' + + +class ServiceView(View): + + def get(self, request, pk): + + service = get_object_or_404(Service, pk=pk) + + return render(request, 'ipam/service.html', { + 'service': service, + }) + + class ServiceCreateView(PermissionRequiredMixin, ObjectEditView): permission_required = 'ipam.add_service' model = Service @@ -936,9 +955,6 @@ class ServiceCreateView(PermissionRequiredMixin, ObjectEditView): obj.virtual_machine = get_object_or_404(VirtualMachine, pk=url_kwargs['virtualmachine']) return obj - def get_return_url(self, request, obj): - return obj.parent.get_absolute_url() - class ServiceEditView(ServiceCreateView): permission_required = 'ipam.change_service' @@ -947,3 +963,22 @@ class ServiceEditView(ServiceCreateView): class ServiceDeleteView(PermissionRequiredMixin, ObjectDeleteView): permission_required = 'ipam.delete_service' model = Service + + +class ServiceBulkEditView(PermissionRequiredMixin, BulkEditView): + permission_required = 'ipam.change_service' + cls = Service + queryset = Service.objects.all() + filter = filters.ServiceFilter + table = tables.ServiceTable + form = forms.ServiceBulkEditForm + default_return_url = 'ipam:service_list' + + +class ServiceBulkDeleteView(PermissionRequiredMixin, BulkDeleteView): + permission_required = 'ipam.delete_service' + cls = Service + queryset = Service.objects.all() + filter = filters.ServiceFilter + table = tables.ServiceTable + default_return_url = 'ipam:service_list' diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index c6a5207bc..442242214 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -187,6 +187,16 @@ {% endif %} + + GPS Coordinates + + {% if site.latitude and site.longitude %} + {{ site.latitude }}, {{ site.longitude }} + {% else %} + N/A + {% endif %} + + Contact Name diff --git a/netbox/templates/dcim/site_edit.html b/netbox/templates/dcim/site_edit.html index ad7932642..a8d14aca7 100644 --- a/netbox/templates/dcim/site_edit.html +++ b/netbox/templates/dcim/site_edit.html @@ -27,6 +27,8 @@
{% render_field form.physical_address %} {% render_field form.shipping_address %} + {% render_field form.latitude %} + {% render_field form.longitude %} {% render_field form.contact_name %} {% render_field form.contact_phone %} {% render_field form.contact_email %} diff --git a/netbox/templates/inc/nav_menu.html b/netbox/templates/inc/nav_menu.html index 7011bff2b..ced87768e 100644 --- a/netbox/templates/inc/nav_menu.html +++ b/netbox/templates/inc/nav_menu.html @@ -286,6 +286,11 @@ {% endif %} VLAN Groups +
  • + +
  • + Services +
  • + {% if form.custom_fields %} +
    +
    Custom Fields
    +
    + {% render_custom_fields form %} +
    +
    + {% endif %} {% endblock %} diff --git a/netbox/templates/ipam/service_list.html b/netbox/templates/ipam/service_list.html new file mode 100644 index 000000000..c78b2bba2 --- /dev/null +++ b/netbox/templates/ipam/service_list.html @@ -0,0 +1,13 @@ +{% extends '_base.html' %} + +{% block content %} +

    {% block title %}Services{% endblock %}

    +
    +
    + {% include 'utilities/obj_table.html' with bulk_edit_url='ipam:service_bulk_edit' bulk_delete_url='ipam:service_bulk_delete' %} +
    +
    + {% include 'inc/search_panel.html' %} +
    +
    +{% endblock %}