diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml
index 3c52c973c..9af9647f0 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yaml
+++ b/.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.3
+ placeholder: v2.11.4
validations:
required: true
- type: dropdown
@@ -39,8 +39,9 @@ body:
reproduce this bug using the current stable release of NetBox. Begin with the
creation of any necessary database objects and call out every operation being
performed explicitly. If reporting a bug in the REST API, be sure to reconstruct
- the raw HTTP request(s) being made: Don't rely on a client library such as
- pynetbox."
+ the raw HTTP request(s) being made: Don't rely on a client library such as
+ pynetbox. Additionally, **do not rely on the demo instance** for reproducing
+ suspected bugs, as its data is prone to modification or deletion at any time.
placeholder: |
1. Click on "create widget"
2. Set foo to 12 and bar to G
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml
index 9181f7ce4..b6e53491e 100644
--- a/.github/ISSUE_TEMPLATE/feature_request.yaml
+++ b/.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.3
+ placeholder: v2.11.4
validations:
required: true
- type: dropdown
diff --git a/README.md b/README.md
index ad0595782..2ab04db02 100644
--- a/README.md
+++ b/README.md
@@ -22,6 +22,8 @@ The complete documentation for NetBox can be found at [Read the Docs](https://ne
Thank you to our sponsors!
+ [](https://try.digitalocean.com/developer-cloud)
+
[](https://ns1.com/)
[](https://stellar.tech/)
diff --git a/docs/additional-features/caching.md b/docs/additional-features/caching.md
index ebce63578..18c9dca68 100644
--- a/docs/additional-features/caching.md
+++ b/docs/additional-features/caching.md
@@ -6,7 +6,7 @@ If a change is made to any of the objects returned by the query within that time
## Invalidating Cached Data
-Although caching is performed automatically and rarely requires administrative intervention, NetBox provides the `invalidate` management command to force invalidation of cached results. This command can reference a specific object my its type and numeric ID:
+Although caching is performed automatically and rarely requires administrative intervention, NetBox provides the `invalidate` management command to force invalidation of cached results. This command can reference a specific object by its type and numeric ID:
```no-highlight
$ python netbox/manage.py invalidate dcim.Device.34
diff --git a/docs/models/dcim/powerfeed.md b/docs/models/dcim/powerfeed.md
index 48ad2a5dc..bac7214f1 100644
--- a/docs/models/dcim/powerfeed.md
+++ b/docs/models/dcim/powerfeed.md
@@ -1,6 +1,6 @@
# Power Feed
-A power feed represents the distribution of power from a power panel to a particular device, typically a power distribution unit (PDU). The power pot (inlet) on a device can be connected via a cable to a power feed. A power feed may optionally be assigned to a rack to allow more easily tracking the distribution of power among racks.
+A power feed represents the distribution of power from a power panel to a particular device, typically a power distribution unit (PDU). The power port (inlet) on a device can be connected via a cable to a power feed. A power feed may optionally be assigned to a rack to allow more easily tracking the distribution of power among racks.
Each power feed is assigned an operational type (primary or redundant) and one of the following statuses:
diff --git a/docs/release-notes/version-2.11.md b/docs/release-notes/version-2.11.md
index 0827d5434..4a0fcef8f 100644
--- a/docs/release-notes/version-2.11.md
+++ b/docs/release-notes/version-2.11.md
@@ -1,5 +1,28 @@
# NetBox v2.11
+## v2.11.4 (2021-05-25)
+
+### Enhancements
+
+* [#5121](https://github.com/netbox-community/netbox/issues/5121) - Add content type filters for tags
+* [#6358](https://github.com/netbox-community/netbox/issues/6358) - Add search field for VLAN groups
+* [#6393](https://github.com/netbox-community/netbox/issues/6393) - Add `description` filter for IP addresses
+* [#6400](https://github.com/netbox-community/netbox/issues/6400) - Add cyan color choice for plugin buttons
+* [#6422](https://github.com/netbox-community/netbox/issues/6422) - Enable filtering users by group under admin UI
+* [#6441](https://github.com/netbox-community/netbox/issues/6441) - Improve UI paginator to optimize page object count
+
+### Bug Fixes
+
+* [#6376](https://github.com/netbox-community/netbox/issues/6376) - Fix assignment of VLAN groups to clusters, cluster groups via REST API
+* [#6398](https://github.com/netbox-community/netbox/issues/6398) - Avoid exception when deleting device connected to self via circuit
+* [#6426](https://github.com/netbox-community/netbox/issues/6426) - Allow assigning virtual chassis member interfaces to LAG on VC master
+* [#6438](https://github.com/netbox-community/netbox/issues/6438) - Fix missing descriptions and label for device type imports and exports
+* [#6465](https://github.com/netbox-community/netbox/issues/6465) - Fix typo in installed plugins REST API endpoint
+* [#6467](https://github.com/netbox-community/netbox/issues/6467) - Fix access to metrics on custom `BASE_PATH` when login is required
+* [#6468](https://github.com/netbox-community/netbox/issues/6468) - Disable ordering VLAN groups list by scope object
+
+---
+
## v2.11.3 (2021-05-07)
### Enhancements
diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py
index 31d08537e..699ded7b0 100644
--- a/netbox/circuits/models.py
+++ b/netbox/circuits/models.py
@@ -20,7 +20,7 @@ __all__ = (
)
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Provider(PrimaryModel):
"""
Each Circuit belongs to a Provider. This is usually a telecommunications company or similar organization. This model
@@ -96,7 +96,7 @@ class Provider(PrimaryModel):
# Provider networks
#
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class ProviderNetwork(PrimaryModel):
"""
This represents a provider network which exists outside of NetBox, the details of which are unknown or
@@ -189,7 +189,7 @@ class CircuitType(OrganizationalModel):
)
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Circuit(PrimaryModel):
"""
A communications circuit connects two points. Each Circuit belongs to a Provider; Providers may have multiple
diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py
index acaa3f4ec..efb712963 100644
--- a/netbox/dcim/forms.py
+++ b/netbox/dcim/forms.py
@@ -1825,7 +1825,7 @@ class ConsolePortTemplateImportForm(ComponentTemplateImportForm):
class Meta:
model = ConsolePortTemplate
fields = [
- 'device_type', 'name', 'label', 'type',
+ 'device_type', 'name', 'label', 'type', 'description',
]
@@ -1834,7 +1834,7 @@ class ConsoleServerPortTemplateImportForm(ComponentTemplateImportForm):
class Meta:
model = ConsoleServerPortTemplate
fields = [
- 'device_type', 'name', 'label', 'type',
+ 'device_type', 'name', 'label', 'type', 'description',
]
@@ -1843,7 +1843,7 @@ class PowerPortTemplateImportForm(ComponentTemplateImportForm):
class Meta:
model = PowerPortTemplate
fields = [
- 'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw',
+ 'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description',
]
@@ -1857,7 +1857,7 @@ class PowerOutletTemplateImportForm(ComponentTemplateImportForm):
class Meta:
model = PowerOutletTemplate
fields = [
- 'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg',
+ 'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description',
]
@@ -1869,7 +1869,7 @@ class InterfaceTemplateImportForm(ComponentTemplateImportForm):
class Meta:
model = InterfaceTemplate
fields = [
- 'device_type', 'name', 'label', 'type', 'mgmt_only',
+ 'device_type', 'name', 'label', 'type', 'mgmt_only', 'description',
]
@@ -1886,7 +1886,7 @@ class FrontPortTemplateImportForm(ComponentTemplateImportForm):
class Meta:
model = FrontPortTemplate
fields = [
- 'device_type', 'name', 'type', 'rear_port', 'rear_port_position',
+ 'device_type', 'name', 'type', 'rear_port', 'rear_port_position', 'label', 'description',
]
@@ -1898,7 +1898,7 @@ class RearPortTemplateImportForm(ComponentTemplateImportForm):
class Meta:
model = RearPortTemplate
fields = [
- 'device_type', 'name', 'type', 'positions',
+ 'device_type', 'name', 'type', 'positions', 'label', 'description',
]
@@ -1907,7 +1907,7 @@ class DeviceBayTemplateImportForm(ComponentTemplateImportForm):
class Meta:
model = DeviceBayTemplate
fields = [
- 'device_type', 'name',
+ 'device_type', 'name', 'label', 'description',
]
@@ -3126,9 +3126,13 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm):
device = Device.objects.get(pk=self.data['device']) if self.is_bound else self.instance.device
- # Restrict parent/LAG interface assignment by device
+ # Restrict parent/LAG interface assignment by device/VC
self.fields['parent'].widget.add_query_param('device_id', device.pk)
- self.fields['lag'].widget.add_query_param('device_id', device.pk)
+ if device.virtual_chassis and device.virtual_chassis.master:
+ # Get available LAG interfaces by VirtualChassis master
+ self.fields['lag'].widget.add_query_param('device_id', device.virtual_chassis.master.pk)
+ else:
+ self.fields['lag'].widget.add_query_param('device_id', device.pk)
# Limit VLAN choices by device
self.fields['untagged_vlan'].widget.add_query_param('available_on_device', device.pk)
diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py
index 28d21ff68..e7040376c 100644
--- a/netbox/dcim/models/cables.py
+++ b/netbox/dcim/models/cables.py
@@ -30,7 +30,7 @@ __all__ = (
# Cables
#
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Cable(PrimaryModel):
"""
A physical connection between two endpoints.
diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py
index f2b13ed6f..bd7f4ac55 100644
--- a/netbox/dcim/models/device_components.py
+++ b/netbox/dcim/models/device_components.py
@@ -211,7 +211,7 @@ class PathEndpoint(models.Model):
# Console ports
#
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class ConsolePort(ComponentModel, CableTermination, PathEndpoint):
"""
A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts.
@@ -254,7 +254,7 @@ class ConsolePort(ComponentModel, CableTermination, PathEndpoint):
# Console server ports
#
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class ConsoleServerPort(ComponentModel, CableTermination, PathEndpoint):
"""
A physical port within a Device (typically a designated console server) which provides access to ConsolePorts.
@@ -297,7 +297,7 @@ class ConsoleServerPort(ComponentModel, CableTermination, PathEndpoint):
# Power ports
#
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class PowerPort(ComponentModel, CableTermination, PathEndpoint):
"""
A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets.
@@ -408,7 +408,7 @@ class PowerPort(ComponentModel, CableTermination, PathEndpoint):
# Power outlets
#
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class PowerOutlet(ComponentModel, CableTermination, PathEndpoint):
"""
A physical power outlet (output) within a Device which provides power to a PowerPort.
@@ -512,7 +512,7 @@ class BaseInterface(models.Model):
return self.ip_addresses.count()
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint):
"""
A network interface within a Device. A physical Interface can connect to exactly one other Interface.
@@ -683,7 +683,7 @@ class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint):
# Pass-through ports
#
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class FrontPort(ComponentModel, CableTermination):
"""
A pass-through port on the front of a Device.
@@ -748,7 +748,7 @@ class FrontPort(ComponentModel, CableTermination):
})
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class RearPort(ComponentModel, CableTermination):
"""
A pass-through port on the rear of a Device.
@@ -801,7 +801,7 @@ class RearPort(ComponentModel, CableTermination):
# Device bays
#
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class DeviceBay(ComponentModel):
"""
An empty space within a Device which can house a child device
@@ -860,7 +860,7 @@ class DeviceBay(ComponentModel):
# Inventory items
#
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class InventoryItem(MPTTModel, ComponentModel):
"""
An InventoryItem represents a serialized piece of hardware within a Device, such as a line card or power supply.
diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py
index e8c7d5b51..95c3c50db 100644
--- a/netbox/dcim/models/devices.py
+++ b/netbox/dcim/models/devices.py
@@ -75,7 +75,7 @@ class Manufacturer(OrganizationalModel):
)
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class DeviceType(PrimaryModel):
"""
A DeviceType represents a particular make (Manufacturer) and model of device. It specifies rack height and depth, as
@@ -183,6 +183,8 @@ class DeviceType(PrimaryModel):
{
'name': c.name,
'type': c.type,
+ 'label': c.label,
+ 'description': c.description,
}
for c in self.consoleporttemplates.all()
]
@@ -191,6 +193,8 @@ class DeviceType(PrimaryModel):
{
'name': c.name,
'type': c.type,
+ 'label': c.label,
+ 'description': c.description,
}
for c in self.consoleserverporttemplates.all()
]
@@ -201,6 +205,8 @@ class DeviceType(PrimaryModel):
'type': c.type,
'maximum_draw': c.maximum_draw,
'allocated_draw': c.allocated_draw,
+ 'label': c.label,
+ 'description': c.description,
}
for c in self.powerporttemplates.all()
]
@@ -211,6 +217,8 @@ class DeviceType(PrimaryModel):
'type': c.type,
'power_port': c.power_port.name if c.power_port else None,
'feed_leg': c.feed_leg,
+ 'label': c.label,
+ 'description': c.description,
}
for c in self.poweroutlettemplates.all()
]
@@ -220,6 +228,8 @@ class DeviceType(PrimaryModel):
'name': c.name,
'type': c.type,
'mgmt_only': c.mgmt_only,
+ 'label': c.label,
+ 'description': c.description,
}
for c in self.interfacetemplates.all()
]
@@ -230,6 +240,8 @@ class DeviceType(PrimaryModel):
'type': c.type,
'rear_port': c.rear_port.name,
'rear_port_position': c.rear_port_position,
+ 'label': c.label,
+ 'description': c.description,
}
for c in self.frontporttemplates.all()
]
@@ -239,6 +251,8 @@ class DeviceType(PrimaryModel):
'name': c.name,
'type': c.type,
'positions': c.positions,
+ 'label': c.label,
+ 'description': c.description,
}
for c in self.rearporttemplates.all()
]
@@ -246,6 +260,8 @@ class DeviceType(PrimaryModel):
data['device-bays'] = [
{
'name': c.name,
+ 'label': c.label,
+ 'description': c.description,
}
for c in self.devicebaytemplates.all()
]
@@ -452,7 +468,7 @@ class Platform(OrganizationalModel):
)
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Device(PrimaryModel, ConfigContextModel):
"""
A Device represents a piece of physical hardware mounted within a Rack. Each Device is assigned a DeviceType,
@@ -906,7 +922,7 @@ class Device(PrimaryModel, ConfigContextModel):
# Virtual chassis
#
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class VirtualChassis(PrimaryModel):
"""
A collection of Devices which operate with a shared control plane (e.g. a switch stack).
diff --git a/netbox/dcim/models/power.py b/netbox/dcim/models/power.py
index a5e3149f8..03e77eea9 100644
--- a/netbox/dcim/models/power.py
+++ b/netbox/dcim/models/power.py
@@ -21,7 +21,7 @@ __all__ = (
# Power
#
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class PowerPanel(PrimaryModel):
"""
A distribution point for electrical power; e.g. a data center RPP.
@@ -71,7 +71,7 @@ class PowerPanel(PrimaryModel):
)
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class PowerFeed(PrimaryModel, PathEndpoint, CableTermination):
"""
An electrical circuit delivered from a PowerPanel.
diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py
index 0eb799dd4..c4416ca28 100644
--- a/netbox/dcim/models/racks.py
+++ b/netbox/dcim/models/racks.py
@@ -78,7 +78,7 @@ class RackRole(OrganizationalModel):
)
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Rack(PrimaryModel):
"""
Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face.
@@ -467,7 +467,7 @@ class Rack(PrimaryModel):
return int(allocated_draw_total / available_power_total * 100)
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class RackReservation(PrimaryModel):
"""
One or more reserved units within a Rack.
diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py
index 1e5165088..7ab37567a 100644
--- a/netbox/dcim/models/sites.py
+++ b/netbox/dcim/models/sites.py
@@ -130,7 +130,7 @@ class SiteGroup(NestedGroupModel):
# Sites
#
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Site(PrimaryModel):
"""
A Site represents a geographic location within a network; typically a building or campus. The optional facility
diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py
index 1dbdca140..8675ee7ce 100644
--- a/netbox/dcim/signals.py
+++ b/netbox/dcim/signals.py
@@ -31,9 +31,10 @@ def rebuild_paths(obj):
with transaction.atomic():
for cp in cable_paths:
- invalidate_obj(cp.origin)
cp.delete()
- create_cablepath(cp.origin)
+ if cp.origin:
+ invalidate_obj(cp.origin)
+ create_cablepath(cp.origin)
#
diff --git a/netbox/extras/constants.py b/netbox/extras/constants.py
index 190e68c36..64cc82f63 100644
--- a/netbox/extras/constants.py
+++ b/netbox/extras/constants.py
@@ -7,5 +7,6 @@ EXTRAS_FEATURES = [
'custom_links',
'export_templates',
'job_results',
+ 'tags',
'webhooks'
]
diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py
index 92c0dc9a6..2a6ae088c 100644
--- a/netbox/extras/filtersets.py
+++ b/netbox/extras/filtersets.py
@@ -6,7 +6,7 @@ from django.db.models import Q
from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGroup
from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet
from tenancy.models import Tenant, TenantGroup
-from utilities.filters import ContentTypeFilter
+from utilities.filters import ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter
from virtualization.models import Cluster, ClusterGroup
from .choices import *
from .models import *
@@ -114,6 +114,12 @@ class TagFilterSet(ChangeLoggedModelFilterSet):
method='search',
label='Search',
)
+ content_type = MultiValueCharFilter(
+ method='_content_type'
+ )
+ content_type_id = MultiValueNumberFilter(
+ method='_content_type_id'
+ )
class Meta:
model = Tag
@@ -127,6 +133,32 @@ class TagFilterSet(ChangeLoggedModelFilterSet):
Q(slug__icontains=value)
)
+ def _content_type(self, queryset, name, values):
+ ct_filter = Q()
+
+ # Compile list of app_label & model pairings
+ for value in values:
+ try:
+ app_label, model = value.lower().split('.')
+ ct_filter |= Q(
+ app_label=app_label,
+ model=model
+ )
+ except ValueError:
+ pass
+
+ # Get ContentType instances
+ content_types = ContentType.objects.filter(ct_filter)
+
+ return queryset.filter(extras_taggeditem_items__content_type__in=content_types).distinct()
+
+ def _content_type_id(self, queryset, name, values):
+
+ # Get ContentType instances
+ content_types = ContentType.objects.filter(pk__in=values)
+
+ return queryset.filter(extras_taggeditem_items__content_type__in=content_types).distinct()
+
class ConfigContextFilterSet(ChangeLoggedModelFilterSet):
q = django_filters.CharFilter(
diff --git a/netbox/extras/forms.py b/netbox/extras/forms.py
index 977ad9d68..ab1c5aded 100644
--- a/netbox/extras/forms.py
+++ b/netbox/extras/forms.py
@@ -8,12 +8,13 @@ from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGrou
from tenancy.models import Tenant, TenantGroup
from utilities.forms import (
add_blank_choice, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorSelect,
- CommentField, CSVModelForm, DateTimePicker, DynamicModelMultipleChoiceField, JSONField, SlugField, StaticSelect2,
- BOOLEAN_WITH_BLANK_CHOICES,
+ CommentField, ContentTypeMultipleChoiceField, CSVModelForm, DateTimePicker, DynamicModelMultipleChoiceField,
+ JSONField, SlugField, StaticSelect2, BOOLEAN_WITH_BLANK_CHOICES,
)
from virtualization.models import Cluster, ClusterGroup
from .choices import *
from .models import ConfigContext, CustomField, ImageAttachment, JournalEntry, ObjectChange, Tag
+from .utils import FeatureQuery
#
@@ -180,6 +181,11 @@ class TagFilterForm(BootstrapMixin, forms.Form):
required=False,
label=_('Search')
)
+ content_type_id = ContentTypeMultipleChoiceField(
+ queryset=ContentType.objects.filter(FeatureQuery('tags').get_query()),
+ required=False,
+ label=_('Tagged object type')
+ )
class TagBulkEditForm(BootstrapMixin, BulkEditForm):
diff --git a/netbox/extras/plugins/views.py b/netbox/extras/plugins/views.py
index 7484b9632..65b33f0c4 100644
--- a/netbox/extras/plugins/views.py
+++ b/netbox/extras/plugins/views.py
@@ -42,7 +42,7 @@ class InstalledPluginsAPIView(APIView):
'author': plugin_app_config.author,
'author_email': plugin_app_config.author_email,
'description': plugin_app_config.description,
- 'verison': plugin_app_config.version
+ 'version': plugin_app_config.version
}
def get(self, request, format=None):
diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py
index eb08f5930..656c3efdc 100644
--- a/netbox/extras/tests/test_filtersets.py
+++ b/netbox/extras/tests/test_filtersets.py
@@ -5,6 +5,7 @@ from django.contrib.auth.models import User
from django.contrib.contenttypes.models import ContentType
from django.test import TestCase
+from circuits.models import Provider
from dcim.models import DeviceRole, DeviceType, Manufacturer, Platform, Rack, Region, Site, SiteGroup
from extras.choices import JournalEntryKindChoices, ObjectChangeActionChoices
from extras.filtersets import *
@@ -537,6 +538,13 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
)
Tag.objects.bulk_create(tags)
+ # Apply some tags so we can filter by content type
+ site = Site.objects.create(name='Site 1', slug='site-1')
+ provider = Provider.objects.create(name='Provider 1', slug='provider-1')
+
+ site.tags.set(tags[0])
+ provider.tags.set(tags[1])
+
def test_name(self):
params = {'name': ['Tag 1', 'Tag 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -549,6 +557,14 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'color': ['ff0000', '00ff00']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ def test_content_type(self):
+ params = {'content_type': ['dcim.site', 'circuits.provider']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ site_ct = ContentType.objects.get_for_model(Site).pk
+ provider_ct = ContentType.objects.get_for_model(Provider).pk
+ params = {'content_type_id': [site_ct, provider_ct]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
class ObjectChangeTestCase(TestCase, BaseFilterSetTests):
queryset = ObjectChange.objects.all()
diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py
index 931e2cc47..3270162a5 100644
--- a/netbox/ipam/api/serializers.py
+++ b/netbox/ipam/api/serializers.py
@@ -7,7 +7,7 @@ from rest_framework.validators import UniqueTogetherValidator
from dcim.api.nested_serializers import NestedDeviceSerializer, NestedSiteSerializer
from ipam.choices import *
-from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS
+from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS, VLANGROUP_SCOPE_TYPES
from ipam.models import Aggregate, IPAddress, Prefix, RIR, Role, RouteTarget, Service, VLAN, VLANGroup, VRF
from netbox.api import ChoiceField, ContentTypeField, SerializedPKRelatedField
from netbox.api.serializers import OrganizationalModelSerializer
@@ -116,8 +116,7 @@ class VLANGroupSerializer(OrganizationalModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='ipam-api:vlangroup-detail')
scope_type = ContentTypeField(
queryset=ContentType.objects.filter(
- app_label='dcim',
- model__in=['region', 'sitegroup', 'site', 'location', 'rack']
+ model__in=VLANGROUP_SCOPE_TYPES
),
required=False
)
diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py
index 5ab4994ea..d618c8eab 100644
--- a/netbox/ipam/filtersets.py
+++ b/netbox/ipam/filtersets.py
@@ -468,7 +468,7 @@ class IPAddressFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
class Meta:
model = IPAddress
- fields = ['id', 'dns_name']
+ fields = ['id', 'dns_name', 'description']
def search(self, queryset, name, value):
if not value.strip():
@@ -536,6 +536,10 @@ class IPAddressFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
class VLANGroupFilterSet(OrganizationalModelFilterSet):
+ q = django_filters.CharFilter(
+ method='search',
+ label='Search',
+ )
scope_type = ContentTypeFilter()
region = django_filters.NumberFilter(
method='filter_scope'
@@ -563,6 +567,15 @@ class VLANGroupFilterSet(OrganizationalModelFilterSet):
model = VLANGroup
fields = ['id', 'name', 'slug', 'description', 'scope_id']
+ def search(self, queryset, name, value):
+ if not value.strip():
+ return queryset
+ qs_filter = (
+ Q(name__icontains=value) |
+ Q(description__icontains=value)
+ )
+ return queryset.filter(qs_filter)
+
def filter_scope(self, queryset, name, value):
return queryset.filter(
scope_type=ContentType.objects.get(model=name),
diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py
index 6a3753859..1b38b63f4 100644
--- a/netbox/ipam/forms.py
+++ b/netbox/ipam/forms.py
@@ -1270,6 +1270,10 @@ class VLANGroupBulkEditForm(BootstrapMixin, CustomFieldBulkEditForm):
class VLANGroupFilterForm(BootstrapMixin, forms.Form):
+ q = forms.CharField(
+ required=False,
+ label=_('Search')
+ )
region = DynamicModelMultipleChoiceField(
queryset=Region.objects.all(),
required=False,
diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py
index 2490a0c5a..7df84c98b 100644
--- a/netbox/ipam/models/ip.py
+++ b/netbox/ipam/models/ip.py
@@ -77,7 +77,7 @@ class RIR(OrganizationalModel):
)
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Aggregate(PrimaryModel):
"""
An aggregate exists at the root level of the IP address space hierarchy in NetBox. Aggregates are used to organize
@@ -228,7 +228,7 @@ class Role(OrganizationalModel):
)
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Prefix(PrimaryModel):
"""
A Prefix represents an IPv4 or IPv6 network, including mask length. Prefixes can optionally be assigned to Sites and
@@ -477,7 +477,7 @@ class Prefix(PrimaryModel):
return int(float(child_count) / prefix_size * 100)
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class IPAddress(PrimaryModel):
"""
An IPAddress represents an individual IPv4 or IPv6 address and its mask. The mask length should match what is
diff --git a/netbox/ipam/models/services.py b/netbox/ipam/models/services.py
index 336f3a269..3fc9c8ec6 100644
--- a/netbox/ipam/models/services.py
+++ b/netbox/ipam/models/services.py
@@ -17,7 +17,7 @@ __all__ = (
)
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Service(PrimaryModel):
"""
A Service represents a layer-four service (e.g. HTTP or SSH) running on a Device or VirtualMachine. A Service may
diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py
index 0801504fe..aa1e6b68c 100644
--- a/netbox/ipam/models/vlans.py
+++ b/netbox/ipam/models/vlans.py
@@ -100,7 +100,7 @@ class VLANGroup(OrganizationalModel):
return None
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class VLAN(PrimaryModel):
"""
A VLAN is a distinct layer two forwarding domain identified by a 12-bit integer (1-4094). Each VLAN must be assigned
diff --git a/netbox/ipam/models/vrfs.py b/netbox/ipam/models/vrfs.py
index 9eb2c6ab6..c6c895697 100644
--- a/netbox/ipam/models/vrfs.py
+++ b/netbox/ipam/models/vrfs.py
@@ -13,7 +13,7 @@ __all__ = (
)
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class VRF(PrimaryModel):
"""
A virtual routing and forwarding (VRF) table represents a discrete layer three forwarding domain (e.g. a routing
@@ -92,7 +92,7 @@ class VRF(PrimaryModel):
return self.name
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class RouteTarget(PrimaryModel):
"""
A BGP extended community used to control the redistribution of routes among VRFs, as defined in RFC 4364.
diff --git a/netbox/ipam/tables.py b/netbox/ipam/tables.py
index ff28f2fc7..82e8751a9 100644
--- a/netbox/ipam/tables.py
+++ b/netbox/ipam/tables.py
@@ -430,7 +430,8 @@ class VLANGroupTable(BaseTable):
name = tables.Column(linkify=True)
scope_type = ContentTypeColumn()
scope = tables.Column(
- linkify=True
+ linkify=True,
+ orderable=False
)
vlan_count = LinkedCountColumn(
viewname='ipam:vlan_list',
diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py
index f43a44c62..282a19b66 100644
--- a/netbox/ipam/tests/test_filtersets.py
+++ b/netbox/ipam/tests/test_filtersets.py
@@ -571,12 +571,12 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
Tenant.objects.bulk_create(tenants)
ipaddresses = (
- IPAddress(address='10.0.0.1/24', tenant=None, vrf=None, assigned_object=None, status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-a'),
+ IPAddress(address='10.0.0.1/24', tenant=None, vrf=None, assigned_object=None, status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-a', description='foobar1'),
IPAddress(address='10.0.0.2/24', tenant=tenants[0], vrf=vrfs[0], assigned_object=interfaces[0], status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-b'),
IPAddress(address='10.0.0.3/24', tenant=tenants[1], vrf=vrfs[1], assigned_object=interfaces[1], status=IPAddressStatusChoices.STATUS_RESERVED, role=IPAddressRoleChoices.ROLE_VIP, dns_name='ipaddress-c'),
IPAddress(address='10.0.0.4/24', tenant=tenants[2], vrf=vrfs[2], assigned_object=interfaces[2], status=IPAddressStatusChoices.STATUS_DEPRECATED, role=IPAddressRoleChoices.ROLE_SECONDARY, dns_name='ipaddress-d'),
IPAddress(address='10.0.0.1/25', tenant=None, vrf=None, assigned_object=None, status=IPAddressStatusChoices.STATUS_ACTIVE),
- IPAddress(address='2001:db8::1/64', tenant=None, vrf=None, assigned_object=None, status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-a'),
+ IPAddress(address='2001:db8::1/64', tenant=None, vrf=None, assigned_object=None, status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-a', description='foobar2'),
IPAddress(address='2001:db8::2/64', tenant=tenants[0], vrf=vrfs[0], assigned_object=vminterfaces[0], status=IPAddressStatusChoices.STATUS_ACTIVE, dns_name='ipaddress-b'),
IPAddress(address='2001:db8::3/64', tenant=tenants[1], vrf=vrfs[1], assigned_object=vminterfaces[1], status=IPAddressStatusChoices.STATUS_RESERVED, role=IPAddressRoleChoices.ROLE_VIP, dns_name='ipaddress-c'),
IPAddress(address='2001:db8::4/64', tenant=tenants[2], vrf=vrfs[2], assigned_object=vminterfaces[2], status=IPAddressStatusChoices.STATUS_DEPRECATED, role=IPAddressRoleChoices.ROLE_SECONDARY, dns_name='ipaddress-d'),
@@ -592,6 +592,10 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'dns_name': ['ipaddress-a', 'ipaddress-b']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+ def test_description(self):
+ params = {'description': ['foobar1', 'foobar2']}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
def test_parent(self):
params = {'parent': '10.0.0.0/24'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5)
diff --git a/netbox/netbox/middleware.py b/netbox/netbox/middleware.py
index 1395cbd1f..d3b3dae40 100644
--- a/netbox/netbox/middleware.py
+++ b/netbox/netbox/middleware.py
@@ -20,17 +20,20 @@ class LoginRequiredMiddleware(object):
self.get_response = get_response
def __call__(self, request):
+ # Redirect unauthenticated requests (except those exempted) to the login page if LOGIN_REQUIRED is true
if settings.LOGIN_REQUIRED and not request.user.is_authenticated:
- # Redirect unauthenticated requests to the login page. API requests are exempt from redirection as the API
- # performs its own authentication. Also metrics can be read without login.
- api_path = reverse('api-root')
- if not request.path_info.startswith((api_path, '/metrics')) and request.path_info != settings.LOGIN_URL:
- return HttpResponseRedirect(
- '{}?next={}'.format(
- settings.LOGIN_URL,
- parse.quote(request.get_full_path_info())
- )
- )
+ # Determine exempt paths
+ exempt_paths = [
+ reverse('api-root')
+ ]
+ if settings.METRICS_ENABLED:
+ exempt_paths.append(reverse('prometheus-django-metrics'))
+
+ # Redirect unauthenticated requests
+ if not request.path_info.startswith(tuple(exempt_paths)) and request.path_info != settings.LOGIN_URL:
+ login_url = f'{settings.LOGIN_URL}?next={parse.quote(request.get_full_path_info())}'
+ return HttpResponseRedirect(login_url)
+
return self.get_response(request)
diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py
index 35f90ff55..4f694c388 100644
--- a/netbox/netbox/settings.py
+++ b/netbox/netbox/settings.py
@@ -16,7 +16,7 @@ from django.core.validators import URLValidator
# Environment setup
#
-VERSION = '2.11.3'
+VERSION = '2.11.4'
# Hostname
HOSTNAME = platform.node()
diff --git a/netbox/secrets/models.py b/netbox/secrets/models.py
index 04a7ed58c..77c1e34e6 100644
--- a/netbox/secrets/models.py
+++ b/netbox/secrets/models.py
@@ -273,7 +273,7 @@ class SecretRole(OrganizationalModel):
)
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Secret(PrimaryModel):
"""
A Secret stores an AES256-encrypted copy of sensitive data, such as passwords or secret keys. An irreversible
diff --git a/netbox/secrets/views.py b/netbox/secrets/views.py
index 091e16224..88f647225 100644
--- a/netbox/secrets/views.py
+++ b/netbox/secrets/views.py
@@ -86,6 +86,18 @@ class SecretRoleBulkDeleteView(generic.BulkDeleteView):
# Secrets
#
+def inject_deprecation_warning(request):
+ """
+ Inject a warning message notifying the user of the pending removal of secrets functionality.
+ """
+ messages.warning(
+ request,
+ mark_safe('
The secrets functionality will be moved to a plugin in NetBox v2.12. '
+ 'Please see
issue #5278 for '
+ 'more information.')
+ )
+
+
class SecretListView(generic.ObjectListView):
queryset = Secret.objects.all()
filterset = filtersets.SecretFilterSet
@@ -93,10 +105,18 @@ class SecretListView(generic.ObjectListView):
table = tables.SecretTable
action_buttons = ('import', 'export')
+ def get(self, request):
+ inject_deprecation_warning(request)
+ return super().get(request)
+
class SecretView(generic.ObjectView):
queryset = Secret.objects.all()
+ def get(self, request, *args, **kwargs):
+ inject_deprecation_warning(request)
+ return super().get(request, *args, **kwargs)
+
class SecretEditView(generic.ObjectEditView):
queryset = Secret.objects.all()
diff --git a/netbox/tenancy/models.py b/netbox/tenancy/models.py
index c9f55ec84..63f960b0e 100644
--- a/netbox/tenancy/models.py
+++ b/netbox/tenancy/models.py
@@ -57,7 +57,7 @@ class TenantGroup(NestedGroupModel):
)
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Tenant(PrimaryModel):
"""
A Tenant represents an organization served by the NetBox owner. This is typically a customer or an internal
diff --git a/netbox/users/admin.py b/netbox/users/admin.py
index 5eff6ec22..8926203df 100644
--- a/netbox/users/admin.py
+++ b/netbox/users/admin.py
@@ -89,6 +89,7 @@ class UserAdmin(UserAdmin_):
('Important dates', {'fields': ('last_login', 'date_joined')}),
)
filter_horizontal = ('groups',)
+ list_filter = ('is_active', 'is_staff', 'is_superuser', 'groups__name')
def get_inlines(self, request, obj):
if obj is not None:
diff --git a/netbox/utilities/choices.py b/netbox/utilities/choices.py
index 3d1002105..2d8e27acf 100644
--- a/netbox/utilities/choices.py
+++ b/netbox/utilities/choices.py
@@ -130,22 +130,24 @@ class ColorChoices(ChoiceSet):
class ButtonColorChoices(ChoiceSet):
"""
- Map standard button color choices to Bootstrap color classes
+ Map standard button color choices to Bootstrap 3 button classes
"""
DEFAULT = 'default'
BLUE = 'primary'
- GREY = 'secondary'
+ CYAN = 'info'
GREEN = 'success'
RED = 'danger'
YELLOW = 'warning'
+ GREY = 'secondary'
BLACK = 'dark'
CHOICES = (
(DEFAULT, 'Default'),
(BLUE, 'Blue'),
- (GREY, 'Grey'),
+ (CYAN, 'Cyan'),
(GREEN, 'Green'),
(RED, 'Red'),
(YELLOW, 'Yellow'),
+ (GREY, 'Grey'),
(BLACK, 'Black')
)
diff --git a/netbox/utilities/paginator.py b/netbox/utilities/paginator.py
index cdad1f230..3b9e1cb37 100644
--- a/netbox/utilities/paginator.py
+++ b/netbox/utilities/paginator.py
@@ -4,7 +4,9 @@ from django.core.paginator import Paginator, Page
class EnhancedPaginator(Paginator):
- def __init__(self, object_list, per_page, **kwargs):
+ def __init__(self, object_list, per_page, orphans=None, **kwargs):
+
+ # Determine the page size
try:
per_page = int(per_page)
if per_page < 1:
@@ -12,7 +14,13 @@ class EnhancedPaginator(Paginator):
except ValueError:
per_page = settings.PAGINATE_COUNT
- super().__init__(object_list, per_page, **kwargs)
+ # Set orphans count based on page size
+ if orphans is None and per_page <= 50:
+ orphans = 5
+ elif orphans is None:
+ orphans = 10
+
+ super().__init__(object_list, per_page, orphans=orphans, **kwargs)
def _get_page(self, *args, **kwargs):
return EnhancedPage(*args, **kwargs)
diff --git a/netbox/utilities/tables.py b/netbox/utilities/tables.py
index 78e1f6d53..86660c2e5 100644
--- a/netbox/utilities/tables.py
+++ b/netbox/utilities/tables.py
@@ -5,7 +5,6 @@ from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import FieldDoesNotExist
from django.db.models.fields.related import RelatedField
from django.urls import reverse
-from django.utils.html import strip_tags
from django.utils.safestring import mark_safe
from django_tables2 import RequestConfig
from django_tables2.data import TableQuerysetData
@@ -15,19 +14,6 @@ from extras.models import CustomField
from .paginator import EnhancedPaginator, get_paginate_count
-def stripped_value(self, **kwargs):
- """
- Replaces TemplateColumn's value() method to both strip HTML tags and remove any leading/trailing whitespace.
- """
- html = super(tables.TemplateColumn, self).value(**kwargs)
- return strip_tags(html).strip() if isinstance(html, str) else html
-
-
-# TODO: We're monkey-patching TemplateColumn here to strip leading/trailing whitespace. This will no longer
-# be necessary under django-tables2 v2.3.5+. (See #5926)
-tables.TemplateColumn.value = stripped_value
-
-
class BaseTable(tables.Table):
"""
Default table for object lists
diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py
index 5aa43a869..0b679bac0 100644
--- a/netbox/virtualization/models.py
+++ b/netbox/virtualization/models.py
@@ -116,7 +116,7 @@ class ClusterGroup(OrganizationalModel):
# Clusters
#
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Cluster(PrimaryModel):
"""
A cluster of VirtualMachines. Each Cluster may optionally be associated with one or more Devices.
@@ -199,7 +199,7 @@ class Cluster(PrimaryModel):
# Virtual machines
#
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class VirtualMachine(PrimaryModel, ConfigContextModel):
"""
A virtual machine which runs inside a Cluster.
@@ -380,7 +380,7 @@ class VirtualMachine(PrimaryModel, ConfigContextModel):
# Interfaces
#
-@extras_features('custom_fields', 'custom_links', 'export_templates', 'webhooks')
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class VMInterface(PrimaryModel, BaseInterface):
virtual_machine = models.ForeignKey(
to='virtualization.VirtualMachine',
diff --git a/requirements.txt b/requirements.txt
index f7ae8178d..904563d05 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,4 +1,4 @@
-Django==3.2.2
+Django==3.2.3
django-cacheops==6.0
django-cors-headers==3.7.0
django-debug-toolbar==3.2.1
@@ -7,13 +7,13 @@ django-mptt==0.12.0
django-pglocks==1.0.4
django-prometheus==2.1.0
django-rq==2.4.1
-django-tables2==2.3.4
+django-tables2==2.4.0
django-taggit==1.4.0
django-timezone-field==4.1.2
djangorestframework==3.12.4
drf-yasg[validation]==1.20.0
gunicorn==20.1.0
-Jinja2==2.11.3
+Jinja2==3.0.1
Markdown==3.3.4
netaddr==0.8.0
Pillow==8.2.0