Sfoglia il codice sorgente

Merge pull request #8343 from netbox-community/1591-service-templates

Closes #1591: Service templates
Jeremy Stretch 4 anni fa
parent
commit
5077ff169e

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

+ 5 - 0
docs/release-notes/version-3.2.md

@@ -14,6 +14,10 @@
 
 ### New Features
 
+#### Service Templates ([#1591](https://github.com/netbox-community/netbox/issues/1591))
+
+A new service template model has been introduced to assist in standardizing the definition and application of layer four services to devices and virtual machines. As an alternative to manually defining a name, protocol, and port(s) each time a service is created, a user now has the option of selecting a pre-defined template from which these values will be populated.
+
 #### Automatic Provisioning of Next Available VLANs ([#2658](https://github.com/netbox-community/netbox/issues/2658))
 
 A new REST API endpoint has been added at `/api/ipam/vlan-groups/<pk>/available-vlans/`. A GET request to this endpoint will return a list of available VLANs within the group. A POST request can be made to this endpoint specifying the name(s) of one or more VLANs to create within the group, and their VLAN IDs will be assigned automatically.
@@ -83,6 +87,7 @@ Inventory item templates can be arranged hierarchically within a device type, an
     * `/api/dcim/module-bays/`
     * `/api/dcim/module-bay-templates/`
     * `/api/dcim/module-types/`
+    * `/api/extras/service-templates/`
 * circuits.ProviderNetwork
     * Added `service_id` field
 * dcim.ConsolePort

+ 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

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

@@ -31,6 +31,8 @@ __all__ = (
     'RoleForm',
     'RouteTargetForm',
     'ServiceForm',
+    'ServiceCreateForm',
+    'ServiceTemplateForm',
     'VLANForm',
     'VLANGroupForm',
     'VRFForm',
@@ -815,6 +817,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(),
@@ -858,3 +881,36 @@ class ServiceForm(CustomFieldModelForm):
             'protocol': StaticSelect(),
             'ipaddresses': StaticSelectMultiple(),
         }
+
+
+class ServiceCreateForm(ServiceForm):
+    service_template = DynamicModelChoiceField(
+        queryset=ServiceTemplate.objects.all(),
+        required=False
+    )
+
+    class Meta(ServiceForm.Meta):
+        fields = [
+            'device', 'virtual_machine', 'service_template', 'name', 'protocol', 'ports', 'ipaddresses', 'description',
+            'tags',
+        ]
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Fields which may be populated from a ServiceTemplate are not required
+        for field in ('name', 'protocol', 'ports'):
+            self.fields[field].required = False
+            del(self.fields[field].widget.attrs['required'])
+
+    def clean(self):
+        if self.cleaned_data['service_template']:
+            # Create a new Service from the specified template
+            service_template = self.cleaned_data['service_template']
+            self.cleaned_data['name'] = service_template.name
+            self.cleaned_data['protocol'] = service_template.protocol
+            self.cleaned_data['ports'] = service_template.ports
+            if not self.cleaned_data['description']:
+                self.cleaned_data['description'] = service_template.description
+        elif not all(self.cleaned_data[f] for f in ('name', 'protocol', 'ports')):
+            raise forms.ValidationError("Must specify name, protocol, and port(s) if not using a service template.")

+ 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

+ 62 - 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
 
@@ -684,3 +719,30 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'ports': '106,107',
             'description': 'New description',
         }
+
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
+    def test_create_from_template(self):
+        self.add_permissions('ipam.add_service')
+
+        device = Device.objects.first()
+        service_template = ServiceTemplate.objects.create(
+            name='HTTP',
+            protocol=ServiceProtocolChoices.PROTOCOL_TCP,
+            ports=[80],
+            description='Hypertext transfer protocol'
+        )
+
+        request = {
+            'path': self._get_url('add'),
+            'data': {
+                'device': device.pk,
+                'service_template': service_template.pk,
+            },
+        }
+        self.assertHttpStatus(self.client.post(**request), 302)
+        instance = self._get_queryset().order_by('pk').last()
+        self.assertEqual(instance.device, device)
+        self.assertEqual(instance.name, service_template.name)
+        self.assertEqual(instance.protocol, service_template.protocol)
+        self.assertEqual(instance.ports, service_template.ports)
+        self.assertEqual(instance.description, service_template.description)

+ 13 - 1
netbox/ipam/urls.py

@@ -162,9 +162,21 @@ 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'),
+    path('services/add/', views.ServiceCreateView.as_view(), name='service_add'),
     path('services/import/', views.ServiceBulkImportView.as_view(), name='service_import'),
     path('services/edit/', views.ServiceBulkEditView.as_view(), name='service_bulk_edit'),
     path('services/delete/', views.ServiceBulkDeleteView.as_view(), name='service_bulk_delete'),

+ 53 - 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
 #
@@ -1044,20 +1087,26 @@ class ServiceView(generic.ObjectView):
     queryset = Service.objects.prefetch_related('ipaddresses')
 
 
+class ServiceCreateView(generic.ObjectEditView):
+    queryset = Service.objects.all()
+    model_form = forms.ServiceCreateForm
+    template_name = 'ipam/service_create.html'
+
+
 class ServiceEditView(generic.ObjectEditView):
     queryset = Service.objects.prefetch_related('ipaddresses')
     model_form = forms.ServiceForm
     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'),
             ),
         ),

+ 74 - 0
netbox/templates/ipam/service_create.html

@@ -0,0 +1,74 @@
+{% extends 'generic/object_edit.html' %}
+{% load form_helpers %}
+
+{% block form %}
+  <div class="field-group my-5">
+    <div class="row mb-2">
+      <h5 class="offset-sm-3">Service</h5>
+    </div>
+
+    {# Device/VM selection #}
+    <div class="row mb-2">
+      <div class="offset-sm-3">
+        <ul class="nav nav-pills" role="tablist">
+          <li role="presentation" class="nav-item">
+            <button role="tab" type="button" id="device_tab" data-bs-toggle="tab" aria-controls="device" data-bs-target="#device" class="nav-link {% if not form.initial.virtual_machine %}active{% endif %}">
+              Device
+            </button>
+          </li>
+          <li role="presentation" class="nav-item">
+            <button role="tab" type="button" id="vm_tab" data-bs-toggle="tab" aria-controls="vm" data-bs-target="#vm" class="nav-link {% if form.initial.virtual_machine %}active{% endif %}">
+              Virtual Machine
+            </button>
+          </li>
+        </ul>
+      </div>
+    </div>
+    <div class="tab-content p-0 border-0">
+      <div class="tab-pane {% if not form.initial.virtual_machine %}active{% endif %}" id="device" role="tabpanel" aria-labeled-by="device_tab">
+        {% render_field form.device %}
+      </div>
+      <div class="tab-pane {% if form.initial.virtual_machine %}active{% endif %}" id="vm" role="tabpanel" aria-labeled-by="vm_tab">
+        {% render_field form.virtual_machine %}
+      </div>
+    </div>
+
+    {# Template or custom #}
+    <div class="row mb-2">
+      <div class="offset-sm-3">
+        <ul class="nav nav-pills" role="tablist">
+          <li role="presentation" class="nav-item">
+            <button role="tab" type="button" id="template_tab" data-bs-toggle="tab" data-bs-target="#template" class="nav-link active">
+              From Template
+            </button>
+          </li>
+          <li role="presentation" class="nav-item">
+            <button role="tab" type="button" id="custom_tab" data-bs-toggle="tab" data-bs-target="#custom" class="nav-link">
+              Custom
+            </button>
+          </li>
+        </ul>
+      </div>
+    </div>
+    <div class="tab-content p-0 border-0">
+      <div class="tab-pane active" id="template" role="tabpanel" aria-labeled-by="template_tab">
+        {% render_field form.service_template %}
+      </div>
+      <div class="tab-pane" id="custom" role="tabpanel" aria-labeled-by="custom_tab">
+        {% render_field form.name %}
+        {% render_field form.protocol %}
+        {% render_field form.ports %}
+      </div>
+    </div>
+    {% render_field form.ipaddresses %}
+    {% render_field form.description %}
+    {% render_field form.tags %}
+  </div>
+
+  {% if form.custom_fields %}
+    <div class="row mb-2">
+      <h5 class="offset-sm-3">Custom Fields</h5>
+    </div>
+    {% render_custom_fields form %}
+  {% endif %}
+{% endblock %}

+ 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 %}