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

Merge branch 'develop' into develop-2.8

John Anderson 6 лет назад
Родитель
Сommit
9df238c5f2
36 измененных файлов с 464 добавлено и 230 удалено
  1. 15 0
      docs/release-notes/version-2.7.md
  2. 3 0
      netbox/circuits/models.py
  3. 10 2
      netbox/dcim/forms.py
  4. 15 0
      netbox/dcim/models/__init__.py
  5. 10 0
      netbox/dcim/models/device_components.py
  6. 22 5
      netbox/dcim/tables.py
  7. 14 0
      netbox/dcim/tests/test_api.py
  8. 0 4
      netbox/dcim/tests/test_views.py
  9. 2 3
      netbox/dcim/urls.py
  10. 17 7
      netbox/dcim/views.py
  11. 3 2
      netbox/extras/api/serializers.py
  12. 9 172
      netbox/extras/constants.py
  13. 40 0
      netbox/extras/migrations/0039_update_features_content_types.py
  14. 6 5
      netbox/extras/models.py
  15. 1 0
      netbox/extras/tests/test_api.py
  16. 3 3
      netbox/extras/tests/test_filters.py
  17. 68 0
      netbox/extras/utils.py
  18. 2 1
      netbox/extras/webhooks.py
  19. 7 0
      netbox/ipam/models.py
  20. 7 1
      netbox/secrets/forms.py
  21. 2 0
      netbox/secrets/models.py
  22. 1 0
      netbox/secrets/urls.py
  23. 9 10
      netbox/secrets/views.py
  24. 1 1
      netbox/templates/dcim/device.html
  25. 6 4
      netbox/templates/dcim/rack.html
  26. 146 0
      netbox/templates/dcim/rackreservation.html
  27. 21 0
      netbox/templates/dcim/rackreservation_edit.html
  28. 1 1
      netbox/templates/inc/custom_fields_panel.html
  29. 1 0
      netbox/templates/inc/nav_menu.html
  30. 1 6
      netbox/templates/secrets/secret_edit.html
  31. 1 1
      netbox/templates/utilities/obj_edit.html
  32. 2 0
      netbox/tenancy/models.py
  33. 1 0
      netbox/utilities/api.py
  34. 2 2
      netbox/utilities/templatetags/helpers.py
  35. 3 0
      netbox/virtualization/models.py
  36. 12 0
      netbox/virtualization/tests/test_api.py

+ 15 - 0
docs/release-notes/version-2.7.md

@@ -1,5 +1,20 @@
 # NetBox v2.7 Release Notes
 
+## v2.7.11 (FUTURE)
+
+### Enhancements
+
+* [#4309](https://github.com/netbox-community/netbox/issues/4309) - Add descriptive tooltip to custom fields on object views
+* [#4369](https://github.com/netbox-community/netbox/issues/4369) - Add a dedicated view for rack reservations
+
+### Bug Fixes
+
+* [#4340](https://github.com/netbox-community/netbox/issues/4340) - Enforce unique constraints for device and virtual machine names in the API
+* [#4343](https://github.com/netbox-community/netbox/issues/4343) - Fix Markdown support for tables
+* [#4365](https://github.com/netbox-community/netbox/issues/4365) - Fix exception raised on IP address bulk add view
+
+---
+
 ## v2.7.10 (2020-03-10)
 
 **Note:** If your deployment requires any non-core Python packages (such as `napalm`, `django-storages`, or `django-auth-ldap`), list them in a file named `local_requirements.txt` in the NetBox root directory (alongside `requirements.txt`). This will ensure they are detected and re-installed by the upgrade script when the Python virtual environment is rebuilt.

+ 3 - 0
netbox/circuits/models.py

@@ -7,6 +7,7 @@ from dcim.constants import CONNECTION_STATUS_CHOICES
 from dcim.fields import ASNField
 from dcim.models import CableTermination
 from extras.models import CustomFieldModel, ObjectChange, TaggedItem
+from extras.utils import extras_features
 from utilities.models import ChangeLoggedModel
 from utilities.utils import serialize_object
 from .choices import *
@@ -21,6 +22,7 @@ __all__ = (
 )
 
 
+@extras_features('custom_fields', 'custom_links', 'graphs', 'export_templates', 'webhooks')
 class Provider(ChangeLoggedModel, CustomFieldModel):
     """
     Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model
@@ -131,6 +133,7 @@ class CircuitType(ChangeLoggedModel):
         )
 
 
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
 class Circuit(ChangeLoggedModel, CustomFieldModel):
     """
     A communications circuit connects two points. Each Circuit belongs to a Provider; Providers may have multiple

+ 10 - 2
netbox/dcim/forms.py

@@ -823,6 +823,13 @@ class RackElevationFilterForm(RackFilterForm):
 #
 
 class RackReservationForm(BootstrapMixin, TenancyForm, forms.ModelForm):
+    rack = forms.ModelChoiceField(
+        queryset=Rack.objects.all(),
+        required=False,
+        widget=forms.HiddenInput()
+    )
+    # TODO: Change this to an API-backed form field. We can't do this currently because we want to retain
+    # the multi-line <select> widget for easy selection of multiple rack units.
     units = SimpleArrayField(
         base_field=forms.IntegerField(),
         widget=ArrayFieldSelectMultiple(
@@ -841,7 +848,7 @@ class RackReservationForm(BootstrapMixin, TenancyForm, forms.ModelForm):
     class Meta:
         model = RackReservation
         fields = [
-            'units', 'user', 'tenant_group', 'tenant', 'description',
+            'rack', 'units', 'user', 'tenant_group', 'tenant', 'description',
         ]
 
     def __init__(self, *args, **kwargs):
@@ -849,7 +856,8 @@ class RackReservationForm(BootstrapMixin, TenancyForm, forms.ModelForm):
         super().__init__(*args, **kwargs)
 
         # Populate rack unit choices
-        self.fields['units'].widget.choices = self._get_unit_choices()
+        if hasattr(self.instance, 'rack'):
+            self.fields['units'].widget.choices = self._get_unit_choices()
 
     def _get_unit_choices(self):
         rack = self.instance.rack

+ 15 - 0
netbox/dcim/models/__init__.py

@@ -21,6 +21,7 @@ from dcim.constants import *
 from dcim.fields import ASNField
 from dcim.elevations import RackElevationSVG
 from extras.models import ConfigContextModel, CustomFieldModel, ObjectChange, TaggedItem
+from extras.utils import extras_features
 from utilities.fields import ColorField, NaturalOrderingField
 from utilities.models import ChangeLoggedModel
 from utilities.utils import serialize_object, to_meters
@@ -75,6 +76,7 @@ __all__ = (
 # Regions
 #
 
+@extras_features('export_templates', 'webhooks')
 class Region(MPTTModel, ChangeLoggedModel):
     """
     Sites can be grouped within geographic Regions.
@@ -138,6 +140,7 @@ class Region(MPTTModel, ChangeLoggedModel):
 # Sites
 #
 
+@extras_features('custom_fields', 'custom_links', 'graphs', 'export_templates', 'webhooks')
 class Site(ChangeLoggedModel, CustomFieldModel):
     """
     A Site represents a geographic location within a network; typically a building or campus. The optional facility
@@ -288,6 +291,7 @@ class Site(ChangeLoggedModel, CustomFieldModel):
 # Racks
 #
 
+@extras_features('export_templates')
 class RackGroup(MPTTModel, ChangeLoggedModel):
     """
     Racks can be grouped as subsets within a Site. The scope of a group will depend on how Sites are defined. For
@@ -396,6 +400,7 @@ class RackRole(ChangeLoggedModel):
         )
 
 
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
 class Rack(ChangeLoggedModel, CustomFieldModel):
     """
     Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face.
@@ -806,6 +811,9 @@ class RackReservation(ChangeLoggedModel):
     def __str__(self):
         return "Reservation for rack {}".format(self.rack)
 
+    def get_absolute_url(self):
+        return reverse('dcim:rackreservation', args=[self.pk])
+
     def clean(self):
 
         if self.units:
@@ -857,6 +865,7 @@ class RackReservation(ChangeLoggedModel):
 # Device Types
 #
 
+@extras_features('export_templates', 'webhooks')
 class Manufacturer(ChangeLoggedModel):
     """
     A Manufacturer represents a company which produces hardware devices; for example, Juniper or Dell.
@@ -892,6 +901,7 @@ class Manufacturer(ChangeLoggedModel):
         )
 
 
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
 class DeviceType(ChangeLoggedModel, CustomFieldModel):
     """
     A DeviceType represents a particular make (Manufacturer) and model of device. It specifies rack height and depth, as
@@ -1240,6 +1250,7 @@ class Platform(ChangeLoggedModel):
         )
 
 
+@extras_features('custom_fields', 'custom_links', 'graphs', 'export_templates', 'webhooks')
 class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
     """
     A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType,
@@ -1675,6 +1686,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
 # Virtual chassis
 #
 
+@extras_features('export_templates', 'webhooks')
 class VirtualChassis(ChangeLoggedModel):
     """
     A collection of Devices which operate with a shared control plane (e.g. a switch stack).
@@ -1741,6 +1753,7 @@ class VirtualChassis(ChangeLoggedModel):
 # Power
 #
 
+@extras_features('custom_links', 'export_templates', 'webhooks')
 class PowerPanel(ChangeLoggedModel):
     """
     A distribution point for electrical power; e.g. a data center RPP.
@@ -1787,6 +1800,7 @@ class PowerPanel(ChangeLoggedModel):
             ))
 
 
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
 class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel):
     """
     An electrical circuit delivered from a PowerPanel.
@@ -1948,6 +1962,7 @@ class PowerFeed(ChangeLoggedModel, CableTermination, CustomFieldModel):
 # Cables
 #
 
+@extras_features('custom_links', 'export_templates', 'webhooks')
 class Cable(ChangeLoggedModel):
     """
     A physical connection between two endpoints.

+ 10 - 0
netbox/dcim/models/device_components.py

@@ -11,6 +11,7 @@ from dcim.constants import *
 from dcim.exceptions import LoopDetected
 from dcim.fields import MACAddressField
 from extras.models import ObjectChange, TaggedItem
+from extras.utils import extras_features
 from utilities.fields import NaturalOrderingField
 from utilities.ordering import naturalize_interface
 from utilities.utils import serialize_object
@@ -169,6 +170,7 @@ class CableTermination(models.Model):
 # Console ports
 #
 
+@extras_features('export_templates', 'webhooks')
 class ConsolePort(CableTermination, ComponentModel):
     """
     A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts.
@@ -229,6 +231,7 @@ class ConsolePort(CableTermination, ComponentModel):
 # Console server ports
 #
 
+@extras_features('webhooks')
 class ConsoleServerPort(CableTermination, ComponentModel):
     """
     A physical port within a Device (typically a designated console server) which provides access to ConsolePorts.
@@ -282,6 +285,7 @@ class ConsoleServerPort(CableTermination, ComponentModel):
 # Power ports
 #
 
+@extras_features('export_templates', 'webhooks')
 class PowerPort(CableTermination, ComponentModel):
     """
     A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets.
@@ -443,6 +447,7 @@ class PowerPort(CableTermination, ComponentModel):
 # Power outlets
 #
 
+@extras_features('webhooks')
 class PowerOutlet(CableTermination, ComponentModel):
     """
     A physical power outlet (output) within a Device which provides power to a PowerPort.
@@ -519,6 +524,7 @@ class PowerOutlet(CableTermination, ComponentModel):
 # Interfaces
 #
 
+@extras_features('graphs', 'export_templates', 'webhooks')
 class Interface(CableTermination, ComponentModel):
     """
     A network interface within a Device or VirtualMachine. A physical Interface can connect to exactly one other
@@ -792,6 +798,7 @@ class Interface(CableTermination, ComponentModel):
 # Pass-through ports
 #
 
+@extras_features('webhooks')
 class FrontPort(CableTermination, ComponentModel):
     """
     A pass-through port on the front of a Device.
@@ -864,6 +871,7 @@ class FrontPort(CableTermination, ComponentModel):
             )
 
 
+@extras_features('webhooks')
 class RearPort(CableTermination, ComponentModel):
     """
     A pass-through port on the rear of a Device.
@@ -915,6 +923,7 @@ class RearPort(CableTermination, ComponentModel):
 # Device bays
 #
 
+@extras_features('webhooks')
 class DeviceBay(ComponentModel):
     """
     An empty space within a Device which can house a child device
@@ -989,6 +998,7 @@ class DeviceBay(ComponentModel):
 # Inventory items
 #
 
+@extras_features('export_templates', 'webhooks')
 class InventoryItem(ComponentModel):
     """
     An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply.

+ 22 - 5
netbox/dcim/tables.py

@@ -341,21 +341,38 @@ class RackDetailTable(RackTable):
 
 class RackReservationTable(BaseTable):
     pk = ToggleColumn()
+    reservation = tables.LinkColumn(
+        viewname='dcim:rackreservation',
+        args=[Accessor('pk')],
+        accessor='pk'
+    )
     site = tables.LinkColumn(
         viewname='dcim:site',
         accessor=Accessor('rack.site'),
         args=[Accessor('rack.site.slug')],
     )
-    tenant = tables.TemplateColumn(template_code=COL_TENANT)
-    rack = tables.LinkColumn('dcim:rack', args=[Accessor('rack.pk')])
-    unit_list = tables.Column(orderable=False, verbose_name='Units')
+    tenant = tables.TemplateColumn(
+        template_code=COL_TENANT
+    )
+    rack = tables.LinkColumn(
+        viewname='dcim:rack',
+        args=[Accessor('rack.pk')]
+    )
+    unit_list = tables.Column(
+        orderable=False,
+        verbose_name='Units'
+    )
     actions = tables.TemplateColumn(
-        template_code=RACKRESERVATION_ACTIONS, attrs={'td': {'class': 'text-right noprint'}}, verbose_name=''
+        template_code=RACKRESERVATION_ACTIONS,
+        attrs={'td': {'class': 'text-right noprint'}},
+        verbose_name=''
     )
 
     class Meta(BaseTable.Meta):
         model = RackReservation
-        fields = ('pk', 'site', 'rack', 'unit_list', 'user', 'created', 'tenant', 'description', 'actions')
+        fields = (
+            'pk', 'reservation', 'site', 'rack', 'unit_list', 'user', 'created', 'tenant', 'description', 'actions',
+        )
 
 
 #

+ 14 - 0
netbox/dcim/tests/test_api.py

@@ -2017,6 +2017,20 @@ class DeviceTest(APITestCase):
 
         self.assertFalse('config_context' in response.data['results'][0])
 
+    def test_unique_name_per_site_constraint(self):
+
+        data = {
+            'device_type': self.devicetype1.pk,
+            'device_role': self.devicerole1.pk,
+            'name': 'Test Device 1',
+            'site': self.site1.pk,
+        }
+
+        url = reverse('dcim-api:device-list')
+        response = self.client.post(url, data, format='json', **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+
 
 class ConsolePortTest(APITestCase):
 

+ 0 - 4
netbox/dcim/tests/test_views.py

@@ -176,10 +176,6 @@ class RackRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
 class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = RackReservation
 
-    # Disable inapplicable tests
-    test_get_object = None
-    test_create_object = None
-
     @classmethod
     def setUpTestData(cls):
 

+ 2 - 3
netbox/dcim/urls.py

@@ -2,7 +2,6 @@ from django.urls import path
 
 from extras.views import ObjectChangeLogView, ImageAttachmentEditView
 from ipam.views import ServiceCreateView
-from secrets.views import secret_add
 from . import views
 from .models import (
     Cable, ConsolePort, ConsoleServerPort, Device, DeviceRole, DeviceType, FrontPort, Interface, Manufacturer, Platform,
@@ -51,9 +50,11 @@ urlpatterns = [
 
     # Rack reservations
     path('rack-reservations/', views.RackReservationListView.as_view(), name='rackreservation_list'),
+    path('rack-reservations/add/', views.RackReservationCreateView.as_view(), name='rackreservation_add'),
     path('rack-reservations/import/', views.RackReservationImportView.as_view(), name='rackreservation_import'),
     path('rack-reservations/edit/', views.RackReservationBulkEditView.as_view(), name='rackreservation_bulk_edit'),
     path('rack-reservations/delete/', views.RackReservationBulkDeleteView.as_view(), name='rackreservation_bulk_delete'),
+    path('rack-reservations/<int:pk>/', views.RackReservationView.as_view(), name='rackreservation'),
     path('rack-reservations/<int:pk>/edit/', views.RackReservationEditView.as_view(), name='rackreservation_edit'),
     path('rack-reservations/<int:pk>/delete/', views.RackReservationDeleteView.as_view(), name='rackreservation_delete'),
     path('rack-reservations/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rackreservation_changelog', kwargs={'model': RackReservation}),
@@ -69,7 +70,6 @@ urlpatterns = [
     path('racks/<int:pk>/edit/', views.RackEditView.as_view(), name='rack_edit'),
     path('racks/<int:pk>/delete/', views.RackDeleteView.as_view(), name='rack_delete'),
     path('racks/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='rack_changelog', kwargs={'model': Rack}),
-    path('racks/<int:rack>/reservations/add/', views.RackReservationCreateView.as_view(), name='rack_add_reservation'),
     path('racks/<int:object_id>/images/add/', ImageAttachmentEditView.as_view(), name='rack_add_image', kwargs={'model': Rack}),
 
     # Manufacturers
@@ -179,7 +179,6 @@ urlpatterns = [
     path('devices/<int:pk>/status/', views.DeviceStatusView.as_view(), name='device_status'),
     path('devices/<int:pk>/lldp-neighbors/', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'),
     path('devices/<int:pk>/config/', views.DeviceConfigView.as_view(), name='device_config'),
-    path('devices/<int:pk>/add-secret/', secret_add, name='device_addsecret'),
     path('devices/<int:device>/services/assign/', ServiceCreateView.as_view(), name='device_service_assign'),
     path('devices/<int:object_id>/images/add/', ImageAttachmentEditView.as_view(), name='device_add_image', kwargs={'model': Device}),
 

+ 17 - 7
netbox/dcim/views.py

@@ -479,20 +479,32 @@ class RackReservationListView(PermissionRequiredMixin, ObjectListView):
     action_buttons = ('export',)
 
 
+class RackReservationView(PermissionRequiredMixin, View):
+    permission_required = 'dcim.view_rackreservation'
+
+    def get(self, request, pk):
+
+        rackreservation = get_object_or_404(RackReservation.objects.prefetch_related('rack'), pk=pk)
+
+        return render(request, 'dcim/rackreservation.html', {
+            'rackreservation': rackreservation,
+        })
+
+
 class RackReservationCreateView(PermissionRequiredMixin, ObjectEditView):
     permission_required = 'dcim.add_rackreservation'
     model = RackReservation
     model_form = forms.RackReservationForm
+    template_name = 'dcim/rackreservation_edit.html'
+    default_return_url = 'dcim:rackreservation_list'
 
     def alter_obj(self, obj, request, args, kwargs):
         if not obj.pk:
-            obj.rack = get_object_or_404(Rack, pk=kwargs['rack'])
+            if 'rack' in request.GET:
+                obj.rack = get_object_or_404(Rack, pk=request.GET.get('rack'))
             obj.user = request.user
         return obj
 
-    def get_return_url(self, request, obj):
-        return obj.rack.get_absolute_url()
-
 
 class RackReservationEditView(RackReservationCreateView):
     permission_required = 'dcim.change_rackreservation'
@@ -501,9 +513,7 @@ class RackReservationEditView(RackReservationCreateView):
 class RackReservationDeleteView(PermissionRequiredMixin, ObjectDeleteView):
     permission_required = 'dcim.delete_rackreservation'
     model = RackReservation
-
-    def get_return_url(self, request, obj):
-        return obj.rack.get_absolute_url()
+    default_return_url = 'dcim:rackreservation_list'
 
 
 class RackReservationImportView(PermissionRequiredMixin, BulkImportView):

+ 3 - 2
netbox/extras/api/serializers.py

@@ -13,6 +13,7 @@ from extras.constants import *
 from extras.models import (
     ConfigContext, ExportTemplate, Graph, ImageAttachment, ObjectChange, ReportResult, Tag,
 )
+from extras.utils import FeatureQuerySet
 from tenancy.api.nested_serializers import NestedTenantSerializer, NestedTenantGroupSerializer
 from tenancy.models import Tenant, TenantGroup
 from users.api.nested_serializers import NestedUserSerializer
@@ -31,7 +32,7 @@ from .nested_serializers import *
 
 class GraphSerializer(ValidatedModelSerializer):
     type = ContentTypeField(
-        queryset=ContentType.objects.filter(GRAPH_MODELS),
+        queryset=ContentType.objects.filter(FeatureQuerySet('graphs').get_queryset()),
     )
 
     class Meta:
@@ -67,7 +68,7 @@ class RenderedGraphSerializer(serializers.ModelSerializer):
 
 class ExportTemplateSerializer(ValidatedModelSerializer):
     content_type = ContentTypeField(
-        queryset=ContentType.objects.filter(EXPORTTEMPLATE_MODELS),
+        queryset=ContentType.objects.filter(FeatureQuerySet('export_templates').get_queryset()),
     )
     template_language = ChoiceField(
         choices=TemplateLanguageChoices,

+ 9 - 172
netbox/extras/constants.py

@@ -1,129 +1,3 @@
-from django.db.models import Q
-
-
-# Models which support custom fields
-CUSTOMFIELD_MODELS = Q(
-    Q(app_label='circuits', model__in=[
-        'circuit',
-        'provider',
-    ]) |
-    Q(app_label='dcim', model__in=[
-        'device',
-        'devicetype',
-        'powerfeed',
-        'rack',
-        'site',
-    ]) |
-    Q(app_label='ipam', model__in=[
-        'aggregate',
-        'ipaddress',
-        'prefix',
-        'service',
-        'vlan',
-        'vrf',
-    ]) |
-    Q(app_label='secrets', model__in=[
-        'secret',
-    ]) |
-    Q(app_label='tenancy', model__in=[
-        'tenant',
-    ]) |
-    Q(app_label='virtualization', model__in=[
-        'cluster',
-        'virtualmachine',
-    ])
-)
-
-# Custom links
-CUSTOMLINK_MODELS = Q(
-    Q(app_label='circuits', model__in=[
-        'circuit',
-        'provider',
-    ]) |
-    Q(app_label='dcim', model__in=[
-        'cable',
-        'device',
-        'devicetype',
-        'powerpanel',
-        'powerfeed',
-        'rack',
-        'site',
-    ]) |
-    Q(app_label='ipam', model__in=[
-        'aggregate',
-        'ipaddress',
-        'prefix',
-        'service',
-        'vlan',
-        'vrf',
-    ]) |
-    Q(app_label='secrets', model__in=[
-        'secret',
-    ]) |
-    Q(app_label='tenancy', model__in=[
-        'tenant',
-    ]) |
-    Q(app_label='virtualization', model__in=[
-        'cluster',
-        'virtualmachine',
-    ])
-)
-
-# Models which can have Graphs associated with them
-GRAPH_MODELS = Q(
-    Q(app_label='circuits', model__in=[
-        'provider',
-    ]) |
-    Q(app_label='dcim', model__in=[
-        'device',
-        'interface',
-        'site',
-    ])
-)
-
-# Models which support export templates
-EXPORTTEMPLATE_MODELS = Q(
-    Q(app_label='circuits', model__in=[
-        'circuit',
-        'provider',
-    ]) |
-    Q(app_label='dcim', model__in=[
-        'cable',
-        'consoleport',
-        'device',
-        'devicetype',
-        'interface',
-        'inventoryitem',
-        'manufacturer',
-        'powerpanel',
-        'powerport',
-        'powerfeed',
-        'rack',
-        'rackgroup',
-        'region',
-        'site',
-        'virtualchassis',
-    ]) |
-    Q(app_label='ipam', model__in=[
-        'aggregate',
-        'ipaddress',
-        'prefix',
-        'service',
-        'vlan',
-        'vrf',
-    ]) |
-    Q(app_label='secrets', model__in=[
-        'secret',
-    ]) |
-    Q(app_label='tenancy', model__in=[
-        'tenant',
-    ]) |
-    Q(app_label='virtualization', model__in=[
-        'cluster',
-        'virtualmachine',
-    ])
-)
-
 # Report logging levels
 LOG_DEFAULT = 0
 LOG_SUCCESS = 10
@@ -138,51 +12,14 @@ LOG_LEVEL_CODES = {
     LOG_FAILURE: 'failure',
 }
 
+# Webhook content types
 HTTP_CONTENT_TYPE_JSON = 'application/json'
 
-# Models which support registered webhooks
-WEBHOOK_MODELS = Q(
-    Q(app_label='circuits', model__in=[
-        'circuit',
-        'provider',
-    ]) |
-    Q(app_label='dcim', model__in=[
-        'cable',
-        'consoleport',
-        'consoleserverport',
-        'device',
-        'devicebay',
-        'devicetype',
-        'frontport',
-        'interface',
-        'inventoryitem',
-        'manufacturer',
-        'poweroutlet',
-        'powerpanel',
-        'powerport',
-        'powerfeed',
-        'rack',
-        'rearport',
-        'region',
-        'site',
-        'virtualchassis',
-    ]) |
-    Q(app_label='ipam', model__in=[
-        'aggregate',
-        'ipaddress',
-        'prefix',
-        'service',
-        'vlan',
-        'vrf',
-    ]) |
-    Q(app_label='secrets', model__in=[
-        'secret',
-    ]) |
-    Q(app_label='tenancy', model__in=[
-        'tenant',
-    ]) |
-    Q(app_label='virtualization', model__in=[
-        'cluster',
-        'virtualmachine',
-    ])
-)
+# Registerable extras features
+EXTRAS_FEATURES = [
+    'custom_fields',
+    'custom_links',
+    'graphs',
+    'export_templates',
+    'webhooks'
+]

+ 40 - 0
netbox/extras/migrations/0039_update_features_content_types.py

@@ -0,0 +1,40 @@
+# Generated by Django 2.2.11 on 2020-03-14 06:50
+
+from django.db import migrations, models
+import django.db.models.deletion
+import extras.utils
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('extras', '0038_webhook_template_support'),
+    ]
+
+    operations = [
+        migrations.AlterField(
+            model_name='customfield',
+            name='obj_type',
+            field=models.ManyToManyField(limit_choices_to=extras.utils.FeatureQuerySet('custom_fields'), related_name='custom_fields', to='contenttypes.ContentType'),
+        ),
+        migrations.AlterField(
+            model_name='customlink',
+            name='content_type',
+            field=models.ForeignKey(limit_choices_to=extras.utils.FeatureQuerySet('custom_links'), on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType'),
+        ),
+        migrations.AlterField(
+            model_name='exporttemplate',
+            name='content_type',
+            field=models.ForeignKey(limit_choices_to=extras.utils.FeatureQuerySet('export_templates'), on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType'),
+        ),
+        migrations.AlterField(
+            model_name='graph',
+            name='type',
+            field=models.ForeignKey(limit_choices_to=extras.utils.FeatureQuerySet('graphs'), on_delete=django.db.models.deletion.CASCADE, to='contenttypes.ContentType'),
+        ),
+        migrations.AlterField(
+            model_name='webhook',
+            name='obj_type',
+            field=models.ManyToManyField(limit_choices_to=extras.utils.FeatureQuerySet('webhooks'), related_name='webhooks', to='contenttypes.ContentType'),
+        ),
+    ]

+ 6 - 5
netbox/extras/models.py

@@ -22,6 +22,7 @@ from utilities.utils import deepmerge, render_jinja2
 from .choices import *
 from .constants import *
 from .querysets import ConfigContextQuerySet
+from .utils import FeatureQuerySet
 
 
 __all__ = (
@@ -58,7 +59,7 @@ class Webhook(models.Model):
         to=ContentType,
         related_name='webhooks',
         verbose_name='Object types',
-        limit_choices_to=WEBHOOK_MODELS,
+        limit_choices_to=FeatureQuerySet('webhooks'),
         help_text="The object(s) to which this Webhook applies."
     )
     name = models.CharField(
@@ -223,7 +224,7 @@ class CustomField(models.Model):
         to=ContentType,
         related_name='custom_fields',
         verbose_name='Object(s)',
-        limit_choices_to=CUSTOMFIELD_MODELS,
+        limit_choices_to=FeatureQuerySet('custom_fields'),
         help_text='The object(s) to which this field applies.'
     )
     type = models.CharField(
@@ -470,7 +471,7 @@ class CustomLink(models.Model):
     content_type = models.ForeignKey(
         to=ContentType,
         on_delete=models.CASCADE,
-        limit_choices_to=CUSTOMLINK_MODELS
+        limit_choices_to=FeatureQuerySet('custom_links')
     )
     name = models.CharField(
         max_length=100,
@@ -518,7 +519,7 @@ class Graph(models.Model):
     type = models.ForeignKey(
         to=ContentType,
         on_delete=models.CASCADE,
-        limit_choices_to=GRAPH_MODELS
+        limit_choices_to=FeatureQuerySet('graphs')
     )
     weight = models.PositiveSmallIntegerField(
         default=1000
@@ -579,7 +580,7 @@ class ExportTemplate(models.Model):
     content_type = models.ForeignKey(
         to=ContentType,
         on_delete=models.CASCADE,
-        limit_choices_to=EXPORTTEMPLATE_MODELS
+        limit_choices_to=FeatureQuerySet('export_templates')
     )
     name = models.CharField(
         max_length=100

+ 1 - 0
netbox/extras/tests/test_api.py

@@ -9,6 +9,7 @@ from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Platform,
 from extras.api.views import ScriptViewSet
 from extras.models import ConfigContext, Graph, ExportTemplate, Tag
 from extras.scripts import BooleanVar, IntegerVar, Script, StringVar
+from extras.utils import FeatureQuerySet
 from tenancy.models import Tenant, TenantGroup
 from utilities.testing import APITestCase
 

+ 3 - 3
netbox/extras/tests/test_filters.py

@@ -3,8 +3,8 @@ from django.test import TestCase
 
 from dcim.models import DeviceRole, Platform, Region, Site
 from extras.choices import *
-from extras.constants import GRAPH_MODELS
 from extras.filters import *
+from extras.utils import FeatureQuerySet
 from extras.models import ConfigContext, ExportTemplate, Graph
 from tenancy.models import Tenant, TenantGroup
 from virtualization.models import Cluster, ClusterGroup, ClusterType
@@ -18,7 +18,7 @@ class GraphTestCase(TestCase):
     def setUpTestData(cls):
 
         # Get the first three available types
-        content_types = ContentType.objects.filter(GRAPH_MODELS)[:3]
+        content_types = ContentType.objects.filter(FeatureQuerySet('graphs').get_queryset())[:3]
 
         graphs = (
             Graph(name='Graph 1', type=content_types[0], template_language=TemplateLanguageChoices.LANGUAGE_DJANGO, source='http://example.com/1'),
@@ -32,7 +32,7 @@ class GraphTestCase(TestCase):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
     def test_type(self):
-        content_type = ContentType.objects.filter(GRAPH_MODELS).first()
+        content_type = ContentType.objects.filter(FeatureQuerySet('graphs').get_queryset()).first()
         params = {'type': content_type.pk}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 

+ 68 - 0
netbox/extras/utils.py

@@ -1,6 +1,12 @@
+import collections
+
+from django.db.models import Q
+from django.utils.deconstruct import deconstructible
 from taggit.managers import _TaggableManager
 from utilities.querysets import DummyQuerySet
 
+from extras.constants import EXTRAS_FEATURES
+
 
 def is_taggable(obj):
     """
@@ -13,3 +19,65 @@ def is_taggable(obj):
         if isinstance(obj.tags, DummyQuerySet):
             return True
     return False
+
+
+#
+# Dynamic feature registration
+#
+
+class Registry:
+    """
+    The registry is a place to hook into for data storage across components
+    """
+
+    def add_store(self, store_name, initial_value=None):
+        """
+        Given the name of some new data parameter and an optional initial value, setup the registry store
+        """
+        if not hasattr(Registry, store_name):
+            setattr(Registry, store_name, initial_value)
+
+
+registry = Registry()
+
+
+@deconstructible
+class FeatureQuerySet:
+    """
+    Helper class that delays evaluation of the registry contents for the functionaility store
+    until it has been populated.
+    """
+
+    def __init__(self, feature):
+        self.feature = feature
+
+    def __call__(self):
+        return self.get_queryset()
+
+    def get_queryset(self):
+        """
+        Given an extras feature, return a Q object for content type lookup
+        """
+        query = Q()
+        for app_label, models in registry.model_feature_store[self.feature].items():
+            query |= Q(app_label=app_label, model__in=models)
+
+        return query
+
+
+registry.add_store('model_feature_store', {f: collections.defaultdict(list) for f in EXTRAS_FEATURES})
+
+
+def extras_features(*features):
+    """
+    Decorator used to register extras provided features to a model
+    """
+    def wrapper(model_class):
+        for feature in features:
+            if feature in EXTRAS_FEATURES:
+                app_label, model_name = model_class._meta.label_lower.split('.')
+                registry.model_feature_store[feature][app_label].append(model_name)
+            else:
+                raise ValueError('{} is not a valid extras feature!'.format(feature))
+        return model_class
+    return wrapper

+ 2 - 1
netbox/extras/webhooks.py

@@ -8,6 +8,7 @@ from extras.models import Webhook
 from utilities.api import get_serializer_for_model
 from .choices import *
 from .constants import *
+from .utils import FeatureQuerySet
 
 
 def generate_signature(request_body, secret):
@@ -29,7 +30,7 @@ def enqueue_webhooks(instance, user, request_id, action):
     """
     obj_type = ContentType.objects.get_for_model(instance.__class__)
 
-    webhook_models = ContentType.objects.filter(WEBHOOK_MODELS)
+    webhook_models = ContentType.objects.filter(FeatureQuerySet('webhooks').get_queryset())
     if obj_type not in webhook_models:
         return
 

+ 7 - 0
netbox/ipam/models.py

@@ -10,6 +10,7 @@ from taggit.managers import TaggableManager
 
 from dcim.models import Device, Interface
 from extras.models import CustomFieldModel, ObjectChange, TaggedItem
+from extras.utils import extras_features
 from utilities.models import ChangeLoggedModel
 from utilities.utils import serialize_object
 from virtualization.models import VirtualMachine
@@ -34,6 +35,7 @@ __all__ = (
 )
 
 
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
 class VRF(ChangeLoggedModel, CustomFieldModel):
     """
     A virtual routing and forwarding (VRF) table represents a discrete layer three forwarding domain (e.g. a routing
@@ -150,6 +152,7 @@ class RIR(ChangeLoggedModel):
         )
 
 
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
 class Aggregate(ChangeLoggedModel, CustomFieldModel):
     """
     An aggregate exists at the root level of the IP address space hierarchy in NetBox. Aggregates are used to organize
@@ -287,6 +290,7 @@ class Role(ChangeLoggedModel):
         )
 
 
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
 class Prefix(ChangeLoggedModel, CustomFieldModel):
     """
     A Prefix represents an IPv4 or IPv6 network, including mask length. Prefixes can optionally be assigned to Sites and
@@ -552,6 +556,7 @@ class Prefix(ChangeLoggedModel, CustomFieldModel):
             return int(float(child_count) / prefix_size * 100)
 
 
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
 class IPAddress(ChangeLoggedModel, CustomFieldModel):
     """
     An IPAddress represents an individual IPv4 or IPv6 address and its mask. The mask length should match what is
@@ -858,6 +863,7 @@ class VLANGroup(ChangeLoggedModel):
         return None
 
 
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
 class VLAN(ChangeLoggedModel, CustomFieldModel):
     """
     A VLAN is a distinct layer two forwarding domain identified by a 12-bit integer (1-4094). Each VLAN must be assigned
@@ -982,6 +988,7 @@ class VLAN(ChangeLoggedModel, CustomFieldModel):
         ).distinct()
 
 
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
 class Service(ChangeLoggedModel, CustomFieldModel):
     """
     A Service represents a layer-four service (e.g. HTTP or SSH) running on a Device or VirtualMachine. A Service may

+ 7 - 1
netbox/secrets/forms.py

@@ -71,6 +71,12 @@ class SecretRoleCSVForm(forms.ModelForm):
 #
 
 class SecretForm(BootstrapMixin, CustomFieldModelForm):
+    device = DynamicModelChoiceField(
+        queryset=Device.objects.all(),
+        widget=APISelect(
+            api_url="/api/dcim/devices/"
+        )
+    )
     plaintext = forms.CharField(
         max_length=SECRET_PLAINTEXT_MAX_LENGTH,
         required=False,
@@ -100,7 +106,7 @@ class SecretForm(BootstrapMixin, CustomFieldModelForm):
     class Meta:
         model = Secret
         fields = [
-            'role', 'name', 'plaintext', 'plaintext2', 'tags',
+            'device', 'role', 'name', 'plaintext', 'plaintext2', 'tags',
         ]
 
     def __init__(self, *args, **kwargs):

+ 2 - 0
netbox/secrets/models.py

@@ -16,6 +16,7 @@ from taggit.managers import TaggableManager
 
 from dcim.models import Device
 from extras.models import CustomFieldModel, TaggedItem
+from extras.utils import extras_features
 from utilities.models import ChangeLoggedModel
 from .exceptions import InvalidKey
 from .hashers import SecretValidationHasher
@@ -295,6 +296,7 @@ class SecretRole(ChangeLoggedModel):
         return user in self.users.all() or user.groups.filter(pk__in=self.groups.all()).exists()
 
 
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
 class Secret(ChangeLoggedModel, CustomFieldModel):
     """
     A Secret stores an AES256-encrypted copy of sensitive data, such as passwords or secret keys. An irreversible

+ 1 - 0
netbox/secrets/urls.py

@@ -17,6 +17,7 @@ urlpatterns = [
 
     # Secrets
     path('secrets/', views.SecretListView.as_view(), name='secret_list'),
+    path('secrets/add/', views.secret_add, name='secret_add'),
     path('secrets/import/', views.SecretBulkImportView.as_view(), name='secret_import'),
     path('secrets/edit/', views.SecretBulkEditView.as_view(), name='secret_bulk_edit'),
     path('secrets/delete/', views.SecretBulkDeleteView.as_view(), name='secret_bulk_delete'),

+ 9 - 10
netbox/secrets/views.py

@@ -8,9 +8,8 @@ from django.shortcuts import get_object_or_404, redirect, render
 from django.urls import reverse
 from django.views.generic import View
 
-from dcim.models import Device
 from utilities.views import (
-    BulkDeleteView, BulkEditView, BulkImportView, ObjectDeleteView, ObjectEditView, ObjectListView,
+    BulkDeleteView, BulkEditView, BulkImportView, GetReturnURLMixin, ObjectDeleteView, ObjectEditView, ObjectListView,
 )
 from . import filters, forms, tables
 from .decorators import userkey_required
@@ -89,12 +88,9 @@ class SecretView(PermissionRequiredMixin, View):
 
 @permission_required('secrets.add_secret')
 @userkey_required()
-def secret_add(request, pk):
+def secret_add(request):
 
-    # Retrieve device
-    device = get_object_or_404(Device, pk=pk)
-
-    secret = Secret(device=device)
+    secret = Secret()
     session_key = get_session_key(request)
 
     if request.method == 'POST':
@@ -123,17 +119,20 @@ def secret_add(request, pk):
 
                     messages.success(request, "Added new secret: {}.".format(secret))
                     if '_addanother' in request.POST:
-                        return redirect('dcim:device_addsecret', pk=device.pk)
+                        return redirect('secrets:secret_add')
                     else:
                         return redirect('secrets:secret', pk=secret.pk)
 
     else:
-        form = forms.SecretForm(instance=secret)
+        initial_data = {
+            'device': request.GET.get('device'),
+        }
+        form = forms.SecretForm(initial=initial_data)
 
     return render(request, 'secrets/secret_edit.html', {
         'secret': secret,
         'form': form,
-        'return_url': device.get_absolute_url(),
+        'return_url': GetReturnURLMixin().get_return_url(request, secret)
     })
 
 

+ 1 - 1
netbox/templates/dcim/device.html

@@ -426,7 +426,7 @@
                             {% csrf_token %}
                         </form>
                         <div class="panel-footer text-right noprint">
-                            <a href="{% url 'dcim:device_addsecret' pk=device.pk %}" class="btn btn-xs btn-primary">
+                            <a href="{% url 'secrets:secret_add' %}?device={{ device.pk }}&return_url={{ device.get_absolute_url }}" class="btn btn-xs btn-primary">
                                 <span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
                                 Add secret
                             </a>

+ 6 - 4
netbox/templates/dcim/rack.html

@@ -271,7 +271,9 @@
                     </tr>
                     {% for resv in reservations %}
                         <tr>
-                            <td>{{ resv.unit_list }}</td>
+                            <td>
+                                <a href="{{ resv.get_absolute_url }}">{{ resv.unit_list }}</a>
+                            </td>
                             <td>
                                 {% if resv.tenant %}
                                     <a href="{{ resv.tenant.get_absolute_url }}">{{ resv.tenant }}</a>
@@ -285,12 +287,12 @@
                             </td>
                             <td class="text-right noprint">
                                 {% if perms.dcim.change_rackreservation %}
-                                    <a href="{% url 'dcim:rackreservation_edit' pk=resv.pk %}" class="btn btn-warning btn-xs" title="Edit reservation">
+                                    <a href="{% url 'dcim:rackreservation_edit' pk=resv.pk %}&return_url={{ rack.get_absolute_url }}" class="btn btn-warning btn-xs" title="Edit reservation">
                                         <i class="glyphicon glyphicon-pencil" aria-hidden="true"></i>
                                     </a>
                                 {% endif %}
                                 {% if perms.dcim.delete_rackreservation %}
-                                    <a href="{% url 'dcim:rackreservation_delete' pk=resv.pk %}" class="btn btn-danger btn-xs" title="Delete reservation">
+                                    <a href="{% url 'dcim:rackreservation_delete' pk=resv.pk %}&return_url={{ rack.get_absolute_url }}" class="btn btn-danger btn-xs" title="Delete reservation">
                                         <i class="glyphicon glyphicon-trash" aria-hidden="true"></i>
                                     </a>
                                 {% endif %}
@@ -303,7 +305,7 @@
             {% endif %}
             {% if perms.dcim.add_rackreservation %}
                 <div class="panel-footer text-right noprint">
-                    <a href="{% url 'dcim:rack_add_reservation' rack=rack.pk %}" class="btn btn-primary btn-xs">
+                    <a href="{% url 'dcim:rackreservation_add' %}?rack={{ rack.pk }}&return_url={{ rack.get_absolute_url }}" class="btn btn-primary btn-xs">
                         <span class="glyphicon glyphicon-plus" aria-hidden="true"></span>
                         Add a reservation
                     </a>

+ 146 - 0
netbox/templates/dcim/rackreservation.html

@@ -0,0 +1,146 @@
+{% extends '_base.html' %}
+{% load buttons %}
+{% load custom_links %}
+{% load helpers %}
+{% load static %}
+
+{% block header %}
+    <div class="row noprint">
+        <div class="col-sm-8 col-md-9">
+            <ol class="breadcrumb">
+                <li><a href="{% url 'dcim:rackreservation_list' %}">Rack Reservations</a></li>
+                <li><a href="{{ rackreservation.rack.get_absolute_url }}">{{ rackreservation.rack }}</a></li>
+                <li>Units {{ rackreservation.unit_list }}</li>
+            </ol>
+        </div>
+        <div class="col-sm-4 col-md-3">
+            <form action="{% url 'dcim:rackreservation_list' %}" method="get">
+                <div class="input-group">
+                    <input type="text" name="q" class="form-control" placeholder="Search racks" />
+                    <span class="input-group-btn">
+                        <button type="submit" class="btn btn-primary">
+                            <span class="fa fa-search" aria-hidden="true"></span>
+                        </button>
+                    </span>
+                </div>
+            </form>
+        </div>
+    </div>
+    <div class="pull-right noprint">
+        {% if perms.dcim.change_rackreservation %}
+            {% edit_button rackreservation %}
+        {% endif %}
+        {% if perms.dcim.delete_rackreservation %}
+            {% delete_button rackreservation %}
+        {% endif %}
+    </div>
+    <h1>{% block title %}{{ rackreservation }}{% endblock %}</h1>
+    {% include 'inc/created_updated.html' with obj=rackreservation %}
+    <ul class="nav nav-tabs">
+        <li role="presentation"{% if not active_tab %} class="active"{% endif %}>
+            <a href="{{ rackreservation.get_absolute_url }}">Rack</a>
+        </li>
+        {% if perms.extras.view_objectchange %}
+            <li role="presentation"{% if active_tab == 'changelog' %} class="active"{% endif %}>
+                <a href="{% url 'dcim:rackreservation_changelog' pk=rackreservation.pk %}">Change Log</a>
+            </li>
+        {% endif %}
+    </ul>
+{% endblock %}
+
+{% block content %}
+<div class="row">
+	<div class="col-md-6">
+        <div class="panel panel-default">
+            <div class="panel-heading">
+                <strong>Rack</strong>
+            </div>
+            <table class="table table-hover panel-body attr-table">
+                {% with rack=rackreservation.rack %}
+                    <tr>
+                        <td>Site</td>
+                        <td>
+                            {% if rack.site.region %}
+                                <a href="{{ rack.site.region.get_absolute_url }}">{{ rack.site.region }}</a>
+                                <i class="fa fa-angle-right"></i>
+                            {% endif %}
+                            <a href="{% url 'dcim:site' slug=rack.site.slug %}">{{ rack.site }}</a>
+                        </td>
+                    </tr>
+                    <tr>
+                        <td>Group</td>
+                        <td>
+                            {% if rack.group %}
+                                <a href="{% url 'dcim:rack_list' %}?site={{ rack.site.slug }}&group={{ rack.group.slug }}">{{ rack.group }}</a>
+                            {% else %}
+                                <span class="text-muted">None</span>
+                            {% endif %}
+                        </td>
+                    </tr>
+                    <tr>
+                        <td>Rack</td>
+                        <td>
+                            <a href="{{ rack.get_absolute_url }}">{{ rack }}</a>
+                        </td>
+                    </tr>
+                {% endwith %}
+            </table>
+        </div>
+        <div class="panel panel-default">
+            <div class="panel-heading">
+                <strong>Reservation Details</strong>
+            </div>
+            <table class="table table-hover panel-body attr-table">
+                <tr>
+                    <td>Units</td>
+                    <td>{{ rackreservation.unit_list }}</td>
+                </tr>
+                <tr>
+                    <td>Tenant</td>
+                    <td>
+                        {% if rackreservation.tenant %}
+                            {% if rackreservation.tenant.group %}
+                                <a href="{{ rackreservation.tenant.group.get_absolute_url }}">{{ rackreservation.tenant.group }}</a>
+                                <i class="fa fa-angle-right"></i>
+                            {% endif %}
+                            <a href="{{ rackreservation.tenant.get_absolute_url }}">{{ rackreservation.tenant }}</a>
+                        {% else %}
+                            <span class="text-muted">None</span>
+                        {% endif %}
+                    </td>
+                </tr>
+                <tr>
+                    <td>User</td>
+                    <td>{{ rackreservation.user }}</td>
+                </tr>
+                <tr>
+                    <td>Description</td>
+                    <td>{{ rackreservation.description }}</td>
+                </tr>
+            </table>
+        </div>
+	</div>
+    <div class="col-md-6">
+        {% with rack=rackreservation.rack %}
+            <div class="row" style="margin-bottom: 20px">
+                <div class="col-md-6 col-sm-6 col-xs-12">
+                    <div class="rack_header">
+                        <h4>Front</h4>
+                    </div>
+                    {% include 'dcim/inc/rack_elevation.html' with face='front' %}
+                </div>
+                <div class="col-md-6 col-sm-6 col-xs-12">
+                    <div class="rack_header">
+                        <h4>Rear</h4>
+                    </div>
+                    {% include 'dcim/inc/rack_elevation.html' with face='rear' %}
+                </div>
+            </div>
+        {% endwith %}
+    </div>
+</div>
+{% endblock %}
+
+{% block javascript %}
+<script src="{% static 'js/rack_elevations.js' %}?v{{ settings.VERSION }}"></script>
+{% endblock %}

+ 21 - 0
netbox/templates/dcim/rackreservation_edit.html

@@ -0,0 +1,21 @@
+{% extends 'utilities/obj_edit.html' %}
+{% load form_helpers %}
+
+{% block form %}
+    <div class="panel panel-default">
+        <div class="panel-heading"><strong>{{ obj_type|capfirst }}</strong></div>
+        <div class="panel-body">
+            <div class="form-group">
+                <label class="col-md-3 control-label">Rack</label>
+                <div class="col-md-9">
+                    <p class="form-control-static">{{ obj.rack }}</p>
+                </div>
+            </div>
+            {% render_field form.units %}
+            {% render_field form.user %}
+            {% render_field form.tenant_group %}
+            {% render_field form.tenant %}
+            {% render_field form.description %}
+        </div>
+    </div>
+{% endblock %}

+ 1 - 1
netbox/templates/inc/custom_fields_panel.html

@@ -7,7 +7,7 @@
             <table class="table table-hover panel-body attr-table">
                 {% for field, value in custom_fields.items %}
                     <tr>
-                        <td>{{ field }}</td>
+                        <td><span title="{{ field.description }}">{{ field }}</span></td>
                         <td>
                             {% if field.type == 'boolean' and value == True %}
                                 <i class="glyphicon glyphicon-ok text-success" title="True"></i>

+ 1 - 0
netbox/templates/inc/nav_menu.html

@@ -462,6 +462,7 @@
                         <li{% if not perms.secrets.view_secret %} class="disabled"{% endif %}>
                             {% if perms.secrets.add_secret %}
                                 <div class="buttons pull-right">
+                                    <a href="{% url 'secrets:secret_add' %}" class="btn btn-xs btn-success" title="Add"><i class="fa fa-plus"></i></a>
                                     <a href="{% url 'secrets:secret_import' %}" class="btn btn-xs btn-info" title="Import"><i class="fa fa-download"></i></a>
                                 </div>
                             {% endif %}

+ 1 - 6
netbox/templates/secrets/secret_edit.html

@@ -21,12 +21,7 @@
             <div class="panel panel-default">
                 <div class="panel-heading"><strong>Secret Attributes</strong></div>
                 <div class="panel-body">
-                    <div class="form-group">
-                        <label class="col-md-3 control-label required">Device</label>
-                        <div class="col-md-9">
-                            <p class="form-control-static">{{ secret.device }}</p>
-                        </div>
-                    </div>
+                    {% render_field form.device %}
                     {% render_field form.role %}
                     {% render_field form.name %}
                     {% render_field form.userkeys %}

+ 1 - 1
netbox/templates/utilities/obj_edit.html

@@ -51,7 +51,7 @@
             </div>
         </div>
     </form>
-    {% if settings.DOCS_ROOT %}
+    {% if obj and settings.DOCS_ROOT %}
         {% include 'inc/modal.html' with name='docs' content=obj|get_docs %}
     {% endif %}
 {% endblock %}

+ 2 - 0
netbox/tenancy/models.py

@@ -5,6 +5,7 @@ from mptt.models import MPTTModel, TreeForeignKey
 from taggit.managers import TaggableManager
 
 from extras.models import CustomFieldModel, ObjectChange, TaggedItem
+from extras.utils import extras_features
 from utilities.models import ChangeLoggedModel
 from utilities.utils import serialize_object
 
@@ -71,6 +72,7 @@ class TenantGroup(MPTTModel, ChangeLoggedModel):
         )
 
 
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
 class Tenant(ChangeLoggedModel, CustomFieldModel):
     """
     A Tenant represents an organization served by the NetBox owner. This is typically a customer or an internal

+ 1 - 0
netbox/utilities/api.py

@@ -235,6 +235,7 @@ class ValidatedModelSerializer(ModelSerializer):
             for k, v in attrs.items():
                 setattr(instance, k, v)
         instance.clean()
+        instance.validate_unique()
 
         return data
 

+ 2 - 2
netbox/utilities/templatetags/helpers.py

@@ -40,7 +40,7 @@ def render_markdown(value):
     value = strip_tags(value)
 
     # Render Markdown
-    html = markdown(value, extensions=['fenced_code'])
+    html = markdown(value, extensions=['fenced_code', 'tables'])
 
     return mark_safe(html)
 
@@ -196,7 +196,7 @@ def get_docs(model):
         return "Unable to load documentation, error reading file: {}".format(path)
 
     # Render Markdown with the admonition extension
-    content = markdown(content, extensions=['admonition', 'fenced_code'])
+    content = markdown(content, extensions=['admonition', 'fenced_code', 'tables'])
 
     return mark_safe(content)
 

+ 3 - 0
netbox/virtualization/models.py

@@ -7,6 +7,7 @@ from taggit.managers import TaggableManager
 
 from dcim.models import Device
 from extras.models import ConfigContextModel, CustomFieldModel, TaggedItem
+from extras.utils import extras_features
 from utilities.models import ChangeLoggedModel
 from .choices import *
 
@@ -101,6 +102,7 @@ class ClusterGroup(ChangeLoggedModel):
 # Clusters
 #
 
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
 class Cluster(ChangeLoggedModel, CustomFieldModel):
     """
     A cluster of VirtualMachines. Each Cluster may optionally be associated with one or more Devices.
@@ -187,6 +189,7 @@ class Cluster(ChangeLoggedModel, CustomFieldModel):
 # Virtual machines
 #
 
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
 class VirtualMachine(ChangeLoggedModel, ConfigContextModel, CustomFieldModel):
     """
     A virtual machine which runs inside a Cluster.

+ 12 - 0
netbox/virtualization/tests/test_api.py

@@ -488,6 +488,18 @@ class VirtualMachineTest(APITestCase):
 
         self.assertFalse('config_context' in response.data['results'][0])
 
+    def test_unique_name_per_cluster_constraint(self):
+
+        data = {
+            'name': 'Test Virtual Machine 1',
+            'cluster': self.cluster1.pk,
+        }
+
+        url = reverse('virtualization-api:virtualmachine-list')
+        response = self.client.post(url, data, format='json', **self.header)
+
+        self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
+
 
 class InterfaceTest(APITestCase):