diff --git a/docs/core-functionality/services.md b/docs/core-functionality/services.md index 2e7aaf65a..316c7fe00 100644 --- a/docs/core-functionality/services.md +++ b/docs/core-functionality/services.md @@ -1,3 +1,4 @@ # Service Mapping +{!models/ipam/servicetemplate.md!} {!models/ipam/service.md!} diff --git a/docs/models/ipam/servicetemplate.md b/docs/models/ipam/servicetemplate.md new file mode 100644 index 000000000..7fed40211 --- /dev/null +++ b/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. diff --git a/netbox/ipam/api/nested_serializers.py b/netbox/ipam/api/nested_serializers.py index 1eb66743b..5f9e09049 100644 --- a/netbox/ipam/api/nested_serializers.py +++ b/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') diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index c028a3d5d..f71d3958a 100644 --- a/netbox/ipam/api/serializers.py +++ b/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) diff --git a/netbox/ipam/api/urls.py b/netbox/ipam/api/urls.py index 3d69e258e..8a68db9be 100644 --- a/netbox/ipam/api/urls.py +++ b/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' diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index de415cd81..357937855 100644 --- a/netbox/ipam/api/views.py +++ b/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' ) diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 8a10a7b24..52e4499c7 100644 --- a/netbox/ipam/filtersets.py +++ b/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', diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py index 1e25a1090..308a467d1 100644 --- a/netbox/ipam/forms/bulk_edit.py +++ b/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() + ) diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py index a4fdaa3ae..1ae977fe5 100644 --- a/netbox/ipam/forms/bulk_import.py +++ b/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(), diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py index df95bdd05..9bfb1df10 100644 --- a/netbox/ipam/forms/filtersets.py +++ b/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 diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/models.py index cef877245..9aabd4d54 100644 --- a/netbox/ipam/forms/models.py +++ b/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(), diff --git a/netbox/ipam/graphql/schema.py b/netbox/ipam/graphql/schema.py index 9609d1434..f466c1857 100644 --- a/netbox/ipam/graphql/schema.py +++ b/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) diff --git a/netbox/ipam/graphql/types.py b/netbox/ipam/graphql/types.py index d9aec66b3..8dd122a0c 100644 --- a/netbox/ipam/graphql/types.py +++ b/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: diff --git a/netbox/ipam/migrations/0055_servicetemplate.py b/netbox/ipam/migrations/0055_servicetemplate.py new file mode 100644 index 000000000..738317907 --- /dev/null +++ b/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',), + }, + ), + ] diff --git a/netbox/ipam/models/__init__.py b/netbox/ipam/models/__init__.py index ab0e4b6ca..1857b7d66 100644 --- a/netbox/ipam/models/__init__.py +++ b/netbox/ipam/models/__init__.py @@ -16,6 +16,7 @@ __all__ = ( 'Role', 'RouteTarget', 'Service', + 'ServiceTemplate', 'VLAN', 'VLANGroup', 'VRF', diff --git a/netbox/ipam/models/services.py b/netbox/ipam/models/services.py index 5c1ebb9dd..43f8353bc 100644 --- a/netbox/ipam/models/services.py +++ b/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) diff --git a/netbox/ipam/tables/services.py b/netbox/ipam/tables/services.py index ff6b766f7..783cb3537 100644 --- a/netbox/ipam/tables/services.py +++ b/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' diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index dfbf1a971..d99de6d20 100644 --- a/netbox/ipam/tests/test_api.py +++ b/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'] diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index 773737dea..d673628af 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/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 diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index 80088eb73..928a8b1c8 100644 --- a/netbox/ipam/tests/test_views.py +++ b/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 diff --git a/netbox/ipam/urls.py b/netbox/ipam/urls.py index a9f420253..fe8cfd150 100644 --- a/netbox/ipam/urls.py +++ b/netbox/ipam/urls.py @@ -162,6 +162,18 @@ urlpatterns = [ path('vlans//changelog/', ObjectChangeLogView.as_view(), name='vlan_changelog', kwargs={'model': VLAN}), path('vlans//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//', views.ServiceTemplateView.as_view(), name='servicetemplate'), + path('service-templates//edit/', views.ServiceTemplateEditView.as_view(), name='servicetemplate_edit'), + path('service-templates//delete/', views.ServiceTemplateDeleteView.as_view(), name='servicetemplate_delete'), + path('service-templates//changelog/', ObjectChangeLogView.as_view(), name='servicetemplate_changelog', kwargs={'model': ServiceTemplate}), + path('service-templates//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'), diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 39706e9cc..f5aa0a7d7 100644 --- a/netbox/ipam/views.py +++ b/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,16 +1093,16 @@ class ServiceEditView(generic.ObjectEditView): template_name = 'ipam/service_edit.html' +class ServiceDeleteView(generic.ObjectDeleteView): + queryset = Service.objects.all() + + class ServiceBulkImportView(generic.BulkImportView): queryset = Service.objects.all() model_form = forms.ServiceCSVForm table = tables.ServiceTable -class ServiceDeleteView(generic.ObjectDeleteView): - queryset = Service.objects.all() - - class ServiceBulkEditView(generic.BulkEditView): queryset = Service.objects.prefetch_related('device', 'virtual_machine') filterset = filtersets.ServiceFilterSet diff --git a/netbox/netbox/navigation_menu.py b/netbox/netbox/navigation_menu.py index dc83d02f9..85d86a47a 100644 --- a/netbox/netbox/navigation_menu.py +++ b/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'), ), ), diff --git a/netbox/templates/ipam/servicetemplate.html b/netbox/templates/ipam/servicetemplate.html new file mode 100644 index 000000000..6e2aacb34 --- /dev/null +++ b/netbox/templates/ipam/servicetemplate.html @@ -0,0 +1,46 @@ +{% extends 'generic/object.html' %} +{% load buttons %} +{% load helpers %} +{% load perms %} +{% load plugins %} + +{% block content %} +
+
+
+
Service Template
+
+ + + + + + + + + + + + + + + + + +
Name{{ object.name }}
Protocol{{ object.get_protocol_display }}
Ports{{ object.port_list }}
Description{{ object.description|placeholder }}
+
+
+ {% plugin_left_page object %} +
+
+ {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} + {% plugin_right_page object %} +
+
+
+
+ {% plugin_full_width_page object %} +
+
+{% endblock %}