Parcourir la source

Introduce ServiceTemplate

jeremystretch il y a 4 ans
Parent
commit
97e7ef9a3f

+ 1 - 0
docs/core-functionality/services.md

@@ -1,3 +1,4 @@
 # Service Mapping
 
+{!models/ipam/servicetemplate.md!}
 {!models/ipam/service.md!}

+ 3 - 0
docs/models/ipam/servicetemplate.md

@@ -0,0 +1,3 @@
+# Service Templates
+
+Service templates can be used to instantiate services on devices and virtual machines. A template defines a name, protocol, and port number(s), and may optionally include a description. Services can be instantiated from templates and applied to devices and/or virtual machines, and may be associated with specific IP addresses.

+ 9 - 0
netbox/ipam/api/nested_serializers.py

@@ -15,6 +15,7 @@ __all__ = [
     'NestedRoleSerializer',
     'NestedRouteTargetSerializer',
     'NestedServiceSerializer',
+    'NestedServiceTemplateSerializer',
     'NestedVLANGroupSerializer',
     'NestedVLANSerializer',
     'NestedVRFSerializer',
@@ -175,6 +176,14 @@ class NestedIPAddressSerializer(WritableNestedSerializer):
 # Services
 #
 
+class NestedServiceTemplateSerializer(WritableNestedSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='ipam-api:servicetemplate-detail')
+
+    class Meta:
+        model = models.ServiceTemplate
+        fields = ['id', 'url', 'display', 'name', 'protocol', 'ports']
+
+
 class NestedServiceSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:service-detail')
 

+ 12 - 0
netbox/ipam/api/serializers.py

@@ -403,6 +403,18 @@ class AvailableIPSerializer(serializers.Serializer):
 # Services
 #
 
+class ServiceTemplateSerializer(PrimaryModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='ipam-api:servicetemplate-detail')
+    protocol = ChoiceField(choices=ServiceProtocolChoices, required=False)
+
+    class Meta:
+        model = ServiceTemplate
+        fields = [
+            'id', 'url', 'display', 'name', 'ports', 'protocol', 'description', 'tags', 'custom_fields', 'created',
+            'last_updated',
+        ]
+
+
 class ServiceSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='ipam-api:service-detail')
     device = NestedDeviceSerializer(required=False, allow_null=True)

+ 1 - 0
netbox/ipam/api/urls.py

@@ -42,6 +42,7 @@ router.register('vlan-groups', views.VLANGroupViewSet)
 router.register('vlans', views.VLANViewSet)
 
 # Services
+router.register('service-templates', views.ServiceTemplateViewSet)
 router.register('services', views.ServiceViewSet)
 
 app_name = 'ipam-api'

+ 7 - 1
netbox/ipam/api/views.py

@@ -140,7 +140,13 @@ class VLANViewSet(CustomFieldModelViewSet):
     filterset_class = filtersets.VLANFilterSet
 
 
-class ServiceViewSet(ModelViewSet):
+class ServiceTemplateViewSet(CustomFieldModelViewSet):
+    queryset = ServiceTemplate.objects.prefetch_related('tags')
+    serializer_class = serializers.ServiceTemplateSerializer
+    filterset_class = filtersets.ServiceTemplateFilterSet
+
+
+class ServiceViewSet(CustomFieldModelViewSet):
     queryset = Service.objects.prefetch_related(
         'device', 'virtual_machine', 'tags', 'ipaddresses'
     )

+ 23 - 0
netbox/ipam/filtersets.py

@@ -29,6 +29,7 @@ __all__ = (
     'RoleFilterSet',
     'RouteTargetFilterSet',
     'ServiceFilterSet',
+    'ServiceTemplateFilterSet',
     'VLANFilterSet',
     'VLANGroupFilterSet',
     'VRFFilterSet',
@@ -854,6 +855,28 @@ class VLANFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
         return queryset.get_for_virtualmachine(value)
 
 
+class ServiceTemplateFilterSet(PrimaryModelFilterSet):
+    q = django_filters.CharFilter(
+        method='search',
+        label='Search',
+    )
+    port = NumericArrayFilter(
+        field_name='ports',
+        lookup_expr='contains'
+    )
+    tag = TagFilter()
+
+    class Meta:
+        model = ServiceTemplate
+        fields = ['id', 'name', 'protocol']
+
+    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)
+
+
 class ServiceFilterSet(PrimaryModelFilterSet):
     q = django_filters.CharFilter(
         method='search',

+ 10 - 2
netbox/ipam/forms/bulk_edit.py

@@ -23,6 +23,7 @@ __all__ = (
     'RoleBulkEditForm',
     'RouteTargetBulkEditForm',
     'ServiceBulkEditForm',
+    'ServiceTemplateBulkEditForm',
     'VLANBulkEditForm',
     'VLANGroupBulkEditForm',
     'VRFBulkEditForm',
@@ -433,9 +434,9 @@ class VLANBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         ]
 
 
-class ServiceBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+class ServiceTemplateBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
-        queryset=Service.objects.all(),
+        queryset=ServiceTemplate.objects.all(),
         widget=forms.MultipleHiddenInput()
     )
     protocol = forms.ChoiceField(
@@ -459,3 +460,10 @@ class ServiceBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         nullable_fields = [
             'description',
         ]
+
+
+class ServiceBulkEditForm(ServiceTemplateBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=Service.objects.all(),
+        widget=forms.MultipleHiddenInput()
+    )

+ 12 - 0
netbox/ipam/forms/bulk_import.py

@@ -21,6 +21,7 @@ __all__ = (
     'RoleCSVForm',
     'RouteTargetCSVForm',
     'ServiceCSVForm',
+    'ServiceTemplateCSVForm',
     'VLANCSVForm',
     'VLANGroupCSVForm',
     'VRFCSVForm',
@@ -392,6 +393,17 @@ class VLANCSVForm(CustomFieldModelCSVForm):
         }
 
 
+class ServiceTemplateCSVForm(CustomFieldModelCSVForm):
+    protocol = CSVChoiceField(
+        choices=ServiceProtocolChoices,
+        help_text='IP protocol'
+    )
+
+    class Meta:
+        model = ServiceTemplate
+        fields = ('name', 'protocol', 'ports', 'description')
+
+
 class ServiceCSVForm(CustomFieldModelCSVForm):
     device = CSVModelChoiceField(
         queryset=Device.objects.all(),

+ 7 - 2
netbox/ipam/forms/filtersets.py

@@ -24,6 +24,7 @@ __all__ = (
     'RoleFilterForm',
     'RouteTargetFilterForm',
     'ServiceFilterForm',
+    'ServiceTemplateFilterForm',
     'VLANFilterForm',
     'VLANGroupFilterForm',
     'VRFFilterForm',
@@ -447,8 +448,8 @@ class VLANFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
     tag = TagFilterField(model)
 
 
-class ServiceFilterForm(CustomFieldModelFilterForm):
-    model = Service
+class ServiceTemplateFilterForm(CustomFieldModelFilterForm):
+    model = ServiceTemplate
     field_groups = (
         ('q', 'tag'),
         ('protocol', 'port'),
@@ -462,3 +463,7 @@ class ServiceFilterForm(CustomFieldModelFilterForm):
         required=False,
     )
     tag = TagFilterField(model)
+
+
+class ServiceFilterForm(ServiceTemplateFilterForm):
+    model = Service

+ 22 - 0
netbox/ipam/forms/models.py

@@ -31,6 +31,7 @@ __all__ = (
     'RoleForm',
     'RouteTargetForm',
     'ServiceForm',
+    'ServiceTemplateForm',
     'VLANForm',
     'VLANGroupForm',
     'VRFForm',
@@ -815,6 +816,27 @@ class VLANForm(TenancyForm, CustomFieldModelForm):
         }
 
 
+class ServiceTemplateForm(CustomFieldModelForm):
+    ports = NumericArrayField(
+        base_field=forms.IntegerField(
+            min_value=SERVICE_PORT_MIN,
+            max_value=SERVICE_PORT_MAX
+        ),
+        help_text="Comma-separated list of one or more port numbers. A range may be specified using a hyphen."
+    )
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
+
+    class Meta:
+        model = ServiceTemplate
+        fields = ('name', 'protocol', 'ports', 'description', 'tags')
+        widgets = {
+            'protocol': StaticSelect(),
+        }
+
+
 class ServiceForm(CustomFieldModelForm):
     device = DynamicModelChoiceField(
         queryset=Device.objects.all(),

+ 3 - 0
netbox/ipam/graphql/schema.py

@@ -32,6 +32,9 @@ class IPAMQuery(graphene.ObjectType):
     service = ObjectField(ServiceType)
     service_list = ObjectListField(ServiceType)
 
+    service_template = ObjectField(ServiceTemplateType)
+    service_template_list = ObjectListField(ServiceTemplateType)
+
     fhrp_group = ObjectField(FHRPGroupType)
     fhrp_group_list = ObjectListField(FHRPGroupType)
 

+ 9 - 0
netbox/ipam/graphql/types.py

@@ -16,6 +16,7 @@ __all__ = (
     'RoleType',
     'RouteTargetType',
     'ServiceType',
+    'ServiceTemplateType',
     'VLANType',
     'VLANGroupType',
     'VRFType',
@@ -120,6 +121,14 @@ class ServiceType(PrimaryObjectType):
         filterset_class = filtersets.ServiceFilterSet
 
 
+class ServiceTemplateType(PrimaryObjectType):
+
+    class Meta:
+        model = models.ServiceTemplate
+        fields = '__all__'
+        filterset_class = filtersets.ServiceTemplateFilterSet
+
+
 class VLANType(PrimaryObjectType):
 
     class Meta:

+ 33 - 0
netbox/ipam/migrations/0055_servicetemplate.py

@@ -0,0 +1,33 @@
+import django.contrib.postgres.fields
+import django.core.serializers.json
+import django.core.validators
+from django.db import migrations, models
+import taggit.managers
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('extras', '0070_customlink_enabled'),
+        ('ipam', '0054_vlangroup_min_max_vids'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='ServiceTemplate',
+            fields=[
+                ('created', models.DateField(auto_now_add=True, null=True)),
+                ('last_updated', models.DateTimeField(auto_now=True, null=True)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                ('id', models.BigAutoField(primary_key=True, serialize=False)),
+                ('protocol', models.CharField(max_length=50)),
+                ('ports', django.contrib.postgres.fields.ArrayField(base_field=models.PositiveIntegerField(validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(65535)]), size=None)),
+                ('description', models.CharField(blank=True, max_length=200)),
+                ('name', models.CharField(max_length=100, unique=True)),
+                ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
+            ],
+            options={
+                'ordering': ('name',),
+            },
+        ),
+    ]

+ 1 - 0
netbox/ipam/models/__init__.py

@@ -16,6 +16,7 @@ __all__ = (
     'Role',
     'RouteTarget',
     'Service',
+    'ServiceTemplate',
     'VLAN',
     'VLANGroup',
     'VRF',

+ 49 - 25
netbox/ipam/models/services.py

@@ -13,11 +13,59 @@ from utilities.utils import array_to_string
 
 __all__ = (
     'Service',
+    'ServiceTemplate',
 )
 
 
+class ServiceBase(models.Model):
+    protocol = models.CharField(
+        max_length=50,
+        choices=ServiceProtocolChoices
+    )
+    ports = ArrayField(
+        base_field=models.PositiveIntegerField(
+            validators=[
+                MinValueValidator(SERVICE_PORT_MIN),
+                MaxValueValidator(SERVICE_PORT_MAX)
+            ]
+        ),
+        verbose_name='Port numbers'
+    )
+    description = models.CharField(
+        max_length=200,
+        blank=True
+    )
+
+    class Meta:
+        abstract = True
+
+    def __str__(self):
+        return f'{self.name} ({self.get_protocol_display()}/{self.port_list})'
+
+    @property
+    def port_list(self):
+        return array_to_string(self.ports)
+
+
 @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
-class Service(PrimaryModel):
+class ServiceTemplate(ServiceBase, PrimaryModel):
+    """
+    A template for a Service to be applied to a device or virtual machine.
+    """
+    name = models.CharField(
+        max_length=100,
+        unique=True
+    )
+
+    class Meta:
+        ordering = ('name',)
+
+    def get_absolute_url(self):
+        return reverse('ipam:servicetemplate', args=[self.pk])
+
+
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
+class Service(ServiceBase, PrimaryModel):
     """
     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.
@@ -40,36 +88,16 @@ class Service(PrimaryModel):
     name = models.CharField(
         max_length=100
     )
-    protocol = models.CharField(
-        max_length=50,
-        choices=ServiceProtocolChoices
-    )
-    ports = ArrayField(
-        base_field=models.PositiveIntegerField(
-            validators=[
-                MinValueValidator(SERVICE_PORT_MIN),
-                MaxValueValidator(SERVICE_PORT_MAX)
-            ]
-        ),
-        verbose_name='Port numbers'
-    )
     ipaddresses = models.ManyToManyField(
         to='ipam.IPAddress',
         related_name='services',
         blank=True,
         verbose_name='IP addresses'
     )
-    description = models.CharField(
-        max_length=200,
-        blank=True
-    )
 
     class Meta:
         ordering = ('protocol', 'ports', 'pk')  # (protocol, port) may be non-unique
 
-    def __str__(self):
-        return f'{self.name} ({self.get_protocol_display()}/{self.port_list})'
-
     def get_absolute_url(self):
         return reverse('ipam:service', args=[self.pk])
 
@@ -85,7 +113,3 @@ class Service(PrimaryModel):
             raise ValidationError("A service cannot be associated with both a device and a virtual machine.")
         if not self.device and not self.virtual_machine:
             raise ValidationError("A service must be associated with either a device or a virtual machine.")
-
-    @property
-    def port_list(self):
-        return array_to_string(self.ports)

+ 20 - 6
netbox/ipam/tables/services.py

@@ -5,12 +5,27 @@ from ipam.models import *
 
 __all__ = (
     'ServiceTable',
+    'ServiceTemplateTable',
 )
 
 
-#
-# Services
-#
+class ServiceTemplateTable(BaseTable):
+    pk = ToggleColumn()
+    name = tables.Column(
+        linkify=True
+    )
+    ports = tables.Column(
+        accessor=tables.A('port_list')
+    )
+    tags = TagColumn(
+        url_name='ipam:servicetemplate_list'
+    )
+
+    class Meta(BaseTable.Meta):
+        model = ServiceTemplate
+        fields = ('pk', 'id', 'name', 'protocol', 'ports', 'description', 'tags')
+        default_columns = ('pk', 'name', 'protocol', 'ports', 'description')
+
 
 class ServiceTable(BaseTable):
     pk = ToggleColumn()
@@ -21,9 +36,8 @@ class ServiceTable(BaseTable):
         linkify=True,
         order_by=('device', 'virtual_machine')
     )
-    ports = tables.TemplateColumn(
-        template_code='{{ record.port_list }}',
-        verbose_name='Ports'
+    ports = tables.Column(
+        accessor=tables.A('port_list')
     )
     tags = TagColumn(
         url_name='ipam:service_list'

+ 35 - 0
netbox/ipam/tests/test_api.py

@@ -832,6 +832,41 @@ class VLANTest(APIViewTestCases.APIViewTestCase):
         self.assertTrue(content['detail'].startswith('Unable to delete object.'))
 
 
+class ServiceTemplateTest(APIViewTestCases.APIViewTestCase):
+    model = ServiceTemplate
+    brief_fields = ['display', 'id', 'name', 'ports', 'protocol', 'url']
+    bulk_update_data = {
+        'description': 'New description',
+    }
+
+    @classmethod
+    def setUpTestData(cls):
+        service_templates = (
+            ServiceTemplate(name='Service Template 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[1, 2]),
+            ServiceTemplate(name='Service Template 2', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[3, 4]),
+            ServiceTemplate(name='Service Template 3', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[5, 6]),
+        )
+        ServiceTemplate.objects.bulk_create(service_templates)
+
+        cls.create_data = [
+            {
+                'name': 'Service Template 4',
+                'protocol': ServiceProtocolChoices.PROTOCOL_TCP,
+                'ports': [7, 8],
+            },
+            {
+                'name': 'Service Template 5',
+                'protocol': ServiceProtocolChoices.PROTOCOL_TCP,
+                'ports': [9, 10],
+            },
+            {
+                'name': 'Service Template 6',
+                'protocol': ServiceProtocolChoices.PROTOCOL_TCP,
+                'ports': [11, 12],
+            },
+        ]
+
+
 class ServiceTest(APIViewTestCases.APIViewTestCase):
     model = Service
     brief_fields = ['display', 'id', 'name', 'ports', 'protocol', 'url']

+ 29 - 0
netbox/ipam/tests/test_filtersets.py

@@ -1307,6 +1307,35 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)  # 5 scoped + 1 global
 
 
+class ServiceTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
+    queryset = ServiceTemplate.objects.all()
+    filterset = ServiceTemplateFilterSet
+
+    @classmethod
+    def setUpTestData(cls):
+        service_templates = (
+            ServiceTemplate(name='Service Template 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[1001]),
+            ServiceTemplate(name='Service Template 2', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[1002]),
+            ServiceTemplate(name='Service Template 3', protocol=ServiceProtocolChoices.PROTOCOL_UDP, ports=[1003]),
+            ServiceTemplate(name='Service Template 4', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[2001]),
+            ServiceTemplate(name='Service Template 5', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[2002]),
+            ServiceTemplate(name='Service Template 6', protocol=ServiceProtocolChoices.PROTOCOL_UDP, ports=[2003]),
+        )
+        ServiceTemplate.objects.bulk_create(service_templates)
+
+    def test_name(self):
+        params = {'name': ['Service Template 1', 'Service Template 2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_protocol(self):
+        params = {'protocol': ServiceProtocolChoices.PROTOCOL_TCP}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+
+    def test_port(self):
+        params = {'port': '1001'}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+
 class ServiceTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = Service.objects.all()
     filterset = ServiceFilterSet

+ 35 - 0
netbox/ipam/tests/test_views.py

@@ -641,6 +641,41 @@ class VLANTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         }
 
 
+class ServiceTemplateTestCase(ViewTestCases.PrimaryObjectViewTestCase):
+    model = ServiceTemplate
+
+    @classmethod
+    def setUpTestData(cls):
+        ServiceTemplate.objects.bulk_create([
+            ServiceTemplate(name='Service Template 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[101]),
+            ServiceTemplate(name='Service Template 2', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[102]),
+            ServiceTemplate(name='Service Template 3', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[103]),
+        ])
+
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
+        cls.form_data = {
+            'name': 'Service Template X',
+            'protocol': ServiceProtocolChoices.PROTOCOL_UDP,
+            'ports': '104,105',
+            'description': 'A new service template',
+            'tags': [t.pk for t in tags],
+        }
+
+        cls.csv_data = (
+            "name,protocol,ports,description",
+            "Service Template 4,tcp,1,First service template",
+            "Service Template 5,tcp,2,Second service template",
+            "Service Template 6,tcp,3,Third service template",
+        )
+
+        cls.bulk_edit_data = {
+            'protocol': ServiceProtocolChoices.PROTOCOL_UDP,
+            'ports': '106,107',
+            'description': 'New description',
+        }
+
+
 class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = Service
 

+ 12 - 0
netbox/ipam/urls.py

@@ -162,6 +162,18 @@ urlpatterns = [
     path('vlans/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='vlan_changelog', kwargs={'model': VLAN}),
     path('vlans/<int:pk>/journal/', ObjectJournalView.as_view(), name='vlan_journal', kwargs={'model': VLAN}),
 
+    # Service templates
+    path('service-templates/', views.ServiceTemplateListView.as_view(), name='servicetemplate_list'),
+    path('service-templates/add/', views.ServiceTemplateEditView.as_view(), name='servicetemplate_add'),
+    path('service-templates/import/', views.ServiceTemplateBulkImportView.as_view(), name='servicetemplate_import'),
+    path('service-templates/edit/', views.ServiceTemplateBulkEditView.as_view(), name='servicetemplate_bulk_edit'),
+    path('service-templates/delete/', views.ServiceTemplateBulkDeleteView.as_view(), name='servicetemplate_bulk_delete'),
+    path('service-templates/<int:pk>/', views.ServiceTemplateView.as_view(), name='servicetemplate'),
+    path('service-templates/<int:pk>/edit/', views.ServiceTemplateEditView.as_view(), name='servicetemplate_edit'),
+    path('service-templates/<int:pk>/delete/', views.ServiceTemplateDeleteView.as_view(), name='servicetemplate_delete'),
+    path('service-templates/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='servicetemplate_changelog', kwargs={'model': ServiceTemplate}),
+    path('service-templates/<int:pk>/journal/', ObjectJournalView.as_view(), name='servicetemplate_journal', kwargs={'model': ServiceTemplate}),
+
     # Services
     path('services/', views.ServiceListView.as_view(), name='service_list'),
     path('services/add/', views.ServiceEditView.as_view(), name='service_add'),

+ 47 - 4
netbox/ipam/views.py

@@ -1028,6 +1028,49 @@ class VLANBulkDeleteView(generic.BulkDeleteView):
     table = tables.VLANTable
 
 
+#
+# Service templates
+#
+
+class ServiceTemplateListView(generic.ObjectListView):
+    queryset = ServiceTemplate.objects.all()
+    filterset = filtersets.ServiceTemplateFilterSet
+    filterset_form = forms.ServiceTemplateFilterForm
+    table = tables.ServiceTemplateTable
+
+
+class ServiceTemplateView(generic.ObjectView):
+    queryset = ServiceTemplate.objects.all()
+
+
+class ServiceTemplateEditView(generic.ObjectEditView):
+    queryset = ServiceTemplate.objects.all()
+    model_form = forms.ServiceTemplateForm
+
+
+class ServiceTemplateDeleteView(generic.ObjectDeleteView):
+    queryset = ServiceTemplate.objects.all()
+
+
+class ServiceTemplateBulkImportView(generic.BulkImportView):
+    queryset = ServiceTemplate.objects.all()
+    model_form = forms.ServiceTemplateCSVForm
+    table = tables.ServiceTemplateTable
+
+
+class ServiceTemplateBulkEditView(generic.BulkEditView):
+    queryset = ServiceTemplate.objects.all()
+    filterset = filtersets.ServiceTemplateFilterSet
+    table = tables.ServiceTemplateTable
+    form = forms.ServiceTemplateBulkEditForm
+
+
+class ServiceTemplateBulkDeleteView(generic.BulkDeleteView):
+    queryset = ServiceTemplate.objects.all()
+    filterset = filtersets.ServiceTemplateFilterSet
+    table = tables.ServiceTemplateTable
+
+
 #
 # Services
 #
@@ -1050,14 +1093,14 @@ class ServiceEditView(generic.ObjectEditView):
     template_name = 'ipam/service_edit.html'
 
 
-class ServiceBulkImportView(generic.BulkImportView):
+class ServiceDeleteView(generic.ObjectDeleteView):
     queryset = Service.objects.all()
-    model_form = forms.ServiceCSVForm
-    table = tables.ServiceTable
 
 
-class ServiceDeleteView(generic.ObjectDeleteView):
+class ServiceBulkImportView(generic.BulkImportView):
     queryset = Service.objects.all()
+    model_form = forms.ServiceCSVForm
+    table = tables.ServiceTable
 
 
 class ServiceBulkEditView(generic.BulkEditView):

+ 1 - 0
netbox/netbox/navigation_menu.py

@@ -264,6 +264,7 @@ IPAM_MENU = Menu(
             label='Other',
             items=(
                 get_model_item('ipam', 'fhrpgroup', 'FHRP Groups'),
+                get_model_item('ipam', 'servicetemplate', 'Service Templates'),
                 get_model_item('ipam', 'service', 'Services'),
             ),
         ),

+ 46 - 0
netbox/templates/ipam/servicetemplate.html

@@ -0,0 +1,46 @@
+{% extends 'generic/object.html' %}
+{% load buttons %}
+{% load helpers %}
+{% load perms %}
+{% load plugins %}
+
+{% block content %}
+  <div class="row mb-3">
+    <div class="col col-md-6">
+      <div class="card">
+        <h5 class="card-header">Service Template</h5>
+        <div class="card-body">
+          <table class="table table-hover attr-table">
+            <tr>
+              <th scope="row">Name</th>
+              <td>{{ object.name }}</td>
+            </tr>
+            <tr>
+              <th scope="row">Protocol</th>
+              <td>{{ object.get_protocol_display }}</td>
+            </tr>
+            <tr>
+              <th scope="row">Ports</th>
+              <td>{{ object.port_list }}</td>
+            </tr>
+            <tr>
+              <th scope="row">Description</th>
+              <td>{{ object.description|placeholder }}</td>
+            </tr>
+          </table>
+        </div>
+      </div>
+      {% plugin_left_page object %}
+      </div>
+      <div class="col col-md-6">
+        {% include 'inc/panels/custom_fields.html' %}
+        {% include 'inc/panels/tags.html' %}
+        {% plugin_right_page object %}
+      </div>
+  </div>
+  <div class="row mb-3">
+    <div class="col col-md-12">
+      {% plugin_full_width_page object %}
+    </div>
+  </div>
+{% endblock %}