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..a521549f9 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
@@ -10,12 +11,14 @@ from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFi
from tenancy.forms import TenancyForm
from tenancy.models import Tenant
from utilities.forms import (
- AnnotatedMultipleChoiceField, APISelect, BootstrapMixin, BulkEditNullBooleanSelect, ChainedModelChoiceField,
- CSVChoiceField, ExpandableIPAddressField, FilterChoiceField, FlexibleModelChoiceField, Livesearch, ReturnURLForm,
- SlugField, add_blank_choice,
+ AnnotatedMultipleChoiceField, APISelect, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
+ ChainedModelChoiceField, CSVChoiceField, ExpandableIPAddressField, FilterChoiceField, FlexibleModelChoiceField,
+ Livesearch, ReturnURLForm, 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 = [
@@ -940,3 +943,25 @@ 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, BulkEditForm):
+ 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 a3d8736c1..736160fd8 100644
--- a/netbox/ipam/models.py
+++ b/netbox/ipam/models.py
@@ -884,6 +884,9 @@ class Service(CreatedUpdatedModel):
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 08035d549..7f8b8918d 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 = """
@@ -392,3 +392,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 aa7c17a5c..d22c32561 100644
--- a/netbox/ipam/urls.py
+++ b/netbox/ipam/urls.py
@@ -85,6 +85,10 @@ urlpatterns = [
url(r'^vlans/(?P
\d+)/delete/$', views.VLANDeleteView.as_view(), name='vlan_delete'),
# 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'),
diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py
index 1d4575e34..e60d1bd79 100644
--- a/netbox/ipam/views.py
+++ b/netbox/ipam/views.py
@@ -931,6 +931,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
@@ -944,9 +963,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'
@@ -955,3 +971,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/inc/nav_menu.html b/netbox/templates/inc/nav_menu.html
index 2c47ad85b..7b9e6ac3c 100644
--- a/netbox/templates/inc/nav_menu.html
+++ b/netbox/templates/inc/nav_menu.html
@@ -283,6 +283,11 @@
{% endif %}
VLAN Groups
+
+
+
+ Services
+
diff --git a/netbox/templates/ipam/inc/service.html b/netbox/templates/ipam/inc/service.html
index a88aff4ce..4a6c15ad2 100644
--- a/netbox/templates/ipam/inc/service.html
+++ b/netbox/templates/ipam/inc/service.html
@@ -1,5 +1,7 @@
- {{ service.name }} |
+
+ {{ service.name }}
+ |
{{ service.get_protocol_display }}/{{ service.port }}
|
diff --git a/netbox/templates/ipam/service.html b/netbox/templates/ipam/service.html
new file mode 100644
index 000000000..c53c6124a
--- /dev/null
+++ b/netbox/templates/ipam/service.html
@@ -0,0 +1,88 @@
+{% extends '_base.html' %}
+{% load helpers %}
+
+{% block content %}
+
+{% if perms.dcim.change_service %}
+
+{% endif %}
+{% block title %}{{ service }}{% endblock %}
+{% include 'inc/created_updated.html' with obj=service %}
+
+
+
+
+ Service
+
+
+
+ Name |
+ {{ service.name }} |
+
+
+ Parent |
+
+ {{ service.parent }}
+ |
+
+
+ Protocol |
+ {{ service.get_protocol_display }} |
+
+
+ Port |
+ {{ service.port }} |
+
+
+ IP Addresses |
+
+ {% for ipaddress in service.ipaddresses.all %}
+ {{ ipaddress }}
+ {% empty %}
+ None
+ {% endfor %}
+ |
+
+
+ Description |
+
+ {% if service.description %}
+ {{ service.description }}
+ {% else %}
+ N/A
+ {% endif %}
+ |
+
+
+
+ {% with service.get_custom_fields as custom_fields %}
+ {% include 'inc/custom_fields_panel.html' %}
+ {% endwith %}
+
+
+{% endblock %}
diff --git a/netbox/templates/ipam/service_list.html b/netbox/templates/ipam/service_list.html
new file mode 100644
index 000000000..840b071bd
--- /dev/null
+++ b/netbox/templates/ipam/service_list.html
@@ -0,0 +1,15 @@
+{% extends '_base.html' %}
+{% load buttons %}
+{% load humanize %}
+
+{% 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 %}