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

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
         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/)
         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.)
         before opening a bug report to see if your issue has already been addressed.)
-      placeholder: v2.11.7
+      placeholder: v2.11.8
     validations:
     validations:
       required: true
       required: true
   - type: dropdown
   - type: dropdown

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

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

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

@@ -1,5 +1,29 @@
 # NetBox v2.11
 # 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)
 ## v2.11.7 (2021-06-16)
 
 
 ### Enhancements
 ### 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:
 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 Fields
 
 
 String based (char) fields (Name, Address, etc) support these lookup expressions:
 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
 ### Foreign Keys & Other Fields
 
 

+ 14 - 4
netbox/dcim/forms.py

@@ -1878,8 +1878,7 @@ class FrontPortTemplateImportForm(ComponentTemplateImportForm):
     )
     )
     rear_port = forms.ModelChoiceField(
     rear_port = forms.ModelChoiceField(
         queryset=RearPortTemplate.objects.all(),
         queryset=RearPortTemplate.objects.all(),
-        to_field_name='name',
-        required=False
+        to_field_name='name'
     )
     )
 
 
     class Meta:
     class Meta:
@@ -2236,6 +2235,12 @@ class BaseDeviceCSVForm(CustomFieldModelCSVForm):
         choices=DeviceStatusChoices,
         choices=DeviceStatusChoices,
         help_text='Operational status'
         help_text='Operational status'
     )
     )
+    virtual_chassis = CSVModelChoiceField(
+        queryset=VirtualChassis.objects.all(),
+        to_field_name='name',
+        required=False,
+        help_text='Virtual chassis'
+    )
     cluster = CSVModelChoiceField(
     cluster = CSVModelChoiceField(
         queryset=Cluster.objects.all(),
         queryset=Cluster.objects.all(),
         to_field_name='name',
         to_field_name='name',
@@ -2246,6 +2251,10 @@ class BaseDeviceCSVForm(CustomFieldModelCSVForm):
     class Meta:
     class Meta:
         fields = []
         fields = []
         model = Device
         model = Device
+        help_texts = {
+            'vc_position': 'Virtual chassis position',
+            'vc_priority': 'Virtual chassis priority',
+        }
 
 
     def __init__(self, data=None, *args, **kwargs):
     def __init__(self, data=None, *args, **kwargs):
         super().__init__(data, *args, **kwargs)
         super().__init__(data, *args, **kwargs)
@@ -2284,7 +2293,8 @@ class DeviceCSVForm(BaseDeviceCSVForm):
     class Meta(BaseDeviceCSVForm.Meta):
     class Meta(BaseDeviceCSVForm.Meta):
         fields = [
         fields = [
             'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
             '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):
     def __init__(self, data=None, *args, **kwargs):
@@ -2319,7 +2329,7 @@ class ChildDeviceCSVForm(BaseDeviceCSVForm):
     class Meta(BaseDeviceCSVForm.Meta):
     class Meta(BaseDeviceCSVForm.Meta):
         fields = [
         fields = [
             'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status',
             '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):
     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):
     def clean(self):
         super().clean()
         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):
     def instantiate(self, device):
         if self.rear_port:
         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')
         tags = create_tags('Alpha', 'Bravo', 'Charlie')
 
 
+        VirtualChassis.objects.create(name='Virtual Chassis 1')
+
         cls.form_data = {
         cls.form_data = {
             'device_type': devicetypes[1].pk,
             'device_type': devicetypes[1].pk,
             'device_role': deviceroles[1].pk,
             'device_role': deviceroles[1].pk,
@@ -1048,10 +1050,10 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         }
         }
 
 
         cls.csv_data = (
         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 = {
         cls.bulk_edit_data = {

+ 2 - 0
netbox/dcim/views.py

@@ -1169,6 +1169,8 @@ class DeviceRoleView(generic.ObjectView):
 
 
         return {
         return {
             'devices_table': devices_table,
             '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"
     name = "extras"
 
 
     def ready(self):
     def ready(self):
+        import extras.lookups
         import extras.signals
         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, '']:
         if value not in [None, '']:
 
 
             # Validate text field
             # 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}'")
                     raise ValidationError(f"Value must match regex '{self.validation_regex}'")
 
 
             # Validate integer
             # Validate integer

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

@@ -431,9 +431,8 @@ class JournalEntry(ChangeLoggedModel):
         verbose_name_plural = 'journal entries'
         verbose_name_plural = 'journal entries'
 
 
     def __str__(self):
     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):
     def get_absolute_url(self):
         return reverse('extras:journalentry', args=[self.pk])
         return reverse('extras:journalentry', args=[self.pk])

+ 1 - 1
netbox/ipam/tables.py

@@ -65,7 +65,7 @@ VLAN_LINK = """
 {% if record.pk %}
 {% if record.pk %}
     <a href="{{ record.get_absolute_url }}">{{ record.vid }}</a>
     <a href="{{ record.get_absolute_url }}">{{ record.vid }}</a>
 {% elif perms.ipam.add_vlan %}
 {% 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 %}
 {% else %}
     {{ record.available }} VLAN{{ record.available|pluralize }} available
     {{ record.available }} VLAN{{ record.available|pluralize }} available
 {% endif %}
 {% endif %}

+ 21 - 5
netbox/ipam/utils.py

@@ -68,24 +68,40 @@ def add_available_ipaddresses(prefix, ipaddress_list, is_pool=False):
     return output
     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
     Create fake records for all gaps between used VLANs
     """
     """
     if not 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
     prev_vid = VLAN_VID_MAX
     new_vlans = []
     new_vlans = []
     for vlan in vlans:
     for vlan in vlans:
         if vlan.vid - prev_vid > 1:
         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
         prev_vid = vlan.vid
 
 
     if vlans[0].vid > VLAN_VID_MIN:
     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:
     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 = list(vlans) + new_vlans
     vlans.sort(key=lambda v: v.vid if type(v) == VLAN else v['vid'])
     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 = filtersets.RIRFilterSet
     filterset_form = forms.RIRFilterForm
     filterset_form = forms.RIRFilterForm
     table = tables.RIRTable
     table = tables.RIRTable
-    template_name = 'ipam/rir_list.html'
 
 
 
 
 class RIRView(generic.ObjectView):
 class RIRView(generic.ObjectView):
@@ -676,7 +675,7 @@ class VLANGroupView(generic.ObjectView):
             Prefetch('prefixes', queryset=Prefix.objects.restrict(request.user))
             Prefetch('prefixes', queryset=Prefix.objects.restrict(request.user))
         ).order_by('vid')
         ).order_by('vid')
         vlans_count = vlans.count()
         vlans_count = vlans.count()
-        vlans = add_available_vlans(instance, vlans)
+        vlans = add_available_vlans(vlans, vlan_group=instance)
 
 
         vlans_table = tables.VLANDetailTable(vlans)
         vlans_table = tables.VLANDetailTable(vlans)
         if request.user.has_perm('ipam.change_vlan') or request.user.has_perm('ipam.delete_vlan'):
         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.models import Circuit, ProviderNetwork, Provider
 from circuits.tables import CircuitTable, ProviderNetworkTable, ProviderTable
 from circuits.tables import CircuitTable, ProviderNetworkTable, ProviderTable
 from dcim.filtersets import (
 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 (
 from dcim.tables import (
-    CableTable, DeviceTable, DeviceTypeTable, PowerFeedTable, RackTable, LocationTable, SiteTable,
+    CableTable, DeviceTable, DeviceTypeTable, PowerFeedTable, RackTable, RackReservationTable, LocationTable, SiteTable,
     VirtualChassisTable,
     VirtualChassisTable,
 )
 )
 from ipam.filtersets import AggregateFilterSet, IPAddressFilterSet, PrefixFilterSet, VLANFilterSet, VRFFilterSet
 from ipam.filtersets import AggregateFilterSet, IPAddressFilterSet, PrefixFilterSet, VLANFilterSet, VRFFilterSet
@@ -64,6 +64,12 @@ SEARCH_TYPES = OrderedDict((
         'table': RackTable,
         'table': RackTable,
         'url': 'dcim:rack_list',
         'url': 'dcim:rack_list',
     }),
     }),
+    ('rackreservation', {
+        'queryset': RackReservation.objects.prefetch_related('site', 'rack', 'user'),
+        'filterset': RackReservationFilterSet,
+        'table': RackReservationTable,
+        'url': 'dcim:rackreservation_list',
+    }),
     ('location', {
     ('location', {
         'queryset': Location.objects.add_related_count(
         'queryset': Location.objects.add_related_count(
             Location.objects.all(),
             Location.objects.all(),

+ 5 - 8
netbox/netbox/filtersets.py

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

+ 3 - 2
netbox/netbox/forms.py

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

+ 1 - 1
netbox/netbox/settings.py

@@ -16,7 +16,7 @@ from django.core.validators import URLValidator
 # Environment setup
 # Environment setup
 #
 #
 
 
-VERSION = '2.11.7'
+VERSION = '2.11.8'
 
 
 # Hostname
 # Hostname
 HOSTNAME = platform.node()
 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 extras.models import CustomField, ExportTemplate
 from utilities.error_handlers import handle_protectederror
 from utilities.error_handlers import handle_protectederror
-from utilities.exceptions import AbortTransaction
+from utilities.exceptions import AbortTransaction, PermissionsViolation
 from utilities.forms import (
 from utilities.forms import (
     BootstrapMixin, BulkRenameForm, ConfirmationForm, CSVDataField, ImportForm, TableConfigForm, restrict_form_fields,
     BootstrapMixin, BulkRenameForm, ConfirmationForm, CSVDataField, ImportForm, TableConfigForm, restrict_form_fields,
 )
 )
@@ -290,7 +290,8 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
                     obj = form.save()
                     obj = form.save()
 
 
                     # Check that the new object conforms with any assigned object-level permissions
                     # 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(
                 msg = '{} {}'.format(
                     'Created' if object_created else 'Modified',
                     'Created' if object_created else 'Modified',
@@ -318,7 +319,7 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
                 else:
                 else:
                     return redirect(self.get_return_url(request, obj))
                     return redirect(self.get_return_url(request, obj))
 
 
-            except ObjectDoesNotExist:
+            except PermissionsViolation:
                 msg = "Object save failed due to object-level permissions violation"
                 msg = "Object save failed due to object-level permissions violation"
                 logger.debug(msg)
                 logger.debug(msg)
                 form.add_error(None, msg)
                 form.add_error(None, msg)
@@ -480,7 +481,7 @@ class BulkCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
 
 
                     # Enforce object-level permissions
                     # Enforce object-level permissions
                     if self.queryset.filter(pk__in=[obj.pk for obj in new_objs]).count() != len(new_objs):
                     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.
                     # 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)
                     msg = "Added {} {}".format(len(new_objs), model._meta.verbose_name_plural)
@@ -494,7 +495,7 @@ class BulkCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
             except IntegrityError:
             except IntegrityError:
                 pass
                 pass
 
 
-            except ObjectDoesNotExist:
+            except PermissionsViolation:
                 msg = "Object creation failed due to object-level permissions violation"
                 msg = "Object creation failed due to object-level permissions violation"
                 logger.debug(msg)
                 logger.debug(msg)
                 form.add_error(None, msg)
                 form.add_error(None, msg)
@@ -565,7 +566,8 @@ class ObjectImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
                         obj = model_form.save()
                         obj = model_form.save()
 
 
                         # Enforce object-level permissions
                         # 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})")
                         logger.debug(f"Created {obj} (PK: {obj.pk})")
 
 
@@ -601,7 +603,7 @@ class ObjectImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
                 except AbortTransaction:
                 except AbortTransaction:
                     pass
                     pass
 
 
-                except ObjectDoesNotExist:
+                except PermissionsViolation:
                     msg = "Object creation failed due to object-level permissions violation"
                     msg = "Object creation failed due to object-level permissions violation"
                     logger.debug(msg)
                     logger.debug(msg)
                     form.add_error(None, msg)
                     form.add_error(None, msg)
@@ -712,7 +714,7 @@ class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
 
 
                     # Enforce object-level permissions
                     # Enforce object-level permissions
                     if self.queryset.filter(pk__in=[obj.pk for obj in new_objs]).count() != len(new_objs):
                     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
                 # Compile a table containing the imported objects
                 obj_table = self.table(new_objs)
                 obj_table = self.table(new_objs)
@@ -730,7 +732,7 @@ class BulkImportView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
             except ValidationError:
             except ValidationError:
                 pass
                 pass
 
 
-            except ObjectDoesNotExist:
+            except PermissionsViolation:
                 msg = "Object import failed due to object-level permissions violation"
                 msg = "Object import failed due to object-level permissions violation"
                 logger.debug(msg)
                 logger.debug(msg)
                 form.add_error(None, msg)
                 form.add_error(None, msg)
@@ -845,7 +847,7 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
 
 
                         # Enforce object-level permissions
                         # Enforce object-level permissions
                         if self.queryset.filter(pk__in=[obj.pk for obj in updated_objects]).count() != len(updated_objects):
                         if self.queryset.filter(pk__in=[obj.pk for obj in updated_objects]).count() != len(updated_objects):
-                            raise ObjectDoesNotExist
+                            raise PermissionsViolation
 
 
                     if updated_objects:
                     if updated_objects:
                         msg = 'Updated {} {}'.format(len(updated_objects), model._meta.verbose_name_plural)
                         msg = 'Updated {} {}'.format(len(updated_objects), model._meta.verbose_name_plural)
@@ -857,7 +859,7 @@ class BulkEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
                 except ValidationError as e:
                 except ValidationError as e:
                     messages.error(self.request, "{} failed validation: {}".format(obj, e))
                     messages.error(self.request, "{} failed validation: {}".format(obj, e))
 
 
-                except ObjectDoesNotExist:
+                except PermissionsViolation:
                     msg = "Object update failed due to object-level permissions violation"
                     msg = "Object update failed due to object-level permissions violation"
                     logger.debug(msg)
                     logger.debug(msg)
                     form.add_error(None, msg)
                     form.add_error(None, msg)
@@ -952,7 +954,7 @@ class BulkRenameView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
 
 
                             # Enforce constrained permissions
                             # Enforce constrained permissions
                             if self.queryset.filter(pk__in=renamed_pks).count() != len(selected_objects):
                             if self.queryset.filter(pk__in=renamed_pks).count() != len(selected_objects):
-                                raise ObjectDoesNotExist
+                                raise PermissionsViolation
 
 
                             messages.success(request, "Renamed {} {}".format(
                             messages.success(request, "Renamed {} {}".format(
                                 len(selected_objects),
                                 len(selected_objects),
@@ -960,7 +962,7 @@ class BulkRenameView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
                             ))
                             ))
                             return redirect(self.get_return_url(request))
                             return redirect(self.get_return_url(request))
 
 
-                except ObjectDoesNotExist:
+                except PermissionsViolation:
                     msg = "Object update failed due to object-level permissions violation"
                     msg = "Object update failed due to object-level permissions violation"
                     logger.debug(msg)
                     logger.debug(msg)
                     form.add_error(None, msg)
                     form.add_error(None, msg)
@@ -1146,7 +1148,7 @@ class ComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View
 
 
                         # Enforce object-level permissions
                         # Enforce object-level permissions
                         if self.queryset.filter(pk__in=[obj.pk for obj in new_objs]).count() != len(new_objs):
                         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(
                     messages.success(request, "Added {} {}".format(
                         len(new_components), self.queryset.model._meta.verbose_name_plural
                         len(new_components), self.queryset.model._meta.verbose_name_plural
@@ -1156,7 +1158,7 @@ class ComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View
                     else:
                     else:
                         return redirect(self.get_return_url(request))
                         return redirect(self.get_return_url(request))
 
 
-                except ObjectDoesNotExist:
+                except PermissionsViolation:
                     msg = "Component creation failed due to object-level permissions violation"
                     msg = "Component creation failed due to object-level permissions violation"
                     logger.debug(msg)
                     logger.debug(msg)
                     form.add_error(None, msg)
                     form.add_error(None, msg)
@@ -1229,7 +1231,7 @@ class BulkComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin,
                                 component_form = self.model_form(component_data)
                                 component_form = self.model_form(component_data)
                                 if component_form.is_valid():
                                 if component_form.is_valid():
                                     instance = component_form.save()
                                     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)
                                     new_components.append(instance)
                                 else:
                                 else:
                                     for field, errors in component_form.errors.as_data().items():
                                     for field, errors in component_form.errors.as_data().items():
@@ -1238,12 +1240,12 @@ class BulkComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin,
 
 
                         # Enforce object-level permissions
                         # Enforce object-level permissions
                         if self.queryset.filter(pk__in=[obj.pk for obj in new_components]).count() != len(new_components):
                         if self.queryset.filter(pk__in=[obj.pk for obj in new_components]).count() != len(new_components):
-                            raise ObjectDoesNotExist
+                            raise PermissionsViolation
 
 
                 except IntegrityError:
                 except IntegrityError:
                     pass
                     pass
 
 
-                except ObjectDoesNotExist:
+                except PermissionsViolation:
                     msg = "Component creation failed due to object-level permissions violation"
                     msg = "Component creation failed due to object-level permissions violation"
                     logger.debug(msg)
                     logger.debug(msg)
                     form.add_error(None, 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>
                     <p class="text-muted">{{ settings.HOSTNAME }} (v{{ settings.VERSION }})</p>
                 </div>
                 </div>
                 <div class="col-xs-4 text-center">
                 <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>
                 <div class="col-xs-4 text-right noprint">
                 <div class="col-xs-4 text-right noprint">
                     <p class="text-muted">
                     <p class="text-muted">

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

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

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

@@ -1,4 +1,5 @@
 {% extends 'base.html' %}
 {% extends 'base.html' %}
+{% load helpers %}
 {% load form_helpers %}
 {% load form_helpers %}
 
 
 {% block title %}Create {{ component_type }}{% endblock %}
 {% block title %}Create {{ component_type }}{% endblock %}
@@ -18,19 +19,34 @@
             {% endif %}
             {% endif %}
             <div class="panel panel-default">
             <div class="panel panel-default">
                 <div class="panel-heading">
                 <div class="panel-heading">
-                    <strong>{{ component_type|title }}</strong>
+                    <strong>{{ component_type|bettertitle }}</strong>
                 </div>
                 </div>
                 <div class="panel-body">
                 <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>
             </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">
                 <div class="col-md-9 col-md-offset-3">
                     <button type="submit" name="_create" class="btn btn-primary">Create</button>
                     <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>
                     <button type="submit" name="_addanother" class="btn btn-primary">Create and Add More</button>
                     <a href="{{ return_url }}" class="btn btn-default">Cancel</a>
                     <a href="{{ return_url }}" class="btn btn-default">Cancel</a>
                 </div>
                 </div>
-		    </div>
+            </div>
         </div>
         </div>
     </div>
     </div>
 </form>
 </form>

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

@@ -42,7 +42,17 @@
         <tr>
         <tr>
           <td>Devices</td>
           <td>Devices</td>
           <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>
           </td>
         </tr>
         </tr>
       </table>
       </table>

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

@@ -54,6 +54,16 @@
                             {% endif %}
                             {% endif %}
                         </td>
                         </td>
                     </tr>
                     </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>
                     <tr>
                         <td>Parent</td>
                         <td>Parent</td>
                         <td>
                         <td>
@@ -88,7 +98,7 @@
                     </tr>
                     </tr>
                     <tr>
                     <tr>
                         <td>802.1Q Mode</td>
                         <td>802.1Q Mode</td>
-                        <td>{{ object.get_mode_display }}</td>
+                        <td>{{ object.get_mode_display|placeholder }}</td>
                     </tr>
                     </tr>
                 </table>
                 </table>
             </div>
             </div>

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

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

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

@@ -80,7 +80,11 @@
                     <td>
                     <td>
                         {% if object.time_zone %}
                         {% if object.time_zone %}
                             {{ object.time_zone }} (UTC {{ object.time_zone|tzoffset }})<br />
                             {{ 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 %}
                         {% else %}
                             <span class="text-muted">&mdash;</span>
                             <span class="text-muted">&mdash;</span>
                         {% endif %}
                         {% endif %}

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

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

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

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

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

@@ -38,7 +38,7 @@
         <div class="col-md-12">
         <div class="col-md-12">
             {% if report.result %}
             {% if report.result %}
                 Last run: <a href="{% url 'extras:report_result' job_result_pk=report.result.pk %}">
                 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>
                 </a>
             {% endif %}
             {% endif %}
         </div>
         </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="rendered-markdown">{{ report.description|render_markdown|placeholder }}</td>
                                     <td class="text-right">
                                     <td class="text-right">
                                         {% if report.result %}
                                         {% 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 %}
                                         {% else %}
                                             <span class="text-muted">Never</span>
                                             <span class="text-muted">Never</span>
                                         {% endif %}
                                         {% endif %}

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

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

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

@@ -29,7 +29,7 @@
                                     <td>{{ script.Meta.description|render_markdown }}</td>
                                     <td>{{ script.Meta.description|render_markdown }}</td>
                                     {% if script.result %}
                                     {% if script.result %}
                                         <td class="text-right">
                                         <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>
                                         </td>
                                     {% else %}
                                     {% else %}
                                         <td class="text-right text-muted">Never</td>
                                         <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' %}">Scripts</a></li>
                 <li><a href="{% url 'extras:script_list' %}#module.{{ script.module }}">{{ script.module|bettertitle }}</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><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>
             </ol>
         </div>
         </div>
     </div>
     </div>
@@ -32,7 +32,7 @@
     </ul>
     </ul>
     <div class="tab-content">
     <div class="tab-content">
         <p>
         <p>
-            Run: <strong>{{ result.created }}</strong>
+            Run: <strong>{{ result.created|annotated_date }}</strong>
             {% if result.completed %}
             {% if result.completed %}
                 Duration: <strong>{{ result.duration }}</strong>
                 Duration: <strong>{{ result.duration }}</strong>
             {% else %}
             {% else %}

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

@@ -42,8 +42,8 @@
   <h1 class="title">{% block title %}{{ object }}{% endblock %}</h1>
   <h1 class="title">{% block title %}{{ object }}{% endblock %}</h1>
   <p>
   <p>
     <small class="text-muted">
     <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>
     </small>
     <span class="label label-default">{{ object|meta:"app_label" }}.{{ object|meta:"model_name" }}:{{ object.pk }}</span>
     <span class="label label-default">{{ object|meta:"app_label" }}.{{ object|meta:"model_name" }}:{{ object.pk }}</span>
   </p>
   </p>

+ 1 - 1
netbox/templates/home.html

@@ -291,7 +291,7 @@
                     {% for result in report_results %}
                     {% for result in report_results %}
                         <tr>
                         <tr>
                             <td><a href="{% url 'extras:report_result' job_result_pk=result.pk %}">{{ result.name }}</a></td>
                             <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>
                         </tr>
                     {% endfor %}
                     {% endfor %}
                 </table>
                 </table>

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

@@ -1,3 +1,4 @@
+{% load helpers %}
 {% if images %}
 {% if images %}
     <table class="table table-hover panel-body">
     <table class="table table-hover panel-body">
         <tr>
         <tr>
@@ -13,7 +14,7 @@
                     <a class="image-preview" href="{{ attachment.image.url }}" target="_blank">{{ attachment }}</a>
                     <a class="image-preview" href="{{ attachment.image.url }}" target="_blank">{{ attachment }}</a>
                 </td>
                 </td>
                 <td>{{ attachment.size|filesizeformat }}</td>
                 <td>{{ attachment.size|filesizeformat }}</td>
-                <td>{{ attachment.created }}</td>
+                <td>{{ attachment.created|annotated_date }}</td>
                 <td class="text-right noprint">
                 <td class="text-right noprint">
                     {% if perms.extras.change_imageattachment %}
                     {% if perms.extras.change_imageattachment %}
                         <a href="{% url 'extras:imageattachment_edit' pk=attachment.pk %}" class="btn btn-warning btn-xs" title="Edit image">
                         <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>
                 <tr>
                 <tr>
                     <td>Date Added</td>
                     <td>Date Added</td>
-                    <td>{{ object.date_added|placeholder }}</td>
+                    <td>{{ object.date_added|annotated_date|placeholder }}</td>
                 </tr>
                 </tr>
                 <tr>
                 <tr>
                     <td>Description</td>
                     <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="row">
                             <div class="col-md-4">
                             <div class="col-md-4">
                                 <small class="text-muted">Created</small><br />
                                 <small class="text-muted">Created</small><br />
-                                <span title="{{ token.created }}">{{ token.created|date }}</span>
+                                {{ token.created|annotated_date }}
                             </div>
                             </div>
                             <div class="col-md-4">
                             <div class="col-md-4">
                                 <small class="text-muted">Expires</small><br />
                                 <small class="text-muted">Expires</small><br />
                                 {% if token.expires %}
                                 {% if token.expires %}
-                                    <span title="{{ token.expires }}">{{ token.expires|date }}</span>
+                                    {{ token.expires|annotated_date }}
                                 {% else %}
                                 {% else %}
                                     <span>Never</span>
                                     <span>Never</span>
                                 {% endif %}
                                 {% endif %}

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

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

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

@@ -1,4 +1,5 @@
 {% extends 'users/base.html' %}
 {% extends 'users/base.html' %}
+{% load helpers %}
 
 
 {% block title %}User Key{% endblock %}
 {% block title %}User Key{% endblock %}
 
 
@@ -19,7 +20,9 @@
             {% endif %}
             {% endif %}
         </h4>
         </h4>
         <p>
         <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>
         </p>
         {% if not object.is_active %}
         {% if not object.is_active %}
             <div class="alert alert-warning" role="alert">
             <div class="alert alert-warning" role="alert">
@@ -37,7 +40,7 @@
                 </a>
                 </a>
             </div>
             </div>
             <h4>Session key: <span class="label label-success">Active</span></h4>
             <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 %}
         {% else %}
             <h4>No active session key</h4>
             <h4>No active session key</h4>
         {% endif %}
         {% endif %}

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

@@ -138,7 +138,7 @@
                     <td><i class="mdi mdi-chip"></i> Memory</td>
                     <td><i class="mdi mdi-chip"></i> Memory</td>
                     <td>
                     <td>
                         {% if object.memory %}
                         {% if object.memory %}
-                            {{ object.memory }} MB
+                            <span title="{{ object.memory }} MB">{{ object.memory|humanize_megabytes }}</span>
                         {% else %}
                         {% else %}
                             <span class="text-muted">&mdash;</span>
                             <span class="text-muted">&mdash;</span>
                         {% endif %}
                         {% 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',
     isw='istartswith',
     nisw='istartswith',
     nisw='istartswith',
     ie='iexact',
     ie='iexact',
-    nie='iexact'
+    nie='iexact',
+    empty='empty',
 )
 )
 
 
 FILTER_NUMERIC_BASED_LOOKUP_MAP = dict(
 FILTER_NUMERIC_BASED_LOOKUP_MAP = dict(

+ 8 - 0
netbox/utilities/exceptions.py

@@ -9,6 +9,14 @@ class AbortTransaction(Exception):
     pass
     pass
 
 
 
 
+class PermissionsViolation(Exception):
+    """
+    Raised when an operation was prevented because it would violate the
+    allowed permissions.
+    """
+    pass
+
+
 class RQWorkerNotRunningException(APIException):
 class RQWorkerNotRunningException(APIException):
     """
     """
     Indicates the temporary inability to enqueue a new task (e.g. custom script execution) because no RQ worker
     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
 import yaml
 from django import template
 from django import template
 from django.conf import settings
 from django.conf import settings
+from django.template.defaultfilters import date
 from django.urls import NoReverseMatch, reverse
 from django.urls import NoReverseMatch, reverse
+from django.utils import timezone
 from django.utils.html import strip_tags
 from django.utils.html import strip_tags
 from django.utils.safestring import mark_safe
 from django.utils.safestring import mark_safe
 from markdown import markdown
 from markdown import markdown
@@ -129,6 +131,20 @@ def humanize_speed(speed):
         return '{} Kbps'.format(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()
 @register.filter()
 def tzoffset(value):
 def tzoffset(value):
     """
     """
@@ -137,6 +153,36 @@ def tzoffset(value):
     return datetime.datetime.now(value).strftime('%z')
     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()
 @register.filter()
 def fgcolor(value):
 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.forms import InterfaceCommonForm, INTERFACE_MODE_HELP_TEXT
 from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site, SiteGroup
 from dcim.models import Device, DeviceRole, Platform, Rack, Region, Site, SiteGroup
 from extras.forms import (
 from extras.forms import (
-    AddRemoveTagsForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm, CustomFieldFilterForm,
+    AddRemoveTagsForm, CustomFieldForm, CustomFieldBulkEditForm, CustomFieldModelCSVForm, CustomFieldModelForm,
+    CustomFieldFilterForm,
 )
 )
 from extras.models import Tag
 from extras.models import Tag
 from ipam.models import IPAddress, VLAN
 from ipam.models import IPAddress, VLAN
@@ -527,8 +528,8 @@ class VirtualMachineBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldB
 class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
 class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFilterForm):
     model = VirtualMachine
     model = VirtualMachine
     field_order = [
     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(
     q = forms.CharField(
         required=False,
         required=False,
@@ -556,14 +557,20 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil
         required=False,
         required=False,
         label=_('Region')
         label=_('Region')
     )
     )
+    site_group_id = DynamicModelMultipleChoiceField(
+        queryset=SiteGroup.objects.all(),
+        required=False,
+        label=_('Site group')
+    )
     site_id = DynamicModelMultipleChoiceField(
     site_id = DynamicModelMultipleChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),
         required=False,
         required=False,
         null_option='None',
         null_option='None',
         query_params={
         query_params={
-            'region_id': '$region_id'
+            'region_id': '$region_id',
+            'group_id': '$site_group_id',
         },
         },
-        label=_('Cluster')
+        label=_('Site')
     )
     )
     role_id = DynamicModelMultipleChoiceField(
     role_id = DynamicModelMultipleChoiceField(
         queryset=DeviceRole.objects.all(),
         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)
         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(
     virtual_machine = DynamicModelChoiceField(
         queryset=VirtualMachine.objects.all()
         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)
         self.fields['tagged_vlans'].widget.add_query_param('available_on_virtualmachine', vm_id)
 
 
 
 
-class VMInterfaceCSVForm(CSVModelForm):
+class VMInterfaceCSVForm(CustomFieldModelCSVForm):
     virtual_machine = CSVModelChoiceField(
     virtual_machine = CSVModelChoiceField(
         queryset=VirtualMachine.objects.all(),
         queryset=VirtualMachine.objects.all(),
         to_field_name='name'
         to_field_name='name'
@@ -740,7 +748,7 @@ class VMInterfaceCSVForm(CSVModelForm):
             return self.cleaned_data['enabled']
             return self.cleaned_data['enabled']
 
 
 
 
-class VMInterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
+class VMInterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=VMInterface.objects.all(),
         queryset=VMInterface.objects.all(),
         widget=forms.MultipleHiddenInput()
         widget=forms.MultipleHiddenInput()

+ 7 - 1
netbox/virtualization/views.py

@@ -34,6 +34,9 @@ class ClusterTypeView(generic.ObjectView):
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
         clusters = Cluster.objects.restrict(request.user, 'view').filter(
         clusters = Cluster.objects.restrict(request.user, 'view').filter(
             type=instance
             type=instance
+        ).annotate(
+            device_count=count_related(Device, 'cluster'),
+            vm_count=count_related(VirtualMachine, 'cluster')
         )
         )
 
 
         clusters_table = tables.ClusterTable(clusters)
         clusters_table = tables.ClusterTable(clusters)
@@ -93,6 +96,9 @@ class ClusterGroupView(generic.ObjectView):
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
         clusters = Cluster.objects.restrict(request.user, 'view').filter(
         clusters = Cluster.objects.restrict(request.user, 'view').filter(
             group=instance
             group=instance
+        ).annotate(
+            device_count=count_related(Device, 'cluster'),
+            vm_count=count_related(VirtualMachine, 'cluster')
         )
         )
 
 
         clusters_table = tables.ClusterTable(clusters)
         clusters_table = tables.ClusterTable(clusters)
@@ -455,7 +461,7 @@ class VMInterfaceCreateView(generic.ComponentCreateView):
     queryset = VMInterface.objects.all()
     queryset = VMInterface.objects.all()
     form = forms.VMInterfaceCreateForm
     form = forms.VMInterfaceCreateForm
     model_form = forms.VMInterfaceForm
     model_form = forms.VMInterfaceForm
-    template_name = 'virtualization/virtualmachine_component_add.html'
+    template_name = 'dcim/device_component_add.html'
 
 
 
 
 class VMInterfaceEditView(generic.ObjectEditView):
 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-cacheops==6.0
 django-cors-headers==3.7.0
 django-cors-headers==3.7.0
 django-debug-toolbar==3.2.1
 django-debug-toolbar==3.2.1
@@ -8,7 +8,7 @@ django-pglocks==1.0.4
 django-prometheus==2.1.0
 django-prometheus==2.1.0
 django-rq==2.4.1
 django-rq==2.4.1
 django-tables2==2.4.0
 django-tables2==2.4.0
-django-taggit==1.4.0
+django-taggit==1.5.1
 django-timezone-field==4.1.2
 django-timezone-field==4.1.2
 djangorestframework==3.12.4
 djangorestframework==3.12.4
 drf-yasg[validation]==1.20.0
 drf-yasg[validation]==1.20.0
@@ -16,8 +16,8 @@ gunicorn==20.1.0
 Jinja2==3.0.1
 Jinja2==3.0.1
 Markdown==3.3.4
 Markdown==3.3.4
 netaddr==0.8.0
 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
 pycryptodome==3.10.1
 PyYAML==5.4.1
 PyYAML==5.4.1
 svgwrite==1.4.1
 svgwrite==1.4.1