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

Merge pull request #6700 from netbox-community/develop

Release v2.11.8
Jeremy Stretch 4 лет назад
Родитель
Сommit
a5b95728bf
50 измененных файлов с 325 добавлено и 185 удалено
  1. 1 1
      .github/ISSUE_TEMPLATE/bug_report.yaml
  2. 1 1
      .github/ISSUE_TEMPLATE/feature_request.yaml
  3. 24 0
      docs/release-notes/version-2.11.md
  4. 19 14
      docs/rest-api/filtering.md
  5. 14 4
      netbox/dcim/forms.py
  6. 16 11
      netbox/dcim/models/device_component_templates.py
  7. 6 4
      netbox/dcim/tests/test_views.py
  8. 2 0
      netbox/dcim/views.py
  9. 1 0
      netbox/extras/apps.py
  10. 17 0
      netbox/extras/lookups.py
  11. 4 2
      netbox/extras/models/customfields.py
  12. 2 3
      netbox/extras/models/models.py
  13. 1 1
      netbox/ipam/tables.py
  14. 21 5
      netbox/ipam/utils.py
  15. 1 2
      netbox/ipam/views.py
  16. 10 4
      netbox/netbox/constants.py
  17. 5 8
      netbox/netbox/filtersets.py
  18. 3 2
      netbox/netbox/forms.py
  19. 1 1
      netbox/netbox/settings.py
  20. 20 18
      netbox/netbox/views/generic.py
  21. 1 1
      netbox/templates/base.html
  22. 1 1
      netbox/templates/circuits/circuit.html
  23. 20 4
      netbox/templates/dcim/device_component_add.html
  24. 11 1
      netbox/templates/dcim/devicerole.html
  25. 11 1
      netbox/templates/dcim/interface.html
  26. 1 1
      netbox/templates/dcim/rack.html
  27. 5 1
      netbox/templates/dcim/site.html
  28. 1 1
      netbox/templates/extras/journalentry.html
  29. 1 1
      netbox/templates/extras/objectchange.html
  30. 1 1
      netbox/templates/extras/report.html
  31. 1 1
      netbox/templates/extras/report_list.html
  32. 1 1
      netbox/templates/extras/report_result.html
  33. 1 1
      netbox/templates/extras/script_list.html
  34. 2 2
      netbox/templates/extras/script_result.html
  35. 2 2
      netbox/templates/generic/object.html
  36. 1 1
      netbox/templates/home.html
  37. 2 1
      netbox/templates/inc/image_attachments.html
  38. 1 1
      netbox/templates/ipam/aggregate.html
  39. 0 23
      netbox/templates/ipam/rir_list.html
  40. 2 2
      netbox/templates/users/api_tokens.html
  41. 1 1
      netbox/templates/users/profile.html
  42. 5 2
      netbox/templates/users/userkey.html
  43. 1 1
      netbox/templates/virtualization/virtualmachine.html
  44. 0 38
      netbox/templates/virtualization/virtualmachine_component_add.html
  45. 2 1
      netbox/utilities/constants.py
  46. 8 0
      netbox/utilities/exceptions.py
  47. 46 0
      netbox/utilities/templatetags/helpers.py
  48. 16 8
      netbox/virtualization/forms.py
  49. 7 1
      netbox/virtualization/views.py
  50. 4 4
      requirements.txt

+ 1 - 1
.github/ISSUE_TEMPLATE/bug_report.yaml

@@ -17,7 +17,7 @@ body:
         What version of NetBox are you currently running? (If you don't have access to the most
         recent NetBox release, consider testing on our [demo instance](https://demo.netbox.dev/)
         before opening a bug report to see if your issue has already been addressed.)
-      placeholder: v2.11.7
+      placeholder: v2.11.8
     validations:
       required: true
   - type: dropdown

+ 1 - 1
.github/ISSUE_TEMPLATE/feature_request.yaml

@@ -14,7 +14,7 @@ body:
     attributes:
       label: NetBox version
       description: What version of NetBox are you currently running?
-      placeholder: v2.11.7
+      placeholder: v2.11.8
     validations:
       required: true
   - type: dropdown

+ 24 - 0
docs/release-notes/version-2.11.md

@@ -1,5 +1,29 @@
 # NetBox v2.11
 
+## v2.11.8 (2021-07-06)
+
+### Enhancements
+
+* [#5503](https://github.com/netbox-community/netbox/issues/5503) - Annotate short date & time fields with their longer form
+* [#6138](https://github.com/netbox-community/netbox/issues/6138) - Add an `empty` filter modifier for character fields
+* [#6200](https://github.com/netbox-community/netbox/issues/6200) - Add rack reservations to global search
+* [#6368](https://github.com/netbox-community/netbox/issues/6368) - Enable virtual chassis assignment during bulk import of devices
+* [#6620](https://github.com/netbox-community/netbox/issues/6620) - Show assigned VMs count under device role view
+* [#6666](https://github.com/netbox-community/netbox/issues/6666) - Show management-only status under interface detail view
+* [#6667](https://github.com/netbox-community/netbox/issues/6667) - Display VM memory as GB/TB as appropriate
+
+### Bug Fixes
+
+* [#6626](https://github.com/netbox-community/netbox/issues/6626) - Fix site field on VM search form; add site group
+* [#6637](https://github.com/netbox-community/netbox/issues/6637) - Fix group assignment in "available VLANs" link under VLAN group view
+* [#6640](https://github.com/netbox-community/netbox/issues/6640) - Disallow numeric values in custom text fields
+* [#6652](https://github.com/netbox-community/netbox/issues/6652) - Fix exception when adding components in bulk to multiple devices
+* [#6676](https://github.com/netbox-community/netbox/issues/6676) - Fix device/VM counts per cluster under cluster type/group views
+* [#6680](https://github.com/netbox-community/netbox/issues/6680) - Allow setting custom field values for VM interfaces on initial creation
+* [#6695](https://github.com/netbox-community/netbox/issues/6695) - Fix exception when importing device type with invalid front port definition
+
+---
+
 ## v2.11.7 (2021-06-16)
 
 ### Enhancements

+ 19 - 14
docs/rest-api/filtering.md

@@ -61,25 +61,30 @@ These lookup expressions can be applied by adding a suffix to the desired field'
 
 Numeric based fields (ASN, VLAN ID, etc) support these lookup expressions:
 
-- `n` - not equal to (negation)
-- `lt` - less than
-- `lte` - less than or equal
-- `gt` - greater than
-- `gte` - greater than or equal
+| Filter | Description |
+|--------|-------------|
+| `n` | Not equal to |
+| `lt` | Less than |
+| `lte` | Less than or equal to |
+| `gt` | Greater than |
+| `gte` | Greater than or equal to |
 
 ### String Fields
 
 String based (char) fields (Name, Address, etc) support these lookup expressions:
 
-- `n` - not equal to (negation)
-- `ic` - case insensitive contains
-- `nic` - negated case insensitive contains
-- `isw` - case insensitive starts with
-- `nisw` - negated case insensitive starts with
-- `iew` - case insensitive ends with
-- `niew` - negated case insensitive ends with
-- `ie` - case insensitive exact match
-- `nie` - negated case insensitive exact match
+| Filter | Description |
+|--------|-------------|
+| `n` | Not equal to |
+| `ic` | Contains (case-insensitive) |
+| `nic` | Does not contain (case-insensitive) |
+| `isw` | Starts with (case-insensitive) |
+| `nisw` | Does not start with (case-insensitive) |
+| `iew` | Ends with (case-insensitive) |
+| `niew` | Does not end with (case-insensitive) |
+| `ie` | Exact match (case-insensitive) |
+| `nie` | Inverse exact match (case-insensitive) |
+| `empty` | Is empty (boolean) |
 
 ### Foreign Keys & Other Fields
 

+ 14 - 4
netbox/dcim/forms.py

@@ -1878,8 +1878,7 @@ class FrontPortTemplateImportForm(ComponentTemplateImportForm):
     )
     rear_port = forms.ModelChoiceField(
         queryset=RearPortTemplate.objects.all(),
-        to_field_name='name',
-        required=False
+        to_field_name='name'
     )
 
     class Meta:
@@ -2236,6 +2235,12 @@ class BaseDeviceCSVForm(CustomFieldModelCSVForm):
         choices=DeviceStatusChoices,
         help_text='Operational status'
     )
+    virtual_chassis = CSVModelChoiceField(
+        queryset=VirtualChassis.objects.all(),
+        to_field_name='name',
+        required=False,
+        help_text='Virtual chassis'
+    )
     cluster = CSVModelChoiceField(
         queryset=Cluster.objects.all(),
         to_field_name='name',
@@ -2246,6 +2251,10 @@ class BaseDeviceCSVForm(CustomFieldModelCSVForm):
     class Meta:
         fields = []
         model = Device
+        help_texts = {
+            'vc_position': 'Virtual chassis position',
+            'vc_priority': 'Virtual chassis priority',
+        }
 
     def __init__(self, data=None, *args, **kwargs):
         super().__init__(data, *args, **kwargs)
@@ -2284,7 +2293,8 @@ class DeviceCSVForm(BaseDeviceCSVForm):
     class Meta(BaseDeviceCSVForm.Meta):
         fields = [
             'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
-            'site', 'location', 'rack', 'position', 'face', 'cluster', 'comments',
+            'site', 'location', 'rack', 'position', 'face', 'virtual_chassis', 'vc_position', 'vc_priority', 'cluster',
+            'comments',
         ]
 
     def __init__(self, data=None, *args, **kwargs):
@@ -2319,7 +2329,7 @@ class ChildDeviceCSVForm(BaseDeviceCSVForm):
     class Meta(BaseDeviceCSVForm.Meta):
         fields = [
             'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
-            'parent', 'device_bay', 'cluster', 'comments',
+            'parent', 'device_bay', 'virtual_chassis', 'vc_position', 'vc_priority', 'cluster', 'comments',
         ]
 
     def __init__(self, data=None, *args, **kwargs):

+ 16 - 11
netbox/dcim/models/device_component_templates.py

@@ -290,19 +290,24 @@ class FrontPortTemplate(ComponentTemplateModel):
     def clean(self):
         super().clean()
 
-        # Validate rear port assignment
-        if self.rear_port.device_type != self.device_type:
-            raise ValidationError(
-                "Rear port ({}) must belong to the same device type".format(self.rear_port)
-            )
+        try:
 
-        # Validate rear port position assignment
-        if self.rear_port_position > self.rear_port.positions:
-            raise ValidationError(
-                "Invalid rear port position ({}); rear port {} has only {} positions".format(
-                    self.rear_port_position, self.rear_port.name, self.rear_port.positions
+            # Validate rear port assignment
+            if self.rear_port.device_type != self.device_type:
+                raise ValidationError(
+                    "Rear port ({}) must belong to the same device type".format(self.rear_port)
                 )
-            )
+
+            # Validate rear port position assignment
+            if self.rear_port_position > self.rear_port.positions:
+                raise ValidationError(
+                    "Invalid rear port position ({}); rear port {} has only {} positions".format(
+                        self.rear_port_position, self.rear_port.name, self.rear_port.positions
+                    )
+                )
+
+        except RearPortTemplate.DoesNotExist:
+            pass
 
     def instantiate(self, device):
         if self.rear_port:

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

@@ -1023,6 +1023,8 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
 
         tags = create_tags('Alpha', 'Bravo', 'Charlie')
 
+        VirtualChassis.objects.create(name='Virtual Chassis 1')
+
         cls.form_data = {
             'device_type': devicetypes[1].pk,
             'device_role': deviceroles[1].pk,
@@ -1048,10 +1050,10 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         }
 
         cls.csv_data = (
-            "device_role,manufacturer,device_type,status,name,site,location,rack,position,face",
-            "Device Role 1,Manufacturer 1,Device Type 1,active,Device 4,Site 1,Location 1,Rack 1,10,front",
-            "Device Role 1,Manufacturer 1,Device Type 1,active,Device 5,Site 1,Location 1,Rack 1,20,front",
-            "Device Role 1,Manufacturer 1,Device Type 1,active,Device 6,Site 1,Location 1,Rack 1,30,front",
+            "device_role,manufacturer,device_type,status,name,site,location,rack,position,face,virtual_chassis,vc_position,vc_priority",
+            "Device Role 1,Manufacturer 1,Device Type 1,active,Device 4,Site 1,Location 1,Rack 1,10,front,Virtual Chassis 1,1,10",
+            "Device Role 1,Manufacturer 1,Device Type 1,active,Device 5,Site 1,Location 1,Rack 1,20,front,Virtual Chassis 1,2,20",
+            "Device Role 1,Manufacturer 1,Device Type 1,active,Device 6,Site 1,Location 1,Rack 1,30,front,Virtual Chassis 1,3,30",
         )
 
         cls.bulk_edit_data = {

+ 2 - 0
netbox/dcim/views.py

@@ -1169,6 +1169,8 @@ class DeviceRoleView(generic.ObjectView):
 
         return {
             'devices_table': devices_table,
+            'device_count': Device.objects.filter(device_role=instance).count(),
+            'virtualmachine_count': VirtualMachine.objects.filter(role=instance).count(),
         }
 
 

+ 1 - 0
netbox/extras/apps.py

@@ -5,4 +5,5 @@ class ExtrasConfig(AppConfig):
     name = "extras"
 
     def ready(self):
+        import extras.lookups
         import extras.signals

+ 17 - 0
netbox/extras/lookups.py

@@ -0,0 +1,17 @@
+from django.db.models import CharField, Lookup
+
+
+class Empty(Lookup):
+    """
+    Filter on whether a string is empty.
+    """
+    lookup_name = 'empty'
+
+    def as_sql(self, qn, connection):
+        lhs, lhs_params = self.process_lhs(qn, connection)
+        rhs, rhs_params = self.process_rhs(qn, connection)
+        params = lhs_params + rhs_params
+        return 'CAST(LENGTH(%s) AS BOOLEAN) != %s' % (lhs, rhs), params
+
+
+CharField.register_lookup(Empty)

+ 4 - 2
netbox/extras/models/customfields.py

@@ -280,8 +280,10 @@ class CustomField(BigIDModel):
         if value not in [None, '']:
 
             # Validate text field
-            if self.type == CustomFieldTypeChoices.TYPE_TEXT and self.validation_regex:
-                if not re.match(self.validation_regex, value):
+            if self.type == CustomFieldTypeChoices.TYPE_TEXT:
+                if type(value) is not str:
+                    raise ValidationError(f"Value must be a string.")
+                if self.validation_regex and not re.match(self.validation_regex, value):
                     raise ValidationError(f"Value must match regex '{self.validation_regex}'")
 
             # Validate integer

+ 2 - 3
netbox/extras/models/models.py

@@ -431,9 +431,8 @@ class JournalEntry(ChangeLoggedModel):
         verbose_name_plural = 'journal entries'
 
     def __str__(self):
-        created_date = timezone.localdate(self.created)
-        created_time = timezone.localtime(self.created)
-        return f"{date_format(created_date)} - {time_format(created_time)} ({self.get_kind_display()})"
+        created = timezone.localtime(self.created)
+        return f"{date_format(created, format='SHORT_DATETIME_FORMAT')} ({self.get_kind_display()})"
 
     def get_absolute_url(self):
         return reverse('extras:journalentry', args=[self.pk])

+ 1 - 1
netbox/ipam/tables.py

@@ -65,7 +65,7 @@ VLAN_LINK = """
 {% if record.pk %}
     <a href="{{ record.get_absolute_url }}">{{ record.vid }}</a>
 {% elif perms.ipam.add_vlan %}
-    <a href="{% url 'ipam:vlan_add' %}?vid={{ record.vid }}&group={{ vlan_group.pk }}{% if vlan_group.site %}&site={{ vlan_group.site.pk }}{% endif %}" class="btn btn-xs btn-success">{{ record.available }} VLAN{{ record.available|pluralize }} available</a>
+    <a href="{% url 'ipam:vlan_add' %}?vid={{ record.vid }}{% if record.vlan_group %}&group={{ record.vlan_group.pk }}{% endif %}" class="btn btn-xs btn-success">{{ record.available }} VLAN{{ record.available|pluralize }} available</a>
 {% else %}
     {{ record.available }} VLAN{{ record.available|pluralize }} available
 {% endif %}

+ 21 - 5
netbox/ipam/utils.py

@@ -68,24 +68,40 @@ def add_available_ipaddresses(prefix, ipaddress_list, is_pool=False):
     return output
 
 
-def add_available_vlans(vlan_group, vlans):
+def add_available_vlans(vlans, vlan_group=None):
     """
     Create fake records for all gaps between used VLANs
     """
     if not vlans:
-        return [{'vid': VLAN_VID_MIN, 'available': VLAN_VID_MAX - VLAN_VID_MIN + 1}]
+        return [{
+            'vid': VLAN_VID_MIN,
+            'vlan_group': vlan_group,
+            'available': VLAN_VID_MAX - VLAN_VID_MIN + 1
+        }]
 
     prev_vid = VLAN_VID_MAX
     new_vlans = []
     for vlan in vlans:
         if vlan.vid - prev_vid > 1:
-            new_vlans.append({'vid': prev_vid + 1, 'available': vlan.vid - prev_vid - 1})
+            new_vlans.append({
+                'vid': prev_vid + 1,
+                'vlan_group': vlan_group,
+                'available': vlan.vid - prev_vid - 1,
+            })
         prev_vid = vlan.vid
 
     if vlans[0].vid > VLAN_VID_MIN:
-        new_vlans.append({'vid': VLAN_VID_MIN, 'available': vlans[0].vid - VLAN_VID_MIN})
+        new_vlans.append({
+            'vid': VLAN_VID_MIN,
+            'vlan_group': vlan_group,
+            'available': vlans[0].vid - VLAN_VID_MIN,
+        })
     if prev_vid < VLAN_VID_MAX:
-        new_vlans.append({'vid': prev_vid + 1, 'available': VLAN_VID_MAX - prev_vid})
+        new_vlans.append({
+            'vid': prev_vid + 1,
+            'vlan_group': vlan_group,
+            'available': VLAN_VID_MAX - prev_vid,
+        })
 
     vlans = list(vlans) + new_vlans
     vlans.sort(key=lambda v: v.vid if type(v) == VLAN else v['vid'])

+ 1 - 2
netbox/ipam/views.py

@@ -145,7 +145,6 @@ class RIRListView(generic.ObjectListView):
     filterset = filtersets.RIRFilterSet
     filterset_form = forms.RIRFilterForm
     table = tables.RIRTable
-    template_name = 'ipam/rir_list.html'
 
 
 class RIRView(generic.ObjectView):
@@ -676,7 +675,7 @@ class VLANGroupView(generic.ObjectView):
             Prefetch('prefixes', queryset=Prefix.objects.restrict(request.user))
         ).order_by('vid')
         vlans_count = vlans.count()
-        vlans = add_available_vlans(instance, vlans)
+        vlans = add_available_vlans(vlans, vlan_group=instance)
 
         vlans_table = tables.VLANDetailTable(vlans)
         if request.user.has_perm('ipam.change_vlan') or request.user.has_perm('ipam.delete_vlan'):

+ 10 - 4
netbox/netbox/constants.py

@@ -4,12 +4,12 @@ from circuits.filtersets import CircuitFilterSet, ProviderFilterSet, ProviderNet
 from circuits.models import Circuit, ProviderNetwork, Provider
 from circuits.tables import CircuitTable, ProviderNetworkTable, ProviderTable
 from dcim.filtersets import (
-    CableFilterSet, DeviceFilterSet, DeviceTypeFilterSet, PowerFeedFilterSet, RackFilterSet, LocationFilterSet,
-    SiteFilterSet, VirtualChassisFilterSet,
+    CableFilterSet, DeviceFilterSet, DeviceTypeFilterSet, PowerFeedFilterSet, RackFilterSet, RackReservationFilterSet,
+    LocationFilterSet, SiteFilterSet, VirtualChassisFilterSet,
 )
-from dcim.models import Cable, Device, DeviceType, PowerFeed, Rack, Location, Site, VirtualChassis
+from dcim.models import Cable, Device, DeviceType, Location, PowerFeed, Rack, RackReservation, Site, VirtualChassis
 from dcim.tables import (
-    CableTable, DeviceTable, DeviceTypeTable, PowerFeedTable, RackTable, LocationTable, SiteTable,
+    CableTable, DeviceTable, DeviceTypeTable, PowerFeedTable, RackTable, RackReservationTable, LocationTable, SiteTable,
     VirtualChassisTable,
 )
 from ipam.filtersets import AggregateFilterSet, IPAddressFilterSet, PrefixFilterSet, VLANFilterSet, VRFFilterSet
@@ -64,6 +64,12 @@ SEARCH_TYPES = OrderedDict((
         'table': RackTable,
         'url': 'dcim:rack_list',
     }),
+    ('rackreservation', {
+        'queryset': RackReservation.objects.prefetch_related('site', 'rack', 'user'),
+        'filterset': RackReservationFilterSet,
+        'table': RackReservationTable,
+        'url': 'dcim:rackreservation_list',
+    }),
     ('location', {
         'queryset': Location.objects.add_related_count(
             Location.objects.all(),

+ 5 - 8
netbox/netbox/filtersets.py

@@ -89,13 +89,13 @@ class BaseFilterSet(django_filters.FilterSet):
             filters.MultiValueNumberFilter,
             filters.MultiValueTimeFilter
         )):
-            lookup_map = FILTER_NUMERIC_BASED_LOOKUP_MAP
+            return FILTER_NUMERIC_BASED_LOOKUP_MAP
 
         elif isinstance(existing_filter, (
             filters.TreeNodeMultipleChoiceFilter,
         )):
             # TreeNodeMultipleChoiceFilter only support negation but must maintain the `in` lookup expression
-            lookup_map = FILTER_TREENODE_NEGATION_LOOKUP_MAP
+            return FILTER_TREENODE_NEGATION_LOOKUP_MAP
 
         elif isinstance(existing_filter, (
             django_filters.ModelChoiceFilter,
@@ -103,7 +103,7 @@ class BaseFilterSet(django_filters.FilterSet):
             TagFilter
         )) or existing_filter.extra.get('choices'):
             # These filter types support only negation
-            lookup_map = FILTER_NEGATION_LOOKUP_MAP
+            return FILTER_NEGATION_LOOKUP_MAP
 
         elif isinstance(existing_filter, (
             django_filters.filters.CharFilter,
@@ -111,12 +111,9 @@ class BaseFilterSet(django_filters.FilterSet):
             filters.MultiValueCharFilter,
             filters.MultiValueMACAddressFilter
         )):
-            lookup_map = FILTER_CHAR_BASED_LOOKUP_MAP
+            return FILTER_CHAR_BASED_LOOKUP_MAP
 
-        else:
-            lookup_map = None
-
-        return lookup_map
+        return None
 
     @classmethod
     def get_filters(cls):

+ 3 - 2
netbox/netbox/forms.py

@@ -11,12 +11,13 @@ OBJ_TYPE_CHOICES = (
     ('DCIM', (
         ('site', 'Sites'),
         ('rack', 'Racks'),
+        ('rackreservation', 'Rack reservations'),
         ('location', 'Locations'),
         ('devicetype', 'Device types'),
         ('device', 'Devices'),
-        ('virtualchassis', 'Virtual Chassis'),
+        ('virtualchassis', 'Virtual chassis'),
         ('cable', 'Cables'),
-        ('powerfeed', 'Power Feeds'),
+        ('powerfeed', 'Power feeds'),
     )),
     ('IPAM', (
         ('vrf', 'VRFs'),

+ 1 - 1
netbox/netbox/settings.py

@@ -16,7 +16,7 @@ from django.core.validators import URLValidator
 # Environment setup
 #
 
-VERSION = '2.11.7'
+VERSION = '2.11.8'
 
 # Hostname
 HOSTNAME = platform.node()

+ 20 - 18
netbox/netbox/views/generic.py

@@ -18,7 +18,7 @@ from django_tables2.export import TableExport
 
 from extras.models import CustomField, ExportTemplate
 from utilities.error_handlers import handle_protectederror
-from utilities.exceptions import AbortTransaction
+from utilities.exceptions import AbortTransaction, PermissionsViolation
 from utilities.forms import (
     BootstrapMixin, BulkRenameForm, ConfirmationForm, CSVDataField, ImportForm, TableConfigForm, restrict_form_fields,
 )
@@ -290,7 +290,8 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
                     obj = form.save()
 
                     # Check that the new object conforms with any assigned object-level permissions
-                    self.queryset.get(pk=obj.pk)
+                    if not self.queryset.filter(pk=obj.pk).first():
+                        raise PermissionsViolation()
 
                 msg = '{} {}'.format(
                     'Created' if object_created else 'Modified',
@@ -318,7 +319,7 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
                 else:
                     return redirect(self.get_return_url(request, obj))
 
-            except ObjectDoesNotExist:
+            except PermissionsViolation:
                 msg = "Object save failed due to object-level permissions violation"
                 logger.debug(msg)
                 form.add_error(None, msg)
@@ -480,7 +481,7 @@ class BulkCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
 
                     # Enforce object-level permissions
                     if self.queryset.filter(pk__in=[obj.pk for obj in new_objs]).count() != len(new_objs):
-                        raise ObjectDoesNotExist
+                        raise PermissionsViolation
 
                     # If we make it to this point, validation has succeeded on all new objects.
                     msg = "Added {} {}".format(len(new_objs), model._meta.verbose_name_plural)
@@ -494,7 +495,7 @@ class BulkCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
             except IntegrityError:
                 pass
 
-            except ObjectDoesNotExist:
+            except PermissionsViolation:
                 msg = "Object creation failed due to object-level permissions violation"
                 logger.debug(msg)
                 form.add_error(None, msg)
@@ -565,7 +566,8 @@ class ObjectImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
                         obj = model_form.save()
 
                         # Enforce object-level permissions
-                        self.queryset.get(pk=obj.pk)
+                        if not self.queryset.filter(pk=obj.pk).first():
+                            raise PermissionsViolation()
 
                         logger.debug(f"Created {obj} (PK: {obj.pk})")
 
@@ -601,7 +603,7 @@ class ObjectImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
                 except AbortTransaction:
                     pass
 
-                except ObjectDoesNotExist:
+                except PermissionsViolation:
                     msg = "Object creation failed due to object-level permissions violation"
                     logger.debug(msg)
                     form.add_error(None, msg)
@@ -712,7 +714,7 @@ class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
 
                     # Enforce object-level permissions
                     if self.queryset.filter(pk__in=[obj.pk for obj in new_objs]).count() != len(new_objs):
-                        raise ObjectDoesNotExist
+                        raise PermissionsViolation
 
                 # Compile a table containing the imported objects
                 obj_table = self.table(new_objs)
@@ -730,7 +732,7 @@ class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
             except ValidationError:
                 pass
 
-            except ObjectDoesNotExist:
+            except PermissionsViolation:
                 msg = "Object import failed due to object-level permissions violation"
                 logger.debug(msg)
                 form.add_error(None, msg)
@@ -845,7 +847,7 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
 
                         # Enforce object-level permissions
                         if self.queryset.filter(pk__in=[obj.pk for obj in updated_objects]).count() != len(updated_objects):
-                            raise ObjectDoesNotExist
+                            raise PermissionsViolation
 
                     if updated_objects:
                         msg = 'Updated {} {}'.format(len(updated_objects), model._meta.verbose_name_plural)
@@ -857,7 +859,7 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
                 except ValidationError as e:
                     messages.error(self.request, "{} failed validation: {}".format(obj, e))
 
-                except ObjectDoesNotExist:
+                except PermissionsViolation:
                     msg = "Object update failed due to object-level permissions violation"
                     logger.debug(msg)
                     form.add_error(None, msg)
@@ -952,7 +954,7 @@ class BulkRenameView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
 
                             # Enforce constrained permissions
                             if self.queryset.filter(pk__in=renamed_pks).count() != len(selected_objects):
-                                raise ObjectDoesNotExist
+                                raise PermissionsViolation
 
                             messages.success(request, "Renamed {} {}".format(
                                 len(selected_objects),
@@ -960,7 +962,7 @@ class BulkRenameView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
                             ))
                             return redirect(self.get_return_url(request))
 
-                except ObjectDoesNotExist:
+                except PermissionsViolation:
                     msg = "Object update failed due to object-level permissions violation"
                     logger.debug(msg)
                     form.add_error(None, msg)
@@ -1146,7 +1148,7 @@ class ComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View
 
                         # Enforce object-level permissions
                         if self.queryset.filter(pk__in=[obj.pk for obj in new_objs]).count() != len(new_objs):
-                            raise ObjectDoesNotExist
+                            raise PermissionsViolation
 
                     messages.success(request, "Added {} {}".format(
                         len(new_components), self.queryset.model._meta.verbose_name_plural
@@ -1156,7 +1158,7 @@ class ComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View
                     else:
                         return redirect(self.get_return_url(request))
 
-                except ObjectDoesNotExist:
+                except PermissionsViolation:
                     msg = "Component creation failed due to object-level permissions violation"
                     logger.debug(msg)
                     form.add_error(None, msg)
@@ -1229,7 +1231,7 @@ class BulkComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin,
                                 component_form = self.model_form(component_data)
                                 if component_form.is_valid():
                                     instance = component_form.save()
-                                    logger.debug(f"Created {instance} on {instance.parent}")
+                                    logger.debug(f"Created {instance} on {instance.parent_object}")
                                     new_components.append(instance)
                                 else:
                                     for field, errors in component_form.errors.as_data().items():
@@ -1238,12 +1240,12 @@ class BulkComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin,
 
                         # Enforce object-level permissions
                         if self.queryset.filter(pk__in=[obj.pk for obj in new_components]).count() != len(new_components):
-                            raise ObjectDoesNotExist
+                            raise PermissionsViolation
 
                 except IntegrityError:
                     pass
 
-                except ObjectDoesNotExist:
+                except PermissionsViolation:
                     msg = "Component creation failed due to object-level permissions violation"
                     logger.debug(msg)
                     form.add_error(None, msg)

+ 1 - 1
netbox/templates/base.html

@@ -67,7 +67,7 @@
                     <p class="text-muted">{{ settings.HOSTNAME }} (v{{ settings.VERSION }})</p>
                 </div>
                 <div class="col-xs-4 text-center">
-                    <p class="text-muted">{% now 'Y-m-d H:i:s T' %}</p>
+                    <p class="text-muted">{% annotated_now %} {% now 'T' %}</p>
                 </div>
                 <div class="col-xs-4 text-right noprint">
                     <p class="text-muted">

+ 1 - 1
netbox/templates/circuits/circuit.html

@@ -51,7 +51,7 @@
                 </tr>
                 <tr>
                     <td>Install Date</td>
-                    <td>{{ object.install_date|placeholder }}</td>
+                    <td>{{ object.install_date|annotated_date|placeholder }}</td>
                 </tr>
                 <tr>
                     <td>Commit Rate</td>

+ 20 - 4
netbox/templates/dcim/device_component_add.html

@@ -1,4 +1,5 @@
 {% extends 'base.html' %}
+{% load helpers %}
 {% load form_helpers %}
 
 {% block title %}Create {{ component_type }}{% endblock %}
@@ -18,19 +19,34 @@
             {% endif %}
             <div class="panel panel-default">
                 <div class="panel-heading">
-                    <strong>{{ component_type|title }}</strong>
+                    <strong>{{ component_type|bettertitle }}</strong>
                 </div>
                 <div class="panel-body">
-                    {% render_form form %}
+                    {% for field in form.hidden_fields %}
+                        {{ field }}
+                    {% endfor %}
+                    {% for field in form.visible_fields %}
+                        {% if field.name not in form.custom_fields %}
+                            {% render_field field %}
+                        {% endif %}
+                    {% endfor %}
                 </div>
             </div>
-		    <div class="form-group">
+            {% if form.custom_fields %}
+                <div class="panel panel-default">
+                    <div class="panel-heading"><strong>Custom Fields</strong></div>
+                    <div class="panel-body">
+                        {% render_custom_fields form %}
+                    </div>
+                </div>
+            {% endif %}
+            <div class="form-group">
                 <div class="col-md-9 col-md-offset-3">
                     <button type="submit" name="_create" class="btn btn-primary">Create</button>
                     <button type="submit" name="_addanother" class="btn btn-primary">Create and Add More</button>
                     <a href="{{ return_url }}" class="btn btn-default">Cancel</a>
                 </div>
-		    </div>
+            </div>
         </div>
     </div>
 </form>

+ 11 - 1
netbox/templates/dcim/devicerole.html

@@ -42,7 +42,17 @@
         <tr>
           <td>Devices</td>
           <td>
-            <a href="{% url 'dcim:device_list' %}?role_id={{ object.pk }}">{{ devices_table.rows|length }}</a>
+            <a href="{% url 'dcim:device_list' %}?role_id={{ object.pk }}">{{ device_count }}</a>
+          </td>
+        </tr>
+        <tr>
+          <td>Virtual Machines</td>
+          <td>
+            {% if object.vm_role %}
+              <a href="{% url 'virtualization:virtualmachine_list' %}?role_id={{ object.pk }}">{{ virtualmachine_count }}</a>
+            {% else %}
+              &mdash;
+            {% endif %}
           </td>
         </tr>
       </table>

+ 11 - 1
netbox/templates/dcim/interface.html

@@ -54,6 +54,16 @@
                             {% endif %}
                         </td>
                     </tr>
+                    <tr>
+                        <td>Management Only</td>
+                        <td>
+                            {% if object.mgmt_only %}
+                                <span class="text-success"><i class="mdi mdi-check-bold"></i></span>
+                            {% else %}
+                                <span class="text-danger"><i class="mdi mdi-close"></i></span>
+                            {% endif %}
+                        </td>
+                    </tr>
                     <tr>
                         <td>Parent</td>
                         <td>
@@ -88,7 +98,7 @@
                     </tr>
                     <tr>
                         <td>802.1Q Mode</td>
-                        <td>{{ object.get_mode_display }}</td>
+                        <td>{{ object.get_mode_display|placeholder }}</td>
                     </tr>
                 </table>
             </div>

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

@@ -268,7 +268,7 @@
                             </td>
                             <td>
                                 {{ resv.description }}<br />
-                                <small>{{ resv.user }} &middot; {{ resv.created }}</small>
+                                <small>{{ resv.user }} &middot; {{ resv.created|annotated_date }}</small>
                             </td>
                             <td class="text-right noprint">
                                 {% if perms.dcim.change_rackreservation %}

+ 5 - 1
netbox/templates/dcim/site.html

@@ -80,7 +80,11 @@
                     <td>
                         {% if object.time_zone %}
                             {{ object.time_zone }} (UTC {{ object.time_zone|tzoffset }})<br />
-                            <small class="text-muted">Site time: {% timezone object.time_zone %}{% now "SHORT_DATETIME_FORMAT" %}{% endtimezone %}</small>
+                            <small class="text-muted">Site time:
+                                {% timezone object.time_zone %}
+                                    {% annotated_now %}
+                                {% endtimezone %}
+                            </small>
                         {% else %}
                             <span class="text-muted">&mdash;</span>
                         {% endif %}

+ 1 - 1
netbox/templates/extras/journalentry.html

@@ -25,7 +25,7 @@
                     <tr>
                         <td>Created</td>
                         <td>
-                            {{ object.created }}
+                            {{ object.created|annotated_date }}
                         </td>
                     </tr>
                     <tr>

+ 1 - 1
netbox/templates/extras/objectchange.html

@@ -44,7 +44,7 @@
                     <tr>
                         <td>Time</td>
                         <td>
-                            {{ object.time }}
+                            {{ object.time|annotated_date }}
                         </td>
                     </tr>
                     <tr>

+ 1 - 1
netbox/templates/extras/report.html

@@ -38,7 +38,7 @@
         <div class="col-md-12">
             {% if report.result %}
                 Last run: <a href="{% url 'extras:report_result' job_result_pk=report.result.pk %}">
-                    <strong>{{ report.result.created }}</strong>
+                    <strong>{{ report.result.created|annotated_date }}</strong>
                 </a>
             {% endif %}
         </div>

+ 1 - 1
netbox/templates/extras/report_list.html

@@ -32,7 +32,7 @@
                                     <td class="rendered-markdown">{{ report.description|render_markdown|placeholder }}</td>
                                     <td class="text-right">
                                         {% if report.result %}
-                                            <a href="{% url 'extras:report_result' job_result_pk=report.result.pk %}">{{ report.result.created }}</a>
+                                            <a href="{% url 'extras:report_result' job_result_pk=report.result.pk %}">{{ report.result.created|annotated_date }}</a>
                                         {% else %}
                                             <span class="text-muted">Never</span>
                                         {% endif %}

+ 1 - 1
netbox/templates/extras/report_result.html

@@ -8,7 +8,7 @@
     <div class="row">
         <div class="col-md-12">
             <p>
-                Run: <strong>{{ result.created }}</strong>
+                Run: <strong>{{ result.created|annotated_date }}</strong>
                 {% if result.completed %}
                     Duration: <strong>{{ result.duration }}</strong>
                 {% else %}

+ 1 - 1
netbox/templates/extras/script_list.html

@@ -29,7 +29,7 @@
                                     <td>{{ script.Meta.description|render_markdown }}</td>
                                     {% if script.result %}
                                         <td class="text-right">
-                                            <a href="{% url 'extras:script_result' job_result_pk=script.result.pk %}">{{ script.result.created }}</a>
+                                            <a href="{% url 'extras:script_result' job_result_pk=script.result.pk %}">{{ script.result.created|annotated_date }}</a>
                                         </td>
                                     {% else %}
                                         <td class="text-right text-muted">Never</td>

+ 2 - 2
netbox/templates/extras/script_result.html

@@ -13,7 +13,7 @@
                 <li><a href="{% url 'extras:script_list' %}">Scripts</a></li>
                 <li><a href="{% url 'extras:script_list' %}#module.{{ script.module }}">{{ script.module|bettertitle }}</a></li>
                 <li><a href="{% url 'extras:script' module=script.module name=class_name %}">{{ script }}</a></li>
-                <li>{{ result.created }}</li>
+                <li>{{ result.created|annotated_date }}</li>
             </ol>
         </div>
     </div>
@@ -32,7 +32,7 @@
     </ul>
     <div class="tab-content">
         <p>
-            Run: <strong>{{ result.created }}</strong>
+            Run: <strong>{{ result.created|annotated_date }}</strong>
             {% if result.completed %}
                 Duration: <strong>{{ result.duration }}</strong>
             {% else %}

+ 2 - 2
netbox/templates/generic/object.html

@@ -42,8 +42,8 @@
   <h1 class="title">{% block title %}{{ object }}{% endblock %}</h1>
   <p>
     <small class="text-muted">
-      Created {{ object.created }} &middot;
-      Updated <span title="{{ object.last_updated }}">{{ object.last_updated|timesince }}</span> ago
+      Created {{ object.created|annotated_date }} &middot;
+      Updated {{ object.last_updated|annotated_date }} ({{ object.last_updated|timesince }} ago)
     </small>
     <span class="label label-default">{{ object|meta:"app_label" }}.{{ object|meta:"model_name" }}:{{ object.pk }}</span>
   </p>

+ 1 - 1
netbox/templates/home.html

@@ -291,7 +291,7 @@
                     {% for result in report_results %}
                         <tr>
                             <td><a href="{% url 'extras:report_result' job_result_pk=result.pk %}">{{ result.name }}</a></td>
-                            <td class="text-right"><span title="{{ result.created }}">{% include 'extras/inc/job_label.html' %}</span></td>
+                            <td class="text-right"><span title="{{ result.created|date:'SHORT_DATETIME_FORMAT' }}">{% include 'extras/inc/job_label.html' %}</span></td>
                         </tr>
                     {% endfor %}
                 </table>

+ 2 - 1
netbox/templates/inc/image_attachments.html

@@ -1,3 +1,4 @@
+{% load helpers %}
 {% if images %}
     <table class="table table-hover panel-body">
         <tr>
@@ -13,7 +14,7 @@
                     <a class="image-preview" href="{{ attachment.image.url }}" target="_blank">{{ attachment }}</a>
                 </td>
                 <td>{{ attachment.size|filesizeformat }}</td>
-                <td>{{ attachment.created }}</td>
+                <td>{{ attachment.created|annotated_date }}</td>
                 <td class="text-right noprint">
                     {% if perms.extras.change_imageattachment %}
                         <a href="{% url 'extras:imageattachment_edit' pk=attachment.pk %}" class="btn btn-warning btn-xs" title="Edit image">

+ 1 - 1
netbox/templates/ipam/aggregate.html

@@ -54,7 +54,7 @@
                 </tr>
                 <tr>
                     <td>Date Added</td>
-                    <td>{{ object.date_added|placeholder }}</td>
+                    <td>{{ object.date_added|annotated_date|placeholder }}</td>
                 </tr>
                 <tr>
                     <td>Description</td>

+ 0 - 23
netbox/templates/ipam/rir_list.html

@@ -1,23 +0,0 @@
-{% extends 'generic/object_list.html' %}
-
-{% block buttons %}
-    {% if request.GET.family == '6' %}
-        <a href="{% url 'ipam:rir_list' %}" class="btn btn-default">
-            <span class="mdi mdi-table" aria-hidden="true"></span>
-            IPv4 Stats
-        </a>
-    {% else %}
-        <a href="{% url 'ipam:rir_list' %}?family=6{% if request.GET %}&{{ request.GET.urlencode }}{% endif %}" class="btn btn-default">
-            <span class="mdi mdi-table" aria-hidden="true"></span>
-            IPv6 Stats
-        </a>
-    {% endif %}
-{% endblock %}
-
-{% block sidebar %}
-    {% if request.GET.family == '6' %}
-        <div class="alert alert-info">
-            <i class="mdi mdi-information-outline"></i> Numbers shown indicate /64 prefixes.
-        </div>
-    {% endif %}
-{% endblock %}

+ 2 - 2
netbox/templates/users/api_tokens.html

@@ -24,12 +24,12 @@
                         <div class="row">
                             <div class="col-md-4">
                                 <small class="text-muted">Created</small><br />
-                                <span title="{{ token.created }}">{{ token.created|date }}</span>
+                                {{ token.created|annotated_date }}
                             </div>
                             <div class="col-md-4">
                                 <small class="text-muted">Expires</small><br />
                                 {% if token.expires %}
-                                    <span title="{{ token.expires }}">{{ token.expires|date }}</span>
+                                    {{ token.expires|annotated_date }}
                                 {% else %}
                                     <span>Never</span>
                                 {% endif %}

+ 1 - 1
netbox/templates/users/profile.html

@@ -11,7 +11,7 @@
     <small class="text-muted">Email</small>
     <h5>{{ request.user.email }}</h5>
     <small class="text-muted">Registered</small>
-    <h5>{{ request.user.date_joined }}</h5>
+    <h5>{{ request.user.date_joined|annotated_date }}</h5>
     <small class="text-muted">Groups</small>
     <h5>{{ request.user.groups.all|join:', ' }}</h5>
     <small class="text-muted">Admin access</small>

+ 5 - 2
netbox/templates/users/userkey.html

@@ -1,4 +1,5 @@
 {% extends 'users/base.html' %}
+{% load helpers %}
 
 {% block title %}User Key{% endblock %}
 
@@ -19,7 +20,9 @@
             {% endif %}
         </h4>
         <p>
-            <small class="text-muted">Created {{ object.created }} &middot; Updated <span title="{{ object.last_updated }}">{{ object.last_updated|timesince }}</span> ago</small>
+            <small class="text-muted">
+              Created {{ object.created|annotated_date }} &middot;
+              Updated {{ object.last_updated|annotated_date }} ({{ object.last_updated|timesince }} ago)
         </p>
         {% if not object.is_active %}
             <div class="alert alert-warning" role="alert">
@@ -37,7 +40,7 @@
                 </a>
             </div>
             <h4>Session key: <span class="label label-success">Active</span></h4>
-            <small class="text-muted">Created {{ object.session_key.created }}</small>
+            <small class="text-muted">Created {{ object.session_key.created|annotated_date }}</small>
         {% else %}
             <h4>No active session key</h4>
         {% endif %}

+ 1 - 1
netbox/templates/virtualization/virtualmachine.html

@@ -138,7 +138,7 @@
                     <td><i class="mdi mdi-chip"></i> Memory</td>
                     <td>
                         {% if object.memory %}
-                            {{ object.memory }} MB
+                            <span title="{{ object.memory }} MB">{{ object.memory|humanize_megabytes }}</span>
                         {% else %}
                             <span class="text-muted">&mdash;</span>
                         {% endif %}

+ 0 - 38
netbox/templates/virtualization/virtualmachine_component_add.html

@@ -1,38 +0,0 @@
-{% extends 'base.html' %}
-{% load helpers %}
-{% load form_helpers %}
-
-{% block title %}Create {{ component_type }}{% endblock %}
-
-{% block content %}
-<form action="" method="post" class="form form-horizontal">
-    {% csrf_token %}
-    <div class="row">
-        <div class="col-md-6 col-md-offset-3">
-            {% if form.non_field_errors %}
-                <div class="panel panel-danger">
-                    <div class="panel-heading"><strong>Errors</strong></div>
-                    <div class="panel-body">
-                        {{ form.non_field_errors }}
-                    </div>
-                </div>
-            {% endif %}
-            <div class="panel panel-default">
-                <div class="panel-heading">
-                    <strong>{{ component_type|bettertitle }}</strong>
-                </div>
-                <div class="panel-body">
-                    {% render_form form %}
-                </div>
-            </div>
-		    <div class="form-group">
-                <div class="col-md-9 col-md-offset-3">
-                    <button type="submit" name="_create" class="btn btn-primary">Create</button>
-                    <button type="submit" name="_addanother" class="btn btn-primary">Create and Add More</button>
-                    <a href="{{ return_url }}" class="btn btn-default">Cancel</a>
-                </div>
-		    </div>
-        </div>
-    </div>
-</form>
-{% endblock %}

+ 2 - 1
netbox/utilities/constants.py

@@ -11,7 +11,8 @@ FILTER_CHAR_BASED_LOOKUP_MAP = dict(
     isw='istartswith',
     nisw='istartswith',
     ie='iexact',
-    nie='iexact'
+    nie='iexact',
+    empty='empty',
 )
 
 FILTER_NUMERIC_BASED_LOOKUP_MAP = dict(

+ 8 - 0
netbox/utilities/exceptions.py

@@ -9,6 +9,14 @@ class AbortTransaction(Exception):
     pass
 
 
+class PermissionsViolation(Exception):
+    """
+    Raised when an operation was prevented because it would violate the
+    allowed permissions.
+    """
+    pass
+
+
 class RQWorkerNotRunningException(APIException):
     """
     Indicates the temporary inability to enqueue a new task (e.g. custom script execution) because no RQ worker

+ 46 - 0
netbox/utilities/templatetags/helpers.py

@@ -5,7 +5,9 @@ import re
 import yaml
 from django import template
 from django.conf import settings
+from django.template.defaultfilters import date
 from django.urls import NoReverseMatch, reverse
+from django.utils import timezone
 from django.utils.html import strip_tags
 from django.utils.safestring import mark_safe
 from markdown import markdown
@@ -129,6 +131,20 @@ def humanize_speed(speed):
         return '{} Kbps'.format(speed)
 
 
+@register.filter()
+def humanize_megabytes(mb):
+    """
+    Express a number of megabytes in the most suitable unit (e.g. gigabytes or terabytes).
+    """
+    if not mb:
+        return ''
+    if mb >= 1048576:
+        return f'{int(mb / 1048576)} TB'
+    if mb >= 1024:
+        return f'{int(mb / 1024)} GB'
+    return f'{mb} MB'
+
+
 @register.filter()
 def tzoffset(value):
     """
@@ -137,6 +153,36 @@ def tzoffset(value):
     return datetime.datetime.now(value).strftime('%z')
 
 
+@register.filter(expects_localtime=True)
+def annotated_date(date_value):
+    """
+    Returns date as HTML span with short date format as the content and the
+    (long) date format as the title.
+    """
+    if not date_value:
+        return ''
+
+    if type(date_value) == datetime.date:
+        long_ts = date(date_value, 'DATE_FORMAT')
+        short_ts = date(date_value, 'SHORT_DATE_FORMAT')
+    else:
+        long_ts = date(date_value, 'DATETIME_FORMAT')
+        short_ts = date(date_value, 'SHORT_DATETIME_FORMAT')
+
+    span = f'<span title="{long_ts}">{short_ts}</span>'
+
+    return mark_safe(span)
+
+
+@register.simple_tag
+def annotated_now():
+    """
+    Returns the current date piped through the annotated_date filter.
+    """
+    tzinfo = timezone.get_current_timezone() if settings.USE_TZ else None
+    return annotated_date(datetime.datetime.now(tz=tzinfo))
+
+
 @register.filter()
 def fgcolor(value):
     """

+ 16 - 8
netbox/virtualization/forms.py

@@ -8,7 +8,8 @@ from dcim.constants import INTERFACE_MTU_MAX, INTERFACE_MTU_MIN
 from dcim.forms import InterfaceCommonForm, INTERFACE_MODE_HELP_TEXT
 from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site, SiteGroup
 from extras.forms import (
-    AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, CustomFieldFilterForm,
+    AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm,
+    CustomFieldFilterForm,
 )
 from extras.models import Tag
 from ipam.models import IPAddress, VLAN
@@ -527,8 +528,8 @@ class VirtualMachineBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldB
 class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
     model = VirtualMachine
     field_order = [
-        'q', 'cluster_group_id', 'cluster_type_id', 'cluster_id', 'status', 'role_id', 'region_id', 'site_id',
-        'tenant_group_id', 'tenant_id', 'platform_id', 'mac_address',
+        'q', 'cluster_group_id', 'cluster_type_id', 'cluster_id', 'status', 'role_id', 'region_id', 'site_group_id',
+        'site_id', 'tenant_group_id', 'tenant_id', 'platform_id', 'mac_address',
     ]
     q = forms.CharField(
         required=False,
@@ -556,14 +557,20 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil
         required=False,
         label=_('Region')
     )
+    site_group_id = DynamicModelMultipleChoiceField(
+        queryset=SiteGroup.objects.all(),
+        required=False,
+        label=_('Site group')
+    )
     site_id = DynamicModelMultipleChoiceField(
         queryset=Site.objects.all(),
         required=False,
         null_option='None',
         query_params={
-            'region_id': '$region_id'
+            'region_id': '$region_id',
+            'group_id': '$site_group_id',
         },
-        label=_('Cluster')
+        label=_('Site')
     )
     role_id = DynamicModelMultipleChoiceField(
         queryset=DeviceRole.objects.all(),
@@ -653,7 +660,8 @@ class VMInterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm)
         self.fields['tagged_vlans'].widget.add_query_param('available_on_virtualmachine', vm_id)
 
 
-class VMInterfaceCreateForm(BootstrapMixin, InterfaceCommonForm):
+class VMInterfaceCreateForm(BootstrapMixin, CustomFieldForm, InterfaceCommonForm):
+    model = VMInterface
     virtual_machine = DynamicModelChoiceField(
         queryset=VirtualMachine.objects.all()
     )
@@ -717,7 +725,7 @@ class VMInterfaceCreateForm(BootstrapMixin, InterfaceCommonForm):
         self.fields['tagged_vlans'].widget.add_query_param('available_on_virtualmachine', vm_id)
 
 
-class VMInterfaceCSVForm(CSVModelForm):
+class VMInterfaceCSVForm(CustomFieldModelCSVForm):
     virtual_machine = CSVModelChoiceField(
         queryset=VirtualMachine.objects.all(),
         to_field_name='name'
@@ -740,7 +748,7 @@ class VMInterfaceCSVForm(CSVModelForm):
             return self.cleaned_data['enabled']
 
 
-class VMInterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
+class VMInterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
         queryset=VMInterface.objects.all(),
         widget=forms.MultipleHiddenInput()

+ 7 - 1
netbox/virtualization/views.py

@@ -34,6 +34,9 @@ class ClusterTypeView(generic.ObjectView):
     def get_extra_context(self, request, instance):
         clusters = Cluster.objects.restrict(request.user, 'view').filter(
             type=instance
+        ).annotate(
+            device_count=count_related(Device, 'cluster'),
+            vm_count=count_related(VirtualMachine, 'cluster')
         )
 
         clusters_table = tables.ClusterTable(clusters)
@@ -93,6 +96,9 @@ class ClusterGroupView(generic.ObjectView):
     def get_extra_context(self, request, instance):
         clusters = Cluster.objects.restrict(request.user, 'view').filter(
             group=instance
+        ).annotate(
+            device_count=count_related(Device, 'cluster'),
+            vm_count=count_related(VirtualMachine, 'cluster')
         )
 
         clusters_table = tables.ClusterTable(clusters)
@@ -455,7 +461,7 @@ class VMInterfaceCreateView(generic.ComponentCreateView):
     queryset = VMInterface.objects.all()
     form = forms.VMInterfaceCreateForm
     model_form = forms.VMInterfaceForm
-    template_name = 'virtualization/virtualmachine_component_add.html'
+    template_name = 'dcim/device_component_add.html'
 
 
 class VMInterfaceEditView(generic.ObjectEditView):

+ 4 - 4
requirements.txt

@@ -1,4 +1,4 @@
-Django==3.2.4
+Django==3.2.5
 django-cacheops==6.0
 django-cors-headers==3.7.0
 django-debug-toolbar==3.2.1
@@ -8,7 +8,7 @@ django-pglocks==1.0.4
 django-prometheus==2.1.0
 django-rq==2.4.1
 django-tables2==2.4.0
-django-taggit==1.4.0
+django-taggit==1.5.1
 django-timezone-field==4.1.2
 djangorestframework==3.12.4
 drf-yasg[validation]==1.20.0
@@ -16,8 +16,8 @@ gunicorn==20.1.0
 Jinja2==3.0.1
 Markdown==3.3.4
 netaddr==0.8.0
-Pillow==8.2.0
-psycopg2-binary==2.9
+Pillow==8.3.0
+psycopg2-binary==2.9.1
 pycryptodome==3.10.1
 PyYAML==5.4.1
 svgwrite==1.4.1