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

Merge pull request #6062 from netbox-community/5971-org-object-views

Closes #5971: Dedicated views for organizational models
Jeremy Stretch 4 лет назад
Родитель
Сommit
b793ee3aff
48 измененных файлов с 1395 добавлено и 181 удалено
  1. 1 1
      netbox/circuits/models.py
  2. 1 0
      netbox/circuits/urls.py
  3. 17 0
      netbox/circuits/views.py
  4. 5 2
      netbox/dcim/models/devices.py
  5. 1 1
      netbox/dcim/models/racks.py
  6. 3 3
      netbox/dcim/models/sites.py
  7. 6 0
      netbox/dcim/tables/devices.py
  8. 4 2
      netbox/dcim/tables/racks.py
  9. 6 2
      netbox/dcim/tables/sites.py
  10. 7 0
      netbox/dcim/urls.py
  11. 120 0
      netbox/dcim/views.py
  12. 4 0
      netbox/extras/models/tags.py
  13. 3 0
      netbox/extras/tables.py
  14. 1 0
      netbox/extras/urls.py
  15. 11 10
      netbox/extras/views.py
  16. 4 1
      netbox/ipam/models/ip.py
  17. 1 1
      netbox/ipam/models/vlans.py
  18. 5 3
      netbox/ipam/tables.py
  19. 3 1
      netbox/ipam/urls.py
  20. 57 32
      netbox/ipam/views.py
  21. 1 1
      netbox/secrets/models.py
  22. 1 0
      netbox/secrets/urls.py
  23. 18 0
      netbox/secrets/views.py
  24. 60 0
      netbox/templates/circuits/circuittype.html
  25. 76 0
      netbox/templates/dcim/devicerole.html
  26. 73 0
      netbox/templates/dcim/location.html
  27. 60 0
      netbox/templates/dcim/manufacturer.html
  28. 68 0
      netbox/templates/dcim/platform.html
  29. 66 0
      netbox/templates/dcim/rackrole.html
  30. 73 0
      netbox/templates/dcim/region.html
  31. 73 0
      netbox/templates/dcim/sitegroup.html
  32. 44 91
      netbox/templates/extras/tag.html
  33. 70 0
      netbox/templates/ipam/rir.html
  34. 64 0
      netbox/templates/ipam/role.html
  35. 72 0
      netbox/templates/ipam/vlangroup.html
  36. 0 24
      netbox/templates/ipam/vlangroup_vlans.html
  37. 60 0
      netbox/templates/secrets/secretrole.html
  38. 73 0
      netbox/templates/tenancy/tenantgroup.html
  39. 60 0
      netbox/templates/virtualization/clustergroup.html
  40. 60 0
      netbox/templates/virtualization/clustertype.html
  41. 1 1
      netbox/tenancy/models.py
  42. 3 1
      netbox/tenancy/tables.py
  43. 1 0
      netbox/tenancy/urls.py
  44. 18 2
      netbox/tenancy/views.py
  45. 1 0
      netbox/utilities/testing/views.py
  46. 2 2
      netbox/virtualization/models.py
  47. 2 0
      netbox/virtualization/urls.py
  48. 35 0
      netbox/virtualization/views.py

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

+ 3 - 3
netbox/dcim/models/sites.py

@@ -56,7 +56,7 @@ class Region(NestedGroupModel):
     csv_headers = ['name', 'slug', 'parent', 'description']
 
     def get_absolute_url(self):
-        return "{}?region={}".format(reverse('dcim:site_list'), self.slug)
+        return reverse('dcim:region', args=[self.pk])
 
     def to_csv(self):
         return (
@@ -108,7 +108,7 @@ class SiteGroup(NestedGroupModel):
     csv_headers = ['name', 'slug', 'parent', 'description']
 
     def get_absolute_url(self):
-        return "{}?group={}".format(reverse('dcim:site_list'), self.slug)
+        return reverse('dcim:sitegroup', args=[self.pk])
 
     def to_csv(self):
         return (
@@ -324,7 +324,7 @@ class Location(NestedGroupModel):
         ]
 
     def get_absolute_url(self):
-        return "{}?location_id={}".format(reverse('dcim:rack_list'), self.pk)
+        return reverse('dcim:location', 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 - 2
netbox/dcim/tables/racks.py

@@ -19,12 +19,14 @@ __all__ = (
 
 
 #
-# Rack groups
+# Locations
 #
 
 class LocationTable(BaseTable):
     pk = ToggleColumn()
-    name = MPTTColumn()
+    name = MPTTColumn(
+        linkify=True
+    )
     site = tables.Column(
         linkify=True
     )

+ 6 - 2
netbox/dcim/tables/sites.py

@@ -17,7 +17,9 @@ __all__ = (
 
 class RegionTable(BaseTable):
     pk = ToggleColumn()
-    name = MPTTColumn()
+    name = MPTTColumn(
+        linkify=True
+    )
     site_count = tables.Column(
         verbose_name='Sites'
     )
@@ -35,7 +37,9 @@ class RegionTable(BaseTable):
 
 class SiteGroupTable(BaseTable):
     pk = ToggleColumn()
-    name = MPTTColumn()
+    name = MPTTColumn(
+        linkify=True
+    )
     site_count = tables.Column(
         verbose_name='Sites'
     )

+ 7 - 0
netbox/dcim/urls.py

@@ -14,6 +14,7 @@ urlpatterns = [
     path('regions/import/', views.RegionBulkImportView.as_view(), name='region_import'),
     path('regions/edit/', views.RegionBulkEditView.as_view(), name='region_bulk_edit'),
     path('regions/delete/', views.RegionBulkDeleteView.as_view(), name='region_bulk_delete'),
+    path('regions/<int:pk>/', views.RegionView.as_view(), name='region'),
     path('regions/<int:pk>/edit/', views.RegionEditView.as_view(), name='region_edit'),
     path('regions/<int:pk>/delete/', views.RegionDeleteView.as_view(), name='region_delete'),
     path('regions/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='region_changelog', kwargs={'model': Region}),
@@ -24,6 +25,7 @@ urlpatterns = [
     path('site-groups/import/', views.SiteGroupBulkImportView.as_view(), name='sitegroup_import'),
     path('site-groups/edit/', views.SiteGroupBulkEditView.as_view(), name='sitegroup_bulk_edit'),
     path('site-groups/delete/', views.SiteGroupBulkDeleteView.as_view(), name='sitegroup_bulk_delete'),
+    path('site-groups/<int:pk>/', views.SiteGroupView.as_view(), name='sitegroup'),
     path('site-groups/<int:pk>/edit/', views.SiteGroupEditView.as_view(), name='sitegroup_edit'),
     path('site-groups/<int:pk>/delete/', views.SiteGroupDeleteView.as_view(), name='sitegroup_delete'),
     path('site-groups/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='sitegroup_changelog', kwargs={'model': SiteGroup}),
@@ -47,6 +49,7 @@ urlpatterns = [
     path('locations/import/', views.LocationBulkImportView.as_view(), name='location_import'),
     path('locations/edit/', views.LocationBulkEditView.as_view(), name='location_bulk_edit'),
     path('locations/delete/', views.LocationBulkDeleteView.as_view(), name='location_bulk_delete'),
+    path('locations/<int:pk>/', views.LocationView.as_view(), name='location'),
     path('locations/<int:pk>/edit/', views.LocationEditView.as_view(), name='location_edit'),
     path('locations/<int:pk>/delete/', views.LocationDeleteView.as_view(), name='location_delete'),
     path('locations/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='location_changelog', kwargs={'model': Location}),
@@ -57,6 +60,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 +97,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 +184,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 +195,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}),

+ 120 - 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
@@ -111,6 +112,23 @@ class RegionListView(generic.ObjectListView):
     table = tables.RegionTable
 
 
+class RegionView(generic.ObjectView):
+    queryset = Region.objects.all()
+
+    def get_extra_context(self, request, instance):
+        sites = Site.objects.restrict(request.user, 'view').filter(
+            region=instance
+        )
+
+        sites_table = tables.SiteTable(sites)
+        sites_table.columns.hide('region')
+        paginate_table(sites_table, request)
+
+        return {
+            'sites_table': sites_table,
+        }
+
+
 class RegionEditView(generic.ObjectEditView):
     queryset = Region.objects.all()
     model_form = forms.RegionForm
@@ -168,6 +186,23 @@ class SiteGroupListView(generic.ObjectListView):
     table = tables.SiteGroupTable
 
 
+class SiteGroupView(generic.ObjectView):
+    queryset = SiteGroup.objects.all()
+
+    def get_extra_context(self, request, instance):
+        sites = Site.objects.restrict(request.user, 'view').filter(
+            group=instance
+        )
+
+        sites_table = tables.SiteTable(sites)
+        sites_table.columns.hide('group')
+        paginate_table(sites_table, request)
+
+        return {
+            'sites_table': sites_table,
+        }
+
+
 class SiteGroupEditView(generic.ObjectEditView):
     queryset = SiteGroup.objects.all()
     model_form = forms.SiteGroupForm
@@ -290,6 +325,23 @@ class LocationListView(generic.ObjectListView):
     table = tables.LocationTable
 
 
+class LocationView(generic.ObjectView):
+    queryset = Location.objects.all()
+
+    def get_extra_context(self, request, instance):
+        devices = Device.objects.restrict(request.user, 'view').filter(
+            location=instance
+        )
+
+        devices_table = tables.DeviceTable(devices)
+        devices_table.columns.hide('location')
+        paginate_table(devices_table, request)
+
+        return {
+            'devices_table': devices_table,
+        }
+
+
 class LocationEditView(generic.ObjectEditView):
     queryset = Location.objects.all()
     model_form = forms.LocationForm
@@ -341,6 +393,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 +636,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 +1103,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 +1159,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 - 0
netbox/extras/models/tags.py

@@ -1,4 +1,5 @@
 from django.db import models
+from django.urls import reverse
 from django.utils.text import slugify
 from taggit.models import TagBase, GenericTaggedItemBase
 
@@ -30,6 +31,9 @@ class Tag(ChangeLoggedModel, TagBase):
     class Meta:
         ordering = ['name']
 
+    def get_absolute_url(self):
+        return reverse('extras:tag', args=[self.pk])
+
     def slugify(self, tag, i=None):
         # Allow Unicode in Tag slugs (avoids empty slugs for Tags with all-Unicode names)
         slug = slugify(tag, allow_unicode=True)

+ 3 - 0
netbox/extras/tables.py

@@ -38,6 +38,9 @@ OBJECTCHANGE_REQUEST_ID = """
 
 class TagTable(BaseTable):
     pk = ToggleColumn()
+    name = tables.Column(
+        linkify=True
+    )
     color = ColorColumn()
     actions = ButtonsColumn(Tag)
 

+ 1 - 0
netbox/extras/urls.py

@@ -13,6 +13,7 @@ urlpatterns = [
     path('tags/import/', views.TagBulkImportView.as_view(), name='tag_import'),
     path('tags/edit/', views.TagBulkEditView.as_view(), name='tag_bulk_edit'),
     path('tags/delete/', views.TagBulkDeleteView.as_view(), name='tag_bulk_delete'),
+    path('tags/<int:pk>/', views.TagView.as_view(), name='tag'),
     path('tags/<int:pk>/edit/', views.TagEditView.as_view(), name='tag_edit'),
     path('tags/<int:pk>/delete/', views.TagDeleteView.as_view(), name='tag_delete'),
     path('tags/<int:pk>/changelog/', views.ObjectChangeLogView.as_view(), name='tag_changelog', kwargs={'model': Tag}),

+ 11 - 10
netbox/extras/views.py

@@ -34,6 +34,17 @@ class TagListView(generic.ObjectListView):
     table = tables.TagTable
 
 
+class TagView(generic.ObjectView):
+    queryset = Tag.objects.all()
+
+    def get_extra_context(self, request, instance):
+        tagged_items = TaggedItem.objects.filter(tag=instance)
+
+        return {
+            'tagged_item_count': tagged_items.count(),
+        }
+
+
 class TagEditView(generic.ObjectEditView):
     queryset = Tag.objects.all()
     model_form = forms.TagForm
@@ -235,11 +246,6 @@ class ObjectChangeLogView(View):
         # fall back to using base.html.
         if self.base_template is None:
             self.base_template = f"{model._meta.app_label}/{model._meta.model_name}.html"
-            # TODO: This can be removed once an object view has been established for every model.
-            try:
-                template.loader.get_template(self.base_template)
-            except template.TemplateDoesNotExist:
-                self.base_template = 'base.html'
 
         return render(request, 'extras/object_changelog.html', {
             'object': obj,
@@ -368,11 +374,6 @@ class ObjectJournalView(View):
         # fall back to using base.html.
         if self.base_template is None:
             self.base_template = f"{model._meta.app_label}/{model._meta.model_name}.html"
-            # TODO: This can be removed once an object view has been established for every model.
-            try:
-                template.loader.get_template(self.base_template)
-            except template.TemplateDoesNotExist:
-                self.base_template = 'base.html'
 
         return render(request, 'extras/object_journal.html', {
             'object': obj,

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

+ 73 - 0
netbox/templates/dcim/location.html

@@ -0,0 +1,73 @@
+{% extends 'generic/object.html' %}
+{% load helpers %}
+{% load plugins %}
+
+{% block breadcrumbs %}
+  <li><a href="{% url 'dcim:location_list' %}">Location</a></li>
+  {% for location in object.get_ancestors %}
+    <li><a href="{{ location.get_absolute_url }}">{{ location }}</a></li>
+  {% endfor %}
+  <li>{{ object }}</li>
+{% endblock %}
+
+{% block content %}
+<div class="row">
+	<div class="col-md-6">
+    <div class="panel panel-default">
+      <div class="panel-heading">
+        <strong>Location</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>Parent</td>
+          <td>
+            {% if object.parent %}
+              <a href="{{ object.parent.get_absolute_url }}">{{ object.parent }}</a>
+            {% else %}
+              <span class="text-muted">&mdash;</span>
+            {% endif %}
+          </td>
+        </tr>
+        <tr>
+          <td>Devices</td>
+          <td>
+            <a href="{% url 'dcim:device_list' %}?location_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' %}?location={{ 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 %}

+ 73 - 0
netbox/templates/dcim/region.html

@@ -0,0 +1,73 @@
+{% extends 'generic/object.html' %}
+{% load helpers %}
+{% load plugins %}
+
+{% block breadcrumbs %}
+  <li><a href="{% url 'dcim:region_list' %}">Region</a></li>
+  {% for region in object.get_ancestors %}
+    <li><a href="{{ region.get_absolute_url }}">{{ region }}</a></li>
+  {% endfor %}
+  <li>{{ object }}</li>
+{% endblock %}
+
+{% block content %}
+<div class="row">
+	<div class="col-md-6">
+    <div class="panel panel-default">
+      <div class="panel-heading">
+        <strong>Region</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>Parent</td>
+          <td>
+            {% if object.parent %}
+              <a href="{{ object.parent.get_absolute_url }}">{{ object.parent }}</a>
+            {% else %}
+              <span class="text-muted">&mdash;</span>
+            {% endif %}
+          </td>
+        </tr>
+        <tr>
+          <td>Sites</td>
+          <td>
+            <a href="{% url 'dcim:site_list' %}?region_id={{ object.pk }}">{{ sites_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>Sites</strong>
+      </div>
+      {% include 'inc/table.html' with table=sites_table %}
+      {% if perms.dcim.add_site %}
+        <div class="panel-footer text-right noprint">
+          <a href="{% url 'dcim:site_add' %}?region={{ object.pk }}" class="btn btn-xs btn-primary">
+            <span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add site
+          </a>
+        </div>
+      {% endif %}
+      </div>
+      {% include 'inc/paginator.html' with paginator=sites_table.paginator page=sites_table.page %}
+      {% plugin_full_width_page object %}
+  </div>
+</div>
+{% endblock %}

+ 73 - 0
netbox/templates/dcim/sitegroup.html

@@ -0,0 +1,73 @@
+{% extends 'generic/object.html' %}
+{% load helpers %}
+{% load plugins %}
+
+{% block breadcrumbs %}
+  <li><a href="{% url 'dcim:sitegroup_list' %}">Site Groups</a></li>
+  {% for sitegroup in object.get_ancestors %}
+    <li><a href="{{ sitegroup.get_absolute_url }}">{{ sitegroup }}</a></li>
+  {% endfor %}
+  <li>{{ object }}</li>
+{% endblock %}
+
+{% block content %}
+<div class="row">
+	<div class="col-md-6">
+    <div class="panel panel-default">
+      <div class="panel-heading">
+        <strong>Site 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>Parent</td>
+          <td>
+            {% if object.parent %}
+              <a href="{{ object.parent.get_absolute_url }}">{{ object.parent }}</a>
+            {% else %}
+              <span class="text-muted">&mdash;</span>
+            {% endif %}
+          </td>
+        </tr>
+        <tr>
+          <td>Sites</td>
+          <td>
+            <a href="{% url 'dcim:site_list' %}?group_id={{ object.pk }}">{{ sites_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>Sites</strong>
+      </div>
+      {% include 'inc/table.html' with table=sites_table %}
+      {% if perms.dcim.add_site %}
+        <div class="panel-footer text-right noprint">
+          <a href="{% url 'dcim:site_add' %}?group={{ object.pk }}" class="btn btn-xs btn-primary">
+            <span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add site
+          </a>
+        </div>
+      {% endif %}
+      </div>
+      {% include 'inc/paginator.html' with paginator=sites_table.paginator page=sites_table.page %}
+      {% plugin_full_width_page object %}
+  </div>
+</div>
+{% endblock %}

+ 44 - 91
netbox/templates/extras/tag.html

@@ -1,98 +1,51 @@
-{% extends 'base.html' %}
+{% extends 'generic/object.html' %}
 {% load helpers %}
+{% load plugins %}
 
-{% block header %}
-    <div class="row">
-        <div class="col-sm-8 col-md-9">
-            <ol class="breadcrumb">
-                <li><a href="{% url 'extras:tag_list' %}">Tags</a></li>
-                <li>{{ object }}</li>
-            </ol>
-        </div>
-        <div class="col-sm-4 col-md-3">
-            <form action="{% url 'extras:tag_list' %}" method="get">
-                <div class="input-group">
-                    <input type="text" name="q" class="form-control" />
-                    <span class="input-group-btn">
-                        <button type="submit" class="btn btn-primary">
-                            <span class="mdi mdi-magnify" aria-hidden="true"></span>
-                        </button>
-                    </span>
-                </div>
-            </form>
-        </div>
-    </div>
-    <div class="pull-right">
-        {% if perms.taggit.change_tag %}
-            <a href="{% url 'extras:tag_edit' slug=object.slug %}" class="btn btn-warning">
-                <span class="mdi mdi-pencil" aria-hidden="true"></span>
-                Edit this tag
-            </a>
-        {% endif %}
-        {% if perms.taggit.delete_tag %}
-            <a href="{% url 'extras:tag_delete' slug=object.slug %}" class="btn btn-danger">
-                <span class="mdi mdi-trash-can-outline" aria-hidden="true"></span>
-                Delete this tag
-            </a>
-        {% endif %}
-    </div>
-    <h1>{% block title %}Tag: {{ object }}{% endblock %}</h1>
-    {% include 'inc/created_updated.html' %}
-    <ul class="nav nav-tabs">
-        <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
-            <a href="{{ object.get_absolute_url }}">Tag</a>
-        </li>
-        {% if perms.extras.view_objectchange %}
-            <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
-                <a href="{% url 'extras:tag_changelog' pk=object.pk %}">Change Log</a>
-            </li>
-        {% endif %}
-    </ul>
+{% block breadcrumbs %}
+  <li><a href="{% url 'extras:tag_list' %}">Tags</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>Tag</strong>
-                </div>
-                <table class="table table-hover panel-body attr-table">
-                    <tr>
-                        <td>Name</td>
-                        <td>
-                            {{ object.name }}
-                        </td>
-                    </tr>
-                    <tr>
-                        <td>Slug</td>
-                        <td>
-                            {{ object.slug }}
-                        </td>
-                    </tr>
-                    <tr>
-                        <td>Tagged Items</td>
-                        <td>
-                            {{ items_count }}
-                        </td>
-                    </tr>
-                    <tr>
-                        <td>Color</td>
-                        <td>
-                            <span class="label color-block" style="background-color: #{{ object.color }}">&nbsp;</span>
-                        </td>
-                    </tr>
-                    <tr>
-                        <td>Description</td>
-                        <td>
-                            {{ object.description|placeholder }}
-                        </td>
-                </table>
-            </div>
-        </div>
-        <div class="col-md-6">
-            {% include 'panel_table.html' with table=items_table heading='Tagged Objects' %}
-            {% include 'inc/paginator.html' with paginator=items_table.paginator page=items_table.page %}
-        </div>
+<div class="row">
+	<div class="col-md-6">
+    <div class="panel panel-default">
+      <div class="panel-heading">
+        <strong>Tag</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>Tagged Items</td>
+          <td>
+            {{ tagged_item_count }}
+          </td>
+        </tr>
+      </table>
     </div>
+    {% plugin_left_page object %}
+  </div>
+	<div class="col-md-6">
+    {% plugin_right_page object %}
+	</div>
+</div>
+<div class="row">
+	<div class="col-md-12">
+      {% 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 %}

+ 73 - 0
netbox/templates/tenancy/tenantgroup.html

@@ -0,0 +1,73 @@
+{% extends 'generic/object.html' %}
+{% load helpers %}
+{% load plugins %}
+
+{% block breadcrumbs %}
+  <li><a href="{% url 'tenancy:tenantgroup_list' %}">Tenant Groups</a></li>
+  {% for tenantgroup in object.get_ancestors %}
+    <li><a href="{{ tenantgroup.get_absolute_url }}">{{ tenantgroup }}</a></li>
+  {% endfor %}
+  <li>{{ object }}</li>
+{% endblock %}
+
+{% block content %}
+<div class="row">
+	<div class="col-md-6">
+    <div class="panel panel-default">
+      <div class="panel-heading">
+        <strong>Tenant 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>Parent</td>
+          <td>
+            {% if object.parent %}
+              <a href="{{ object.parent.get_absolute_url }}">{{ object.parent }}</a>
+            {% else %}
+              <span class="text-muted">&mdash;</span>
+            {% endif %}
+          </td>
+        </tr>
+        <tr>
+          <td>Sites</td>
+          <td>
+            <a href="{% url 'tenancy:tenant_list' %}?group_id={{ object.pk }}">{{ tenants_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>Tenants</strong>
+      </div>
+      {% include 'inc/table.html' with table=tenants_table %}
+      {% if perms.tenancy.add_tenant %}
+        <div class="panel-footer text-right noprint">
+          <a href="{% url 'tenancy:tenant_add' %}?group={{ object.pk }}" class="btn btn-xs btn-primary">
+            <span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add tenant
+          </a>
+        </div>
+      {% endif %}
+      </div>
+      {% include 'inc/paginator.html' with paginator=tenants_table.paginator page=tenants_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 %}

+ 1 - 1
netbox/tenancy/models.py

@@ -45,7 +45,7 @@ class TenantGroup(NestedGroupModel):
         ordering = ['name']
 
     def get_absolute_url(self):
-        return "{}?group={}".format(reverse('tenancy:tenant_list'), self.slug)
+        return reverse('tenancy:tenantgroup', args=[self.pk])
 
     def to_csv(self):
         return (

+ 3 - 1
netbox/tenancy/tables.py

@@ -35,7 +35,9 @@ class TenantColumn(tables.TemplateColumn):
 
 class TenantGroupTable(BaseTable):
     pk = ToggleColumn()
-    name = MPTTColumn()
+    name = MPTTColumn(
+        linkify=True
+    )
     tenant_count = LinkedCountColumn(
         viewname='tenancy:tenant_list',
         url_params={'group': 'slug'},

+ 1 - 0
netbox/tenancy/urls.py

@@ -13,6 +13,7 @@ urlpatterns = [
     path('tenant-groups/import/', views.TenantGroupBulkImportView.as_view(), name='tenantgroup_import'),
     path('tenant-groups/edit/', views.TenantGroupBulkEditView.as_view(), name='tenantgroup_bulk_edit'),
     path('tenant-groups/delete/', views.TenantGroupBulkDeleteView.as_view(), name='tenantgroup_bulk_delete'),
+    path('tenant-groups/<int:pk>/', views.TenantGroupView.as_view(), name='tenantgroup'),
     path('tenant-groups/<int:pk>/edit/', views.TenantGroupEditView.as_view(), name='tenantgroup_edit'),
     path('tenant-groups/<int:pk>/delete/', views.TenantGroupDeleteView.as_view(), name='tenantgroup_delete'),
     path('tenant-groups/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='tenantgroup_changelog', kwargs={'model': TenantGroup}),

+ 18 - 2
netbox/tenancy/views.py

@@ -1,9 +1,8 @@
-from django.shortcuts import get_object_or_404, render
-
 from circuits.models import Circuit
 from dcim.models import Site, Rack, Device, RackReservation
 from ipam.models import IPAddress, Prefix, VLAN, VRF
 from netbox.views import generic
+from utilities.tables import paginate_table
 from virtualization.models import VirtualMachine, Cluster
 from . import filters, forms, tables
 from .models import Tenant, TenantGroup
@@ -24,6 +23,23 @@ class TenantGroupListView(generic.ObjectListView):
     table = tables.TenantGroupTable
 
 
+class TenantGroupView(generic.ObjectView):
+    queryset = TenantGroup.objects.all()
+
+    def get_extra_context(self, request, instance):
+        tenants = Tenant.objects.restrict(request.user, 'view').filter(
+            group=instance
+        )
+
+        tenants_table = tables.TenantTable(tenants)
+        tenants_table.columns.hide('group')
+        paginate_table(tenants_table, request)
+
+        return {
+            'tenants_table': tenants_table,
+        }
+
+
 class TenantGroupEditView(generic.ObjectEditView):
     queryset = TenantGroup.objects.all()
     model_form = forms.TenantGroupForm

+ 1 - 0
netbox/utilities/testing/views.py

@@ -1018,6 +1018,7 @@ class ViewTestCases:
         maxDiff = None
 
     class OrganizationalObjectViewTestCase(
+        GetObjectViewTestCase,
         GetObjectChangelogViewTestCase,
         CreateObjectViewTestCase,
         EditObjectViewTestCase,

+ 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