Просмотр исходного кода

Add dedicated views for organizational models

Jeremy Stretch 4 лет назад
Родитель
Сommit
b7e44a744d

+ 1 - 1
netbox/circuits/models.py

@@ -175,7 +175,7 @@ class CircuitType(OrganizationalModel):
         return self.name
 
     def get_absolute_url(self):
-        return "{}?type={}".format(reverse('circuits:circuit_list'), self.slug)
+        return reverse('circuits:circuittype', args=[self.pk])
 
     def to_csv(self):
         return (

+ 1 - 0
netbox/circuits/urls.py

@@ -38,6 +38,7 @@ urlpatterns = [
     path('circuit-types/import/', views.CircuitTypeBulkImportView.as_view(), name='circuittype_import'),
     path('circuit-types/edit/', views.CircuitTypeBulkEditView.as_view(), name='circuittype_bulk_edit'),
     path('circuit-types/delete/', views.CircuitTypeBulkDeleteView.as_view(), name='circuittype_bulk_delete'),
+    path('circuit-types/<int:pk>/', views.CircuitTypeView.as_view(), name='circuittype'),
     path('circuit-types/<int:pk>/edit/', views.CircuitTypeEditView.as_view(), name='circuittype_edit'),
     path('circuit-types/<int:pk>/delete/', views.CircuitTypeDeleteView.as_view(), name='circuittype_delete'),
     path('circuit-types/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='circuittype_changelog', kwargs={'model': CircuitType}),

+ 17 - 0
netbox/circuits/views.py

@@ -147,6 +147,23 @@ class CircuitTypeListView(generic.ObjectListView):
     table = tables.CircuitTypeTable
 
 
+class CircuitTypeView(generic.ObjectView):
+    queryset = CircuitType.objects.all()
+
+    def get_extra_context(self, request, instance):
+        circuits = Circuit.objects.restrict(request.user, 'view').filter(
+            type=instance
+        )
+
+        circuits_table = tables.CircuitTable(circuits)
+        circuits_table.columns.hide('type')
+        paginate_table(circuits_table, request)
+
+        return {
+            'circuits_table': circuits_table,
+        }
+
+
 class CircuitTypeEditView(generic.ObjectEditView):
     queryset = CircuitType.objects.all()
     model_form = forms.CircuitTypeForm

+ 5 - 2
netbox/dcim/models/devices.py

@@ -65,7 +65,7 @@ class Manufacturer(OrganizationalModel):
         return self.name
 
     def get_absolute_url(self):
-        return "{}?manufacturer={}".format(reverse('dcim:devicetype_list'), self.slug)
+        return reverse('dcim:manufacturer', args=[self.pk])
 
     def to_csv(self):
         return (
@@ -375,6 +375,9 @@ class DeviceRole(OrganizationalModel):
     def __str__(self):
         return self.name
 
+    def get_absolute_url(self):
+        return reverse('dcim:devicerole', args=[self.pk])
+
     def to_csv(self):
         return (
             self.name,
@@ -436,7 +439,7 @@ class Platform(OrganizationalModel):
         return self.name
 
     def get_absolute_url(self):
-        return "{}?platform={}".format(reverse('dcim:device_list'), self.slug)
+        return reverse('dcim:platform', args=[self.pk])
 
     def to_csv(self):
         return (

+ 1 - 1
netbox/dcim/models/racks.py

@@ -67,7 +67,7 @@ class RackRole(OrganizationalModel):
         return self.name
 
     def get_absolute_url(self):
-        return "{}?role={}".format(reverse('dcim:rack_list'), self.slug)
+        return reverse('dcim:rackrole', args=[self.pk])
 
     def to_csv(self):
         return (

+ 6 - 0
netbox/dcim/tables/devices.py

@@ -50,6 +50,9 @@ __all__ = (
 
 class DeviceRoleTable(BaseTable):
     pk = ToggleColumn()
+    name = tables.Column(
+        linkify=True
+    )
     device_count = LinkedCountColumn(
         viewname='dcim:device_list',
         url_params={'role': 'slug'},
@@ -76,6 +79,9 @@ class DeviceRoleTable(BaseTable):
 
 class PlatformTable(BaseTable):
     pk = ToggleColumn()
+    name = tables.Column(
+        linkify=True
+    )
     device_count = LinkedCountColumn(
         viewname='dcim:device_list',
         url_params={'platform': 'slug'},

+ 4 - 0
netbox/dcim/urls.py

@@ -57,6 +57,7 @@ urlpatterns = [
     path('rack-roles/import/', views.RackRoleBulkImportView.as_view(), name='rackrole_import'),
     path('rack-roles/edit/', views.RackRoleBulkEditView.as_view(), name='rackrole_bulk_edit'),
     path('rack-roles/delete/', views.RackRoleBulkDeleteView.as_view(), name='rackrole_bulk_delete'),
+    path('rack-roles/<int:pk>/', views.RackRoleView.as_view(), name='rackrole'),
     path('rack-roles/<int:pk>/edit/', views.RackRoleEditView.as_view(), name='rackrole_edit'),
     path('rack-roles/<int:pk>/delete/', views.RackRoleDeleteView.as_view(), name='rackrole_delete'),
     path('rack-roles/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rackrole_changelog', kwargs={'model': RackRole}),
@@ -93,6 +94,7 @@ urlpatterns = [
     path('manufacturers/import/', views.ManufacturerBulkImportView.as_view(), name='manufacturer_import'),
     path('manufacturers/edit/', views.ManufacturerBulkEditView.as_view(), name='manufacturer_bulk_edit'),
     path('manufacturers/delete/', views.ManufacturerBulkDeleteView.as_view(), name='manufacturer_bulk_delete'),
+    path('manufacturers/<int:pk>/', views.ManufacturerView.as_view(), name='manufacturer'),
     path('manufacturers/<int:pk>/edit/', views.ManufacturerEditView.as_view(), name='manufacturer_edit'),
     path('manufacturers/<int:pk>/delete/', views.ManufacturerDeleteView.as_view(), name='manufacturer_delete'),
     path('manufacturers/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='manufacturer_changelog', kwargs={'model': Manufacturer}),
@@ -179,6 +181,7 @@ urlpatterns = [
     path('device-roles/import/', views.DeviceRoleBulkImportView.as_view(), name='devicerole_import'),
     path('device-roles/edit/', views.DeviceRoleBulkEditView.as_view(), name='devicerole_bulk_edit'),
     path('device-roles/delete/', views.DeviceRoleBulkDeleteView.as_view(), name='devicerole_bulk_delete'),
+    path('device-roles/<int:pk>/', views.DeviceRoleView.as_view(), name='devicerole'),
     path('device-roles/<int:pk>/edit/', views.DeviceRoleEditView.as_view(), name='devicerole_edit'),
     path('device-roles/<int:pk>/delete/', views.DeviceRoleDeleteView.as_view(), name='devicerole_delete'),
     path('device-roles/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='devicerole_changelog', kwargs={'model': DeviceRole}),
@@ -189,6 +192,7 @@ urlpatterns = [
     path('platforms/import/', views.PlatformBulkImportView.as_view(), name='platform_import'),
     path('platforms/edit/', views.PlatformBulkEditView.as_view(), name='platform_bulk_edit'),
     path('platforms/delete/', views.PlatformBulkDeleteView.as_view(), name='platform_bulk_delete'),
+    path('platforms/<int:pk>/', views.PlatformView.as_view(), name='platform'),
     path('platforms/<int:pk>/edit/', views.PlatformEditView.as_view(), name='platform_edit'),
     path('platforms/<int:pk>/delete/', views.PlatformDeleteView.as_view(), name='platform_delete'),
     path('platforms/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='platform_changelog', kwargs={'model': Platform}),

+ 69 - 0
netbox/dcim/views.py

@@ -20,6 +20,7 @@ from secrets.models import Secret
 from utilities.forms import ConfirmationForm
 from utilities.paginator import EnhancedPaginator, get_paginate_count
 from utilities.permissions import get_permission_for_model
+from utilities.tables import paginate_table
 from utilities.utils import csv_format, count_related
 from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin
 from virtualization.models import VirtualMachine
@@ -341,6 +342,23 @@ class RackRoleListView(generic.ObjectListView):
     table = tables.RackRoleTable
 
 
+class RackRoleView(generic.ObjectView):
+    queryset = RackRole.objects.all()
+
+    def get_extra_context(self, request, instance):
+        racks = Rack.objects.restrict(request.user, 'view').filter(
+            role=instance
+        )
+
+        racks_table = tables.RackTable(racks)
+        racks_table.columns.hide('role')
+        paginate_table(racks_table, request)
+
+        return {
+            'racks_table': racks_table,
+        }
+
+
 class RackRoleEditView(generic.ObjectEditView):
     queryset = RackRole.objects.all()
     model_form = forms.RackRoleForm
@@ -567,6 +585,23 @@ class ManufacturerListView(generic.ObjectListView):
     table = tables.ManufacturerTable
 
 
+class ManufacturerView(generic.ObjectView):
+    queryset = Manufacturer.objects.all()
+
+    def get_extra_context(self, request, instance):
+        devicetypes = DeviceType.objects.restrict(request.user, 'view').filter(
+            manufacturer=instance
+        )
+
+        devicetypes_table = tables.DeviceTypeTable(devicetypes)
+        devicetypes_table.columns.hide('manufacturer')
+        paginate_table(devicetypes_table, request)
+
+        return {
+            'devicetypes_table': devicetypes_table,
+        }
+
+
 class ManufacturerEditView(generic.ObjectEditView):
     queryset = Manufacturer.objects.all()
     model_form = forms.ManufacturerForm
@@ -1017,6 +1052,23 @@ class DeviceRoleListView(generic.ObjectListView):
     table = tables.DeviceRoleTable
 
 
+class DeviceRoleView(generic.ObjectView):
+    queryset = DeviceRole.objects.all()
+
+    def get_extra_context(self, request, instance):
+        devices = Device.objects.restrict(request.user, 'view').filter(
+            device_role=instance
+        )
+
+        devices_table = tables.DeviceTable(devices)
+        devices_table.columns.hide('device_role')
+        paginate_table(devices_table, request)
+
+        return {
+            'devices_table': devices_table,
+        }
+
+
 class DeviceRoleEditView(generic.ObjectEditView):
     queryset = DeviceRole.objects.all()
     model_form = forms.DeviceRoleForm
@@ -1056,6 +1108,23 @@ class PlatformListView(generic.ObjectListView):
     table = tables.PlatformTable
 
 
+class PlatformView(generic.ObjectView):
+    queryset = Platform.objects.all()
+
+    def get_extra_context(self, request, instance):
+        devices = Device.objects.restrict(request.user, 'view').filter(
+            platform=instance
+        )
+
+        devices_table = tables.DeviceTable(devices)
+        devices_table.columns.hide('platform')
+        paginate_table(devices_table, request)
+
+        return {
+            'devices_table': devices_table,
+        }
+
+
 class PlatformEditView(generic.ObjectEditView):
     queryset = Platform.objects.all()
     model_form = forms.PlatformForm

+ 4 - 1
netbox/ipam/models/ip.py

@@ -66,7 +66,7 @@ class RIR(OrganizationalModel):
         return self.name
 
     def get_absolute_url(self):
-        return "{}?rir={}".format(reverse('ipam:aggregate_list'), self.slug)
+        return reverse('ipam:rir', args=[self.pk])
 
     def to_csv(self):
         return (
@@ -216,6 +216,9 @@ class Role(OrganizationalModel):
     def __str__(self):
         return self.name
 
+    def get_absolute_url(self):
+        return reverse('ipam:role', args=[self.pk])
+
     def to_csv(self):
         return (
             self.name,

+ 1 - 1
netbox/ipam/models/vlans.py

@@ -70,7 +70,7 @@ class VLANGroup(OrganizationalModel):
         return self.name
 
     def get_absolute_url(self):
-        return reverse('ipam:vlangroup_vlans', args=[self.pk])
+        return reverse('ipam:vlangroup', args=[self.pk])
 
     def clean(self):
         super().clean()

+ 5 - 3
netbox/ipam/tables.py

@@ -224,6 +224,9 @@ class AggregateDetailTable(AggregateTable):
 
 class RoleTable(BaseTable):
     pk = ToggleColumn()
+    name = tables.Column(
+        linkify=True
+    )
     prefix_count = LinkedCountColumn(
         viewname='ipam:prefix_list',
         url_params={'role': 'slug'},
@@ -450,9 +453,8 @@ class VLANTable(BaseTable):
     site = tables.Column(
         linkify=True
     )
-    group = tables.LinkColumn(
-        viewname='ipam:vlangroup_vlans',
-        args=[Accessor('group__pk')]
+    group = tables.Column(
+        linkify=True
     )
     tenant = TenantColumn()
     status = ChoiceFieldColumn(

+ 3 - 1
netbox/ipam/urls.py

@@ -37,6 +37,7 @@ urlpatterns = [
     path('rirs/import/', views.RIRBulkImportView.as_view(), name='rir_import'),
     path('rirs/edit/', views.RIRBulkEditView.as_view(), name='rir_bulk_edit'),
     path('rirs/delete/', views.RIRBulkDeleteView.as_view(), name='rir_bulk_delete'),
+    path('rirs/<int:pk>/', views.RIRView.as_view(), name='rir'),
     path('rirs/<int:pk>/edit/', views.RIREditView.as_view(), name='rir_edit'),
     path('rirs/<int:pk>/delete/', views.RIRDeleteView.as_view(), name='rir_delete'),
     path('rirs/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rir_changelog', kwargs={'model': RIR}),
@@ -59,6 +60,7 @@ urlpatterns = [
     path('roles/import/', views.RoleBulkImportView.as_view(), name='role_import'),
     path('roles/edit/', views.RoleBulkEditView.as_view(), name='role_bulk_edit'),
     path('roles/delete/', views.RoleBulkDeleteView.as_view(), name='role_bulk_delete'),
+    path('roles/<int:pk>/', views.RoleView.as_view(), name='role'),
     path('roles/<int:pk>/edit/', views.RoleEditView.as_view(), name='role_edit'),
     path('roles/<int:pk>/delete/', views.RoleDeleteView.as_view(), name='role_delete'),
     path('roles/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='role_changelog', kwargs={'model': Role}),
@@ -97,9 +99,9 @@ urlpatterns = [
     path('vlan-groups/import/', views.VLANGroupBulkImportView.as_view(), name='vlangroup_import'),
     path('vlan-groups/edit/', views.VLANGroupBulkEditView.as_view(), name='vlangroup_bulk_edit'),
     path('vlan-groups/delete/', views.VLANGroupBulkDeleteView.as_view(), name='vlangroup_bulk_delete'),
+    path('vlan-groups/<int:pk>/', views.VLANGroupView.as_view(), name='vlangroup'),
     path('vlan-groups/<int:pk>/edit/', views.VLANGroupEditView.as_view(), name='vlangroup_edit'),
     path('vlan-groups/<int:pk>/delete/', views.VLANGroupDeleteView.as_view(), name='vlangroup_delete'),
-    path('vlan-groups/<int:pk>/vlans/', views.VLANGroupVLANsView.as_view(), name='vlangroup_vlans'),
     path('vlan-groups/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='vlangroup_changelog', kwargs={'model': VLANGroup}),
 
     # VLANs

+ 57 - 32
netbox/ipam/views.py

@@ -148,6 +148,23 @@ class RIRListView(generic.ObjectListView):
     template_name = 'ipam/rir_list.html'
 
 
+class RIRView(generic.ObjectView):
+    queryset = RIR.objects.all()
+
+    def get_extra_context(self, request, instance):
+        aggregates = Aggregate.objects.restrict(request.user, 'view').filter(
+            rir=instance
+        )
+
+        aggregates_table = tables.AggregateTable(aggregates)
+        aggregates_table.columns.hide('rir')
+        paginate_table(aggregates_table, request)
+
+        return {
+            'aggregates_table': aggregates_table,
+        }
+
+
 class RIREditView(generic.ObjectEditView):
     queryset = RIR.objects.all()
     model_form = forms.RIRForm
@@ -286,6 +303,23 @@ class RoleListView(generic.ObjectListView):
     table = tables.RoleTable
 
 
+class RoleView(generic.ObjectView):
+    queryset = Role.objects.all()
+
+    def get_extra_context(self, request, instance):
+        prefixes = Prefix.objects.restrict(request.user, 'view').filter(
+            role=instance
+        )
+
+        prefixes_table = tables.PrefixTable(prefixes)
+        prefixes_table.columns.hide('role')
+        paginate_table(prefixes_table, request)
+
+        return {
+            'prefixes_table': prefixes_table,
+        }
+
+
 class RoleEditView(generic.ObjectEditView):
     queryset = Role.objects.all()
     model_form = forms.RoleForm
@@ -633,6 +667,29 @@ class VLANGroupListView(generic.ObjectListView):
     table = tables.VLANGroupTable
 
 
+class VLANGroupView(generic.ObjectView):
+    queryset = VLANGroup.objects.all()
+
+    def get_extra_context(self, request, instance):
+        vlans = VLAN.objects.restrict(request.user, 'view').filter(group=instance).prefetch_related(
+            Prefetch('prefixes', queryset=Prefix.objects.restrict(request.user))
+        )
+        vlans_count = vlans.count()
+        vlans = add_available_vlans(instance, vlans)
+
+        vlans_table = tables.VLANDetailTable(vlans)
+        if request.user.has_perm('ipam.change_vlan') or request.user.has_perm('ipam.delete_vlan'):
+            vlans_table.columns.show('pk')
+        vlans_table.columns.hide('site')
+        vlans_table.columns.hide('group')
+        paginate_table(vlans_table, request)
+
+        return {
+            'vlans_count': vlans_count,
+            'vlans_table': vlans_table,
+        }
+
+
 class VLANGroupEditView(generic.ObjectEditView):
     queryset = VLANGroup.objects.all()
     model_form = forms.VLANGroupForm
@@ -666,38 +723,6 @@ class VLANGroupBulkDeleteView(generic.BulkDeleteView):
     table = tables.VLANGroupTable
 
 
-class VLANGroupVLANsView(generic.ObjectView):
-    queryset = VLANGroup.objects.all()
-    template_name = 'ipam/vlangroup_vlans.html'
-
-    def get_extra_context(self, request, instance):
-        vlans = VLAN.objects.restrict(request.user, 'view').filter(group=instance).prefetch_related(
-            Prefetch('prefixes', queryset=Prefix.objects.restrict(request.user))
-        )
-        vlans = add_available_vlans(instance, vlans)
-
-        vlan_table = tables.VLANDetailTable(vlans)
-        if request.user.has_perm('ipam.change_vlan') or request.user.has_perm('ipam.delete_vlan'):
-            vlan_table.columns.show('pk')
-        vlan_table.columns.hide('site')
-        vlan_table.columns.hide('group')
-        paginate_table(vlan_table, request)
-
-        # Compile permissions list for rendering the object table
-        permissions = {
-            'add': request.user.has_perm('ipam.add_vlan'),
-            'change': request.user.has_perm('ipam.change_vlan'),
-            'delete': request.user.has_perm('ipam.delete_vlan'),
-        }
-
-        return {
-            'first_available_vlan': instance.get_next_available_vid(),
-            'bulk_querystring': f'group_id={instance.pk}',
-            'vlan_table': vlan_table,
-            'permissions': permissions,
-        }
-
-
 #
 # VLANs
 #

+ 1 - 1
netbox/secrets/models.py

@@ -263,7 +263,7 @@ class SecretRole(OrganizationalModel):
         return self.name
 
     def get_absolute_url(self):
-        return "{}?role={}".format(reverse('secrets:secret_list'), self.slug)
+        return reverse('secrets:secretrole', args=[self.pk])
 
     def to_csv(self):
         return (

+ 1 - 0
netbox/secrets/urls.py

@@ -13,6 +13,7 @@ urlpatterns = [
     path('secret-roles/import/', views.SecretRoleBulkImportView.as_view(), name='secretrole_import'),
     path('secret-roles/edit/', views.SecretRoleBulkEditView.as_view(), name='secretrole_bulk_edit'),
     path('secret-roles/delete/', views.SecretRoleBulkDeleteView.as_view(), name='secretrole_bulk_delete'),
+    path('secret-roles/<int:pk>/', views.SecretRoleView.as_view(), name='secretrole'),
     path('secret-roles/<int:pk>/edit/', views.SecretRoleEditView.as_view(), name='secretrole_edit'),
     path('secret-roles/<int:pk>/delete/', views.SecretRoleDeleteView.as_view(), name='secretrole_delete'),
     path('secret-roles/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='secretrole_changelog', kwargs={'model': SecretRole}),

+ 18 - 0
netbox/secrets/views.py

@@ -7,6 +7,7 @@ from django.utils.html import escape
 from django.utils.safestring import mark_safe
 
 from netbox.views import generic
+from utilities.tables import paginate_table
 from utilities.utils import count_related
 from . import filters, forms, tables
 from .models import SecretRole, Secret, SessionKey, UserKey
@@ -33,6 +34,23 @@ class SecretRoleListView(generic.ObjectListView):
     table = tables.SecretRoleTable
 
 
+class SecretRoleView(generic.ObjectView):
+    queryset = SecretRole.objects.all()
+
+    def get_extra_context(self, request, instance):
+        secrets = Secret.objects.restrict(request.user, 'view').filter(
+            role=instance
+        )
+
+        secrets_table = tables.SecretTable(secrets)
+        secrets_table.columns.hide('role')
+        paginate_table(secrets_table, request)
+
+        return {
+            'secrets_table': secrets_table,
+        }
+
+
 class SecretRoleEditView(generic.ObjectEditView):
     queryset = SecretRole.objects.all()
     model_form = forms.SecretRoleForm

+ 60 - 0
netbox/templates/circuits/circuittype.html

@@ -0,0 +1,60 @@
+{% extends 'generic/object.html' %}
+{% load helpers %}
+{% load plugins %}
+
+{% block breadcrumbs %}
+  <li><a href="{% url 'circuits:circuittype_list' %}">Circuit Types</a></li>
+  <li>{{ object }}</li>
+{% endblock %}
+
+{% block content %}
+<div class="row">
+	<div class="col-md-6">
+    <div class="panel panel-default">
+      <div class="panel-heading">
+        <strong>Circuit Type</strong>
+      </div>
+      <table class="table table-hover panel-body attr-table">
+        <tr>
+          <td>Name</td>
+          <td>{{ object.name }}</td>
+        </tr>
+        <tr>
+          <td>Description</td>
+          <td>{{ object.description|placeholder }}</td>
+        </tr>
+        <tr>
+          <td>Circuits</td>
+          <td>
+            <a href="{% url 'circuits:circuit_list' %}?type_id={{ object.pk }}">{{ circuits_table.rows|length }}</a>
+          </td>
+        </tr>
+      </table>
+    </div>
+    {% plugin_left_page object %}
+  </div>
+	<div class="col-md-6">
+    {% include 'inc/custom_fields_panel.html' %}
+    {% plugin_right_page object %}
+	</div>
+</div>
+<div class="row">
+	<div class="col-md-12">
+    <div class="panel panel-default">
+      <div class="panel-heading">
+        <strong>Circuits</strong>
+      </div>
+      {% include 'inc/table.html' with table=circuits_table %}
+      {% if perms.circuits.add_circuit %}
+        <div class="panel-footer text-right noprint">
+          <a href="{% url 'circuits:circuit_add' %}?type={{ object.pk }}" class="btn btn-xs btn-primary">
+            <span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add circuit
+          </a>
+        </div>
+      {% endif %}
+      </div>
+      {% include 'inc/paginator.html' with paginator=circuits_table.paginator page=circuits_table.page %}
+      {% plugin_full_width_page object %}
+  </div>
+</div>
+{% endblock %}

+ 76 - 0
netbox/templates/dcim/devicerole.html

@@ -0,0 +1,76 @@
+{% extends 'generic/object.html' %}
+{% load helpers %}
+{% load plugins %}
+
+{% block breadcrumbs %}
+  <li><a href="{% url 'dcim:devicerole_list' %}">Device Roles</a></li>
+  <li>{{ object }}</li>
+{% endblock %}
+
+{% block content %}
+<div class="row">
+	<div class="col-md-6">
+    <div class="panel panel-default">
+      <div class="panel-heading">
+        <strong>Device Role</strong>
+      </div>
+      <table class="table table-hover panel-body attr-table">
+        <tr>
+          <td>Name</td>
+          <td>{{ object.name }}</td>
+        </tr>
+        <tr>
+          <td>Description</td>
+          <td>{{ object.description|placeholder }}</td>
+        </tr>
+        <tr>
+          <td>Color</td>
+          <td>
+            <span class="label color-block" style="background-color: #{{ object.color }}">&nbsp;</span>
+          </td>
+        </tr>
+        <tr>
+          <td>VM Role</td>
+          <td>
+            {% if object.vm_role %}
+              <i class="mdi mdi-check-bold text-success" title="Yes"></i>
+            {% else %}
+              <i class="mdi mdi-close-thick text-danger" title="No"></i>
+            {% endif %}
+          </td>
+        </tr>
+        <tr>
+          <td>Devices</td>
+          <td>
+            <a href="{% url 'dcim:device_list' %}?role_id={{ object.pk }}">{{ devices_table.rows|length }}</a>
+          </td>
+        </tr>
+      </table>
+    </div>
+    {% plugin_left_page object %}
+	</div>
+	<div class="col-md-6">
+    {% include 'inc/custom_fields_panel.html' %}
+    {% plugin_right_page object %}
+  </div>
+</div>
+<div class="row">
+	<div class="col-md-12">
+    <div class="panel panel-default">
+      <div class="panel-heading">
+        <strong>Devices</strong>
+      </div>
+      {% include 'inc/table.html' with table=devices_table %}
+      {% if perms.dcim.add_device %}
+        <div class="panel-footer text-right noprint">
+          <a href="{% url 'dcim:device_add' %}?device_role={{ object.pk }}" class="btn btn-xs btn-primary">
+            <span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add device
+          </a>
+        </div>
+      {% endif %}
+    </div>
+    {% include 'inc/paginator.html' with paginator=devices_table.paginator page=devices_table.page %}
+    {% plugin_full_width_page object %}
+  </div>
+</div>
+{% endblock %}

+ 60 - 0
netbox/templates/dcim/manufacturer.html

@@ -0,0 +1,60 @@
+{% extends 'generic/object.html' %}
+{% load helpers %}
+{% load plugins %}
+
+{% block breadcrumbs %}
+  <li><a href="{% url 'dcim:manufacturer_list' %}">Manufacturers</a></li>
+  <li>{{ object }}</li>
+{% endblock %}
+
+{% block content %}
+<div class="row">
+	<div class="col-md-6">
+    <div class="panel panel-default">
+      <div class="panel-heading">
+        <strong>Manufacturer</strong>
+      </div>
+      <table class="table table-hover panel-body attr-table">
+        <tr>
+          <td>Name</td>
+          <td>{{ object.name }}</td>
+        </tr>
+        <tr>
+          <td>Description</td>
+          <td>{{ object.description|placeholder }}</td>
+        </tr>
+        <tr>
+          <td>Device types</td>
+          <td>
+            <a href="{% url 'dcim:devicetype_list' %}?manufacturer_id={{ object.pk }}">{{ devicetypes_table.rows|length }}</a>
+          </td>
+        </tr>
+      </table>
+    </div>
+    {% plugin_left_page object %}
+	</div>
+	<div class="col-md-6">
+    {% include 'inc/custom_fields_panel.html' %}
+    {% plugin_right_page object %}
+  </div>
+</div>
+<div class="row">
+	<div class="col-md-12">
+    <div class="panel panel-default">
+      <div class="panel-heading">
+        <strong>Device Types</strong>
+      </div>
+      {% include 'inc/table.html' with table=devicetypes_table %}
+      {% if perms.dcim.add_devicetype %}
+        <div class="panel-footer text-right noprint">
+          <a href="{% url 'dcim:devicetype_add' %}?manufacturer={{ object.pk }}" class="btn btn-xs btn-primary">
+            <span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add device type
+          </a>
+        </div>
+      {% endif %}
+    </div>
+    {% include 'inc/paginator.html' with paginator=devicetypes_table.paginator page=devicetypes_table.page %}
+    {% plugin_full_width_page object %}
+  </div>
+</div>
+{% endblock %}

+ 68 - 0
netbox/templates/dcim/platform.html

@@ -0,0 +1,68 @@
+{% extends 'generic/object.html' %}
+{% load helpers %}
+{% load plugins %}
+
+{% block breadcrumbs %}
+  <li><a href="{% url 'dcim:platform_list' %}">Platforms</a></li>
+  <li>{{ object }}</li>
+{% endblock %}
+
+{% block content %}
+<div class="row">
+	<div class="col-md-6">
+    <div class="panel panel-default">
+      <div class="panel-heading">
+        <strong>Platform</strong>
+      </div>
+      <table class="table table-hover panel-body attr-table">
+        <tr>
+          <td>Name</td>
+          <td>{{ object.name }}</td>
+        </tr>
+        <tr>
+          <td>Description</td>
+          <td>{{ object.description|placeholder }}</td>
+        </tr>
+        <tr>
+          <td>NAPALM Driver</td>
+          <td>{{ object.napalm_driver }}</td>
+        </tr>
+        <tr>
+          <td>NAPALM Arguments</td>
+          <td><pre>{{ object.napalm_args }}</pre></td>
+        </tr>
+        <tr>
+          <td>Devices</td>
+          <td>
+            <a href="{% url 'dcim:device_list' %}?platform_id={{ object.pk }}">{{ devices_table.rows|length }}</a>
+          </td>
+        </tr>
+      </table>
+    </div>
+    {% plugin_left_page object %}
+	</div>
+	<div class="col-md-6">
+    {% include 'inc/custom_fields_panel.html' %}
+    {% plugin_right_page object %}
+  </div>
+</div>
+<div class="row">
+	<div class="col-md-12">
+    <div class="panel panel-default">
+      <div class="panel-heading">
+        <strong>Devices</strong>
+      </div>
+      {% include 'inc/table.html' with table=devices_table %}
+      {% if perms.dcim.add_device %}
+        <div class="panel-footer text-right noprint">
+          <a href="{% url 'dcim:device_add' %}?device_role={{ object.pk }}" class="btn btn-xs btn-primary">
+            <span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add device
+          </a>
+        </div>
+      {% endif %}
+    </div>
+    {% include 'inc/paginator.html' with paginator=devices_table.paginator page=devices_table.page %}
+        {% plugin_full_width_page object %}
+    </div>
+</div>
+{% endblock %}

+ 66 - 0
netbox/templates/dcim/rackrole.html

@@ -0,0 +1,66 @@
+{% extends 'generic/object.html' %}
+{% load helpers %}
+{% load plugins %}
+
+{% block breadcrumbs %}
+  <li><a href="{% url 'dcim:rackrole_list' %}">Rack Roles</a></li>
+  <li>{{ object }}</li>
+{% endblock %}
+
+{% block content %}
+<div class="row">
+	<div class="col-md-6">
+    <div class="panel panel-default">
+      <div class="panel-heading">
+        <strong>Rack Role</strong>
+      </div>
+      <table class="table table-hover panel-body attr-table">
+        <tr>
+          <td>Name</td>
+          <td>{{ object.name }}</td>
+        </tr>
+        <tr>
+          <td>Description</td>
+          <td>{{ object.description|placeholder }}</td>
+        </tr>
+        <tr>
+          <td>Color</td>
+          <td>
+            <span class="label color-block" style="background-color: #{{ object.color }}">&nbsp;</span>
+          </td>
+        </tr>
+        <tr>
+          <td>Racks</td>
+          <td>
+            <a href="{% url 'dcim:rack_list' %}?role_id={{ object.pk }}">{{ racks_table.rows|length }}</a>
+          </td>
+        </tr>
+      </table>
+    </div>
+    {% plugin_left_page object %}
+	</div>
+	<div class="col-md-6">
+    {% include 'inc/custom_fields_panel.html' %}
+    {% plugin_right_page object %}
+  </div>
+</div>
+<div class="row">
+	<div class="col-md-12">
+    <div class="panel panel-default">
+      <div class="panel-heading">
+        <strong>Racks</strong>
+      </div>
+      {% include 'inc/table.html' with table=racks_table %}
+      {% if perms.dcim.add_rack %}
+        <div class="panel-footer text-right noprint">
+          <a href="{% url 'dcim:rack_add' %}?role={{ object.pk }}" class="btn btn-xs btn-primary">
+            <span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add rack
+          </a>
+        </div>
+      {% endif %}
+    </div>
+    {% include 'inc/paginator.html' with paginator=racks_table.paginator page=racks_table.page %}
+    {% plugin_full_width_page object %}
+  </div>
+</div>
+{% endblock %}

+ 70 - 0
netbox/templates/ipam/rir.html

@@ -0,0 +1,70 @@
+{% extends 'generic/object.html' %}
+{% load helpers %}
+{% load plugins %}
+
+{% block breadcrumbs %}
+  <li><a href="{% url 'ipam:rir_list' %}">RIRs</a></li>
+  <li>{{ object }}</li>
+{% endblock %}
+
+{% block content %}
+<div class="row">
+	<div class="col-md-6">
+    <div class="panel panel-default">
+      <div class="panel-heading">
+        <strong>RIR</strong>
+      </div>
+      <table class="table table-hover panel-body attr-table">
+        <tr>
+          <td>Name</td>
+          <td>{{ object.name }}</td>
+        </tr>
+        <tr>
+          <td>Description</td>
+          <td>{{ object.description|placeholder }}</td>
+        </tr>
+        <tr>
+          <td>Private</td>
+          <td>
+            {% if object.is_private %}
+              <i class="mdi mdi-check-bold text-success" title="Yes"></i>
+            {% else %}
+              <i class="mdi mdi-close-thick text-danger" title="No"></i>
+            {% endif %}
+          </td>
+        </tr>
+        <tr>
+          <td>Aggregates</td>
+          <td>
+            <a href="{% url 'ipam:aggregate_list' %}?rir_id={{ object.pk }}">{{ aggregates_table.rows|length }}</a>
+          </td>
+        </tr>
+      </table>
+    </div>
+    {% plugin_left_page object %}
+	</div>
+	<div class="col-md-6">
+    {% include 'inc/custom_fields_panel.html' %}
+    {% plugin_right_page object %}
+  </div>
+</div>
+<div class="row">
+	<div class="col-md-12">
+    <div class="panel panel-default">
+      <div class="panel-heading">
+        <strong>Aggregates</strong>
+      </div>
+      {% include 'inc/table.html' with table=aggregates_table %}
+      {% if perms.ipam.add_aggregate %}
+        <div class="panel-footer text-right noprint">
+          <a href="{% url 'ipam:aggregate_add' %}?rir={{ object.pk }}" class="btn btn-xs btn-primary">
+            <span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add aggregate
+          </a>
+        </div>
+      {% endif %}
+    </div>
+    {% include 'inc/paginator.html' with paginator=aggregates_table.paginator page=aggregates_table.page %}
+    {% plugin_full_width_page object %}
+  </div>
+</div>
+{% endblock %}

+ 64 - 0
netbox/templates/ipam/role.html

@@ -0,0 +1,64 @@
+{% extends 'generic/object.html' %}
+{% load helpers %}
+{% load plugins %}
+
+{% block breadcrumbs %}
+  <li><a href="{% url 'ipam:role_list' %}">Roles</a></li>
+  <li>{{ object }}</li>
+{% endblock %}
+
+{% block content %}
+<div class="row">
+	<div class="col-md-6">
+    <div class="panel panel-default">
+      <div class="panel-heading">
+        <strong>Role</strong>
+      </div>
+      <table class="table table-hover panel-body attr-table">
+        <tr>
+          <td>Name</td>
+          <td>{{ object.name }}</td>
+        </tr>
+        <tr>
+          <td>Description</td>
+          <td>{{ object.description|placeholder }}</td>
+        </tr>
+        <tr>
+          <td>Weight</td>
+          <td>{{ object.weight }}</td>
+        </tr>
+        <tr>
+          <td>Prefixes</td>
+          <td>
+            <a href="{% url 'ipam:prefix_list' %}?role_id={{ object.pk }}">{{ prefixes_table.rows|length }}</a>
+          </td>
+        </tr>
+      </table>
+    </div>
+    {% plugin_left_page object %}
+	</div>
+	<div class="col-md-6">
+    {% include 'inc/custom_fields_panel.html' %}
+    {% plugin_right_page object %}
+  </div>
+</div>
+<div class="row">
+	<div class="col-md-12">
+    <div class="panel panel-default">
+      <div class="panel-heading">
+        <strong>Prefixes</strong>
+      </div>
+      {% include 'inc/table.html' with table=prefixes_table %}
+      {% if perms.ipam.add_prefix %}
+        <div class="panel-footer text-right noprint">
+          <a href="{% url 'ipam:prefix_add' %}?role={{ object.pk }}" class="btn btn-xs btn-primary">
+            <span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add prefix
+          </a>
+        </div>
+      {% endif %}
+    </div>
+    {% include 'inc/paginator.html' with paginator=prefixes_table.paginator page=prefixes_table.page %}
+    {% plugin_full_width_page object %}
+  </div>
+</div>
+{% endblock %}

+ 72 - 0
netbox/templates/ipam/vlangroup.html

@@ -0,0 +1,72 @@
+{% extends 'generic/object.html' %}
+{% load helpers %}
+{% load plugins %}
+
+{% block breadcrumbs %}
+  <li><a href="{% url 'ipam:vlangroup_list' %}">VLAN Groups</a></li>
+  {% if object.scope %}
+    <li><a href="{{ object.scope.get_absolute_url }}">{{ object.scope }}</a></li>
+  {% endif %}
+  <li>{{ object }}</li>
+{% endblock %}
+
+{% block content %}
+<div class="row">
+	<div class="col-md-6">
+    <div class="panel panel-default">
+      <div class="panel-heading">
+        <strong>VLAN Group</strong>
+      </div>
+      <table class="table table-hover panel-body attr-table">
+        <tr>
+          <td>Name</td>
+          <td>{{ object.name }}</td>
+        </tr>
+        <tr>
+          <td>Description</td>
+          <td>{{ object.description|placeholder }}</td>
+        </tr>
+        <tr>
+          <td>Scope</td>
+          <td>
+          {% if object.scope %}
+            <a href="{{ object.scope.get_absolute_url }}">{{ object.scope }}</a>
+          {% else %}
+            <span class="text-muted">&mdash;</span>
+          {% endif %}
+        </tr>
+        <tr>
+          <td>VLANs</td>
+          <td>
+            <a href="{% url 'ipam:vlan_list' %}?group_id={{ object.pk }}">{{ vlans_count }}</a>
+          </td>
+        </tr>
+      </table>
+    </div>
+    {% plugin_left_page object %}
+	</div>
+	<div class="col-md-6">
+    {% include 'inc/custom_fields_panel.html' %}
+    {% plugin_right_page object %}
+  </div>
+</div>
+<div class="row">
+	<div class="col-md-12">
+    <div class="panel panel-default">
+      <div class="panel-heading">
+        <strong>VLANs</strong>
+      </div>
+      {% include 'inc/table.html' with table=vlans_table %}
+      {% if perms.ipam.add_vlan %}
+        <div class="panel-footer text-right noprint">
+          <a href="{% url 'ipam:vlan_add' %}?role={{ object.pk }}" class="btn btn-xs btn-primary">
+            <span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add VLAN
+          </a>
+        </div>
+      {% endif %}
+    </div>
+    {% include 'inc/paginator.html' with paginator=vlans_table.paginator page=vlans_table.page %}
+    </div>
+</div>
+{% endblock %}
+

+ 0 - 24
netbox/templates/ipam/vlangroup_vlans.html

@@ -1,24 +0,0 @@
-{% extends 'base.html' %}
-
-{% block title %}{{ object }} - VLANs{% endblock %}
-
-{% block content %}
-<div class="row noprint">
-    <div class="col-sm-12 col-md-12">
-        <ol class="breadcrumb">
-            <li><a href="{% url 'ipam:vlangroup_list' %}">VLAN Groups</a></li>
-            {% if object.site %}
-                <li><a href="{{ object.site.get_absolute_url }}">{{ object.site }}</a></li>
-            {% endif %}
-            <li>{{ object }}</li>
-        </ol>
-    </div>
-</div>
-    {% include 'ipam/inc/vlangroup_header.html' %}
-    <div class="row">
-        <div class="col-md-12">
-            {% include 'utilities/obj_table.html' with table=vlan_table table_template='panel_table.html' heading='VLANs' bulk_edit_url='ipam:vlan_bulk_edit' bulk_delete_url='ipam:vlan_bulk_delete' %}
-        </div>
-    </div>
-{% endblock %}
-

+ 60 - 0
netbox/templates/secrets/secretrole.html

@@ -0,0 +1,60 @@
+{% extends 'generic/object.html' %}
+{% load helpers %}
+{% load plugins %}
+
+{% block breadcrumbs %}
+  <li><a href="{% url 'secrets:secretrole_list' %}">Secret Roles</a></li>
+  <li>{{ object }}</li>
+{% endblock %}
+
+{% block content %}
+<div class="row">
+	<div class="col-md-6">
+    <div class="panel panel-default">
+      <div class="panel-heading">
+        <strong>Secret Role</strong>
+      </div>
+      <table class="table table-hover panel-body attr-table">
+        <tr>
+          <td>Name</td>
+          <td>{{ object.name }}</td>
+        </tr>
+        <tr>
+          <td>Description</td>
+          <td>{{ object.description|placeholder }}</td>
+        </tr>
+        <tr>
+          <td>Secrets</td>
+          <td>
+            <a href="{% url 'secrets:secret_list' %}?role_id={{ object.pk }}">{{ secrets_table.rows|length }}</a>
+          </td>
+        </tr>
+      </table>
+    </div>
+    {% plugin_left_page object %}
+	</div>
+	<div class="col-md-6">
+    {% include 'inc/custom_fields_panel.html' %}
+    {% plugin_right_page object %}
+  </div>
+</div>
+<div class="row">
+	<div class="col-md-12">
+    <div class="panel panel-default">
+      <div class="panel-heading">
+        <strong>Secrets</strong>
+      </div>
+      {% include 'inc/table.html' with table=secrets_table %}
+      {% if perms.secrets.add_secret %}
+        <div class="panel-footer text-right noprint">
+          <a href="{% url 'secrets:secret_add' %}?role={{ object.pk }}" class="btn btn-xs btn-primary">
+            <span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add secret
+          </a>
+        </div>
+      {% endif %}
+    </div>
+    {% include 'inc/paginator.html' with paginator=secrets_table.paginator page=secrets_table.page %}
+    {% plugin_full_width_page object %}
+  </div>
+</div>
+{% endblock %}

+ 60 - 0
netbox/templates/virtualization/clustergroup.html

@@ -0,0 +1,60 @@
+{% extends 'generic/object.html' %}
+{% load helpers %}
+{% load plugins %}
+
+{% block breadcrumbs %}
+  <li><a href="{% url 'virtualization:clustertype_list' %}">Cluster Groups</a></li>
+  <li>{{ object }}</li>
+{% endblock %}
+
+{% block content %}
+<div class="row">
+	<div class="col-md-6">
+    <div class="panel panel-default">
+      <div class="panel-heading">
+        <strong>Cluster Group</strong>
+      </div>
+      <table class="table table-hover panel-body attr-table">
+        <tr>
+          <td>Name</td>
+          <td>{{ object.name }}</td>
+        </tr>
+        <tr>
+          <td>Description</td>
+          <td>{{ object.description|placeholder }}</td>
+        </tr>
+        <tr>
+          <td>Clusters</td>
+          <td>
+            <a href="{% url 'virtualization:cluster_list' %}?group_id={{ object.pk }}">{{ clusters_table.rows|length }}</a>
+          </td>
+        </tr>
+      </table>
+    </div>
+    {% plugin_left_page object %}
+	</div>
+	<div class="col-md-6">
+    {% include 'inc/custom_fields_panel.html' %}
+    {% plugin_right_page object %}
+  </div>
+</div>
+<div class="row">
+	<div class="col-md-12">
+    <div class="panel panel-default">
+      <div class="panel-heading">
+        <strong>Clusters</strong>
+      </div>
+      {% include 'inc/table.html' with table=clusters_table %}
+      {% if perms.virtualization.add_cluster %}
+        <div class="panel-footer text-right noprint">
+          <a href="{% url 'virtualization:cluster_add' %}?type={{ object.pk }}" class="btn btn-xs btn-primary">
+            <span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add cluster
+          </a>
+        </div>
+      {% endif %}
+    </div>
+    {% include 'inc/paginator.html' with paginator=clusters_table.paginator page=clusters_table.page %}
+    {% plugin_full_width_page object %}
+  </div>
+</div>
+{% endblock %}

+ 60 - 0
netbox/templates/virtualization/clustertype.html

@@ -0,0 +1,60 @@
+{% extends 'generic/object.html' %}
+{% load helpers %}
+{% load plugins %}
+
+{% block breadcrumbs %}
+  <li><a href="{% url 'virtualization:clustertype_list' %}">Cluster Types</a></li>
+  <li>{{ object }}</li>
+{% endblock %}
+
+{% block content %}
+<div class="row">
+	<div class="col-md-6">
+    <div class="panel panel-default">
+      <div class="panel-heading">
+        <strong>Cluster Type</strong>
+      </div>
+      <table class="table table-hover panel-body attr-table">
+        <tr>
+          <td>Name</td>
+          <td>{{ object.name }}</td>
+        </tr>
+        <tr>
+          <td>Description</td>
+          <td>{{ object.description|placeholder }}</td>
+        </tr>
+        <tr>
+          <td>Clusters</td>
+          <td>
+            <a href="{% url 'virtualization:cluster_list' %}?type_id={{ object.pk }}">{{ clusters_table.rows|length }}</a>
+          </td>
+        </tr>
+      </table>
+    </div>
+    {% plugin_left_page object %}
+	</div>
+	<div class="col-md-6">
+    {% include 'inc/custom_fields_panel.html' %}
+    {% plugin_right_page object %}
+  </div>
+</div>
+<div class="row">
+	<div class="col-md-12">
+    <div class="panel panel-default">
+      <div class="panel-heading">
+        <strong>Clusters</strong>
+      </div>
+      {% include 'inc/table.html' with table=clusters_table %}
+      {% if perms.virtualization.add_cluster %}
+        <div class="panel-footer text-right noprint">
+          <a href="{% url 'virtualization:cluster_add' %}?type={{ object.pk }}" class="btn btn-xs btn-primary">
+            <span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add cluster
+          </a>
+        </div>
+      {% endif %}
+    </div>
+    {% include 'inc/paginator.html' with paginator=clusters_table.paginator page=clusters_table.page %}
+    {% plugin_full_width_page object %}
+  </div>
+</div>
+{% endblock %}

+ 2 - 2
netbox/virtualization/models.py

@@ -59,7 +59,7 @@ class ClusterType(OrganizationalModel):
         return self.name
 
     def get_absolute_url(self):
-        return "{}?type={}".format(reverse('virtualization:cluster_list'), self.slug)
+        return reverse('virtualization:clustertype', args=[self.pk])
 
     def to_csv(self):
         return (
@@ -102,7 +102,7 @@ class ClusterGroup(OrganizationalModel):
         return self.name
 
     def get_absolute_url(self):
-        return "{}?group={}".format(reverse('virtualization:cluster_list'), self.slug)
+        return reverse('virtualization:clustergroup', args=[self.pk])
 
     def to_csv(self):
         return (

+ 2 - 0
netbox/virtualization/urls.py

@@ -14,6 +14,7 @@ urlpatterns = [
     path('cluster-types/import/', views.ClusterTypeBulkImportView.as_view(), name='clustertype_import'),
     path('cluster-types/edit/', views.ClusterTypeBulkEditView.as_view(), name='clustertype_bulk_edit'),
     path('cluster-types/delete/', views.ClusterTypeBulkDeleteView.as_view(), name='clustertype_bulk_delete'),
+    path('cluster-types/<int:pk>/', views.ClusterTypeView.as_view(), name='clustertype'),
     path('cluster-types/<int:pk>/edit/', views.ClusterTypeEditView.as_view(), name='clustertype_edit'),
     path('cluster-types/<int:pk>/delete/', views.ClusterTypeDeleteView.as_view(), name='clustertype_delete'),
     path('cluster-types/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='clustertype_changelog', kwargs={'model': ClusterType}),
@@ -24,6 +25,7 @@ urlpatterns = [
     path('cluster-groups/import/', views.ClusterGroupBulkImportView.as_view(), name='clustergroup_import'),
     path('cluster-groups/edit/', views.ClusterGroupBulkEditView.as_view(), name='clustergroup_bulk_edit'),
     path('cluster-groups/delete/', views.ClusterGroupBulkDeleteView.as_view(), name='clustergroup_bulk_delete'),
+    path('cluster-groups/<int:pk>/', views.ClusterGroupView.as_view(), name='clustergroup'),
     path('cluster-groups/<int:pk>/edit/', views.ClusterGroupEditView.as_view(), name='clustergroup_edit'),
     path('cluster-groups/<int:pk>/delete/', views.ClusterGroupDeleteView.as_view(), name='clustergroup_delete'),
     path('cluster-groups/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='clustergroup_changelog', kwargs={'model': ClusterGroup}),

+ 35 - 0
netbox/virtualization/views.py

@@ -11,6 +11,7 @@ from ipam.models import IPAddress, Service
 from ipam.tables import InterfaceIPAddressTable, InterfaceVLANTable
 from netbox.views import generic
 from secrets.models import Secret
+from utilities.tables import paginate_table
 from utilities.utils import count_related
 from . import filters, forms, tables
 from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine, VMInterface
@@ -27,6 +28,23 @@ class ClusterTypeListView(generic.ObjectListView):
     table = tables.ClusterTypeTable
 
 
+class ClusterTypeView(generic.ObjectView):
+    queryset = ClusterType.objects.all()
+
+    def get_extra_context(self, request, instance):
+        clusters = Cluster.objects.restrict(request.user, 'view').filter(
+            type=instance
+        )
+
+        clusters_table = tables.ClusterTable(clusters)
+        clusters_table.columns.hide('type')
+        paginate_table(clusters_table, request)
+
+        return {
+            'clusters_table': clusters_table,
+        }
+
+
 class ClusterTypeEditView(generic.ObjectEditView):
     queryset = ClusterType.objects.all()
     model_form = forms.ClusterTypeForm
@@ -69,6 +87,23 @@ class ClusterGroupListView(generic.ObjectListView):
     table = tables.ClusterGroupTable
 
 
+class ClusterGroupView(generic.ObjectView):
+    queryset = ClusterGroup.objects.all()
+
+    def get_extra_context(self, request, instance):
+        clusters = Cluster.objects.restrict(request.user, 'view').filter(
+            group=instance
+        )
+
+        clusters_table = tables.ClusterTable(clusters)
+        clusters_table.columns.hide('group')
+        paginate_table(clusters_table, request)
+
+        return {
+            'clusters_table': clusters_table,
+        }
+
+
 class ClusterGroupEditView(generic.ObjectEditView):
     queryset = ClusterGroup.objects.all()
     model_form = forms.ClusterGroupForm