From 419f86a4a5efbfa18c5cf8cb722d8c4bc2c29fbc Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 16 Dec 2021 09:36:15 -0500 Subject: [PATCH 1/5] #8054: Support configurable status choices --- docs/configuration/optional-settings.md | 35 +++++++++++++++++++++++++ netbox/circuits/choices.py | 5 ++-- netbox/dcim/choices.py | 20 ++++++++------ netbox/ipam/choices.py | 20 ++++++++------ netbox/netbox/settings.py | 1 + netbox/utilities/choices.py | 14 ++++++++++ netbox/virtualization/choices.py | 5 ++-- 7 files changed, 80 insertions(+), 20 deletions(-) diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index d8d79b6ec..97c1b6201 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -140,6 +140,41 @@ EXEMPT_VIEW_PERMISSIONS = ['*'] --- +## FIELD_CHOICES + +Default: Empty dictionary + +Some static choice fields on models can be configured with custom values. This is done by defining `FIELD_CHOICES` as a dictionary mapping model fields to their choices list. Each choice in the list must have a database value and a human-friendly label, and may optionally specify a color. + +For example, to specify a custom set of choices for the site status field: + +```python +FIELD_CHOICES = { + 'dcim.Site.status': ( + ('foo', 'Foo'), + ('bar', 'Bar'), + ('baz', 'Baz'), + ) +} +``` + +These will be appended to the stock choices for the field. + +The following model field support configurable choices: + +* `circuits.Circuit.status` +* `dcim.Device.status` +* `dcim.PowerFeed.status` +* `dcim.Rack.status` +* `dcim.Site.status` +* `ipam.IPAddress.status` +* `ipam.IPRange.status` +* `ipam.Prefix.status` +* `ipam.VLAN.status` +* `virtualization.VirtualMachine.status` + +--- + ## HTTP_PROXIES Default: None diff --git a/netbox/circuits/choices.py b/netbox/circuits/choices.py index 0efa431fa..e3177adb4 100644 --- a/netbox/circuits/choices.py +++ b/netbox/circuits/choices.py @@ -6,6 +6,7 @@ from utilities.choices import ChoiceSet # class CircuitStatusChoices(ChoiceSet): + key = 'circuits.Circuit.status' STATUS_DEPROVISIONING = 'deprovisioning' STATUS_ACTIVE = 'active' @@ -14,14 +15,14 @@ class CircuitStatusChoices(ChoiceSet): STATUS_OFFLINE = 'offline' STATUS_DECOMMISSIONED = 'decommissioned' - CHOICES = ( + CHOICES = [ (STATUS_PLANNED, 'Planned'), (STATUS_PROVISIONING, 'Provisioning'), (STATUS_ACTIVE, 'Active'), (STATUS_OFFLINE, 'Offline'), (STATUS_DEPROVISIONING, 'Deprovisioning'), (STATUS_DECOMMISSIONED, 'Decommissioned'), - ) + ] CSS_CLASSES = { STATUS_DEPROVISIONING: 'warning', diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index fcb37211f..208a06c5d 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -6,6 +6,7 @@ from utilities.choices import ChoiceSet # class SiteStatusChoices(ChoiceSet): + key = 'dcim.Site.status' STATUS_PLANNED = 'planned' STATUS_STAGING = 'staging' @@ -13,13 +14,13 @@ class SiteStatusChoices(ChoiceSet): STATUS_DECOMMISSIONING = 'decommissioning' STATUS_RETIRED = 'retired' - CHOICES = ( + CHOICES = [ (STATUS_PLANNED, 'Planned'), (STATUS_STAGING, 'Staging'), (STATUS_ACTIVE, 'Active'), (STATUS_DECOMMISSIONING, 'Decommissioning'), (STATUS_RETIRED, 'Retired'), - ) + ] CSS_CLASSES = { STATUS_PLANNED: 'info', @@ -67,6 +68,7 @@ class RackWidthChoices(ChoiceSet): class RackStatusChoices(ChoiceSet): + key = 'dcim.Rack.status' STATUS_RESERVED = 'reserved' STATUS_AVAILABLE = 'available' @@ -74,13 +76,13 @@ class RackStatusChoices(ChoiceSet): STATUS_ACTIVE = 'active' STATUS_DEPRECATED = 'deprecated' - CHOICES = ( + CHOICES = [ (STATUS_RESERVED, 'Reserved'), (STATUS_AVAILABLE, 'Available'), (STATUS_PLANNED, 'Planned'), (STATUS_ACTIVE, 'Active'), (STATUS_DEPRECATED, 'Deprecated'), - ) + ] CSS_CLASSES = { STATUS_RESERVED: 'warning', @@ -144,6 +146,7 @@ class DeviceFaceChoices(ChoiceSet): class DeviceStatusChoices(ChoiceSet): + key = 'dcim.Device.status' STATUS_OFFLINE = 'offline' STATUS_ACTIVE = 'active' @@ -153,7 +156,7 @@ class DeviceStatusChoices(ChoiceSet): STATUS_INVENTORY = 'inventory' STATUS_DECOMMISSIONING = 'decommissioning' - CHOICES = ( + CHOICES = [ (STATUS_OFFLINE, 'Offline'), (STATUS_ACTIVE, 'Active'), (STATUS_PLANNED, 'Planned'), @@ -161,7 +164,7 @@ class DeviceStatusChoices(ChoiceSet): (STATUS_FAILED, 'Failed'), (STATUS_INVENTORY, 'Inventory'), (STATUS_DECOMMISSIONING, 'Decommissioning'), - ) + ] CSS_CLASSES = { STATUS_OFFLINE: 'warning', @@ -1183,18 +1186,19 @@ class CableLengthUnitChoices(ChoiceSet): # class PowerFeedStatusChoices(ChoiceSet): + key = 'dcim.PowerFeed.status' STATUS_OFFLINE = 'offline' STATUS_ACTIVE = 'active' STATUS_PLANNED = 'planned' STATUS_FAILED = 'failed' - CHOICES = ( + CHOICES = [ (STATUS_OFFLINE, 'Offline'), (STATUS_ACTIVE, 'Active'), (STATUS_PLANNED, 'Planned'), (STATUS_FAILED, 'Failed'), - ) + ] CSS_CLASSES = { STATUS_OFFLINE: 'warning', diff --git a/netbox/ipam/choices.py b/netbox/ipam/choices.py index 638ef62f6..c414fc115 100644 --- a/netbox/ipam/choices.py +++ b/netbox/ipam/choices.py @@ -17,18 +17,19 @@ class IPAddressFamilyChoices(ChoiceSet): # class PrefixStatusChoices(ChoiceSet): + key = 'ipam.Prefix.status' STATUS_CONTAINER = 'container' STATUS_ACTIVE = 'active' STATUS_RESERVED = 'reserved' STATUS_DEPRECATED = 'deprecated' - CHOICES = ( + CHOICES = [ (STATUS_CONTAINER, 'Container'), (STATUS_ACTIVE, 'Active'), (STATUS_RESERVED, 'Reserved'), (STATUS_DEPRECATED, 'Deprecated'), - ) + ] CSS_CLASSES = { STATUS_CONTAINER: 'secondary', @@ -43,16 +44,17 @@ class PrefixStatusChoices(ChoiceSet): # class IPRangeStatusChoices(ChoiceSet): + key = 'ipam.IPRange.status' STATUS_ACTIVE = 'active' STATUS_RESERVED = 'reserved' STATUS_DEPRECATED = 'deprecated' - CHOICES = ( + CHOICES = [ (STATUS_ACTIVE, 'Active'), (STATUS_RESERVED, 'Reserved'), (STATUS_DEPRECATED, 'Deprecated'), - ) + ] CSS_CLASSES = { STATUS_ACTIVE: 'primary', @@ -66,6 +68,7 @@ class IPRangeStatusChoices(ChoiceSet): # class IPAddressStatusChoices(ChoiceSet): + key = 'ipam.IPAddress.status' STATUS_ACTIVE = 'active' STATUS_RESERVED = 'reserved' @@ -73,13 +76,13 @@ class IPAddressStatusChoices(ChoiceSet): STATUS_DHCP = 'dhcp' STATUS_SLAAC = 'slaac' - CHOICES = ( + CHOICES = [ (STATUS_ACTIVE, 'Active'), (STATUS_RESERVED, 'Reserved'), (STATUS_DEPRECATED, 'Deprecated'), (STATUS_DHCP, 'DHCP'), (STATUS_SLAAC, 'SLAAC'), - ) + ] CSS_CLASSES = { STATUS_ACTIVE: 'primary', @@ -161,16 +164,17 @@ class FHRPGroupAuthTypeChoices(ChoiceSet): # class VLANStatusChoices(ChoiceSet): + key = 'ipam.VLAN.status' STATUS_ACTIVE = 'active' STATUS_RESERVED = 'reserved' STATUS_DEPRECATED = 'deprecated' - CHOICES = ( + CHOICES = [ (STATUS_ACTIVE, 'Active'), (STATUS_RESERVED, 'Reserved'), (STATUS_DEPRECATED, 'Deprecated'), - ) + ] CSS_CLASSES = { STATUS_ACTIVE: 'primary', diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 9bc0dbc0c..9d956250b 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -86,6 +86,7 @@ DEVELOPER = getattr(configuration, 'DEVELOPER', False) DOCS_ROOT = getattr(configuration, 'DOCS_ROOT', os.path.join(os.path.dirname(BASE_DIR), 'docs')) EMAIL = getattr(configuration, 'EMAIL', {}) EXEMPT_VIEW_PERMISSIONS = getattr(configuration, 'EXEMPT_VIEW_PERMISSIONS', []) +FIELD_CHOICES = getattr(configuration, 'FIELD_CHOICES', {}) HTTP_PROXIES = getattr(configuration, 'HTTP_PROXIES', None) INTERNAL_IPS = getattr(configuration, 'INTERNAL_IPS', ('127.0.0.1', '::1')) LOGGING = getattr(configuration, 'LOGGING', {}) diff --git a/netbox/utilities/choices.py b/netbox/utilities/choices.py index b831b3490..46d74490a 100644 --- a/netbox/utilities/choices.py +++ b/netbox/utilities/choices.py @@ -1,7 +1,21 @@ +from django.conf import settings + + class ChoiceSetMeta(type): """ Metaclass for ChoiceSet """ + def __new__(mcs, name, bases, attrs): + + # Extend static choices with any configured choices + if 'key' in attrs: + try: + attrs['CHOICES'].extend(settings.FIELD_CHOICES[attrs['key']]) + except KeyError: + pass + + return super().__new__(mcs, name, bases, attrs) + def __call__(cls, *args, **kwargs): # Django will check if a 'choices' value is callable, and if so assume that it returns an iterable return getattr(cls, 'CHOICES', ()) diff --git a/netbox/virtualization/choices.py b/netbox/virtualization/choices.py index 9c4eb6cd5..c121d052e 100644 --- a/netbox/virtualization/choices.py +++ b/netbox/virtualization/choices.py @@ -6,6 +6,7 @@ from utilities.choices import ChoiceSet # class VirtualMachineStatusChoices(ChoiceSet): + key = 'virtualization.VirtualMachine.status' STATUS_OFFLINE = 'offline' STATUS_ACTIVE = 'active' @@ -14,14 +15,14 @@ class VirtualMachineStatusChoices(ChoiceSet): STATUS_FAILED = 'failed' STATUS_DECOMMISSIONING = 'decommissioning' - CHOICES = ( + CHOICES = [ (STATUS_OFFLINE, 'Offline'), (STATUS_ACTIVE, 'Active'), (STATUS_PLANNED, 'Planned'), (STATUS_STAGED, 'Staged'), (STATUS_FAILED, 'Failed'), (STATUS_DECOMMISSIONING, 'Decommissioning'), - ) + ] CSS_CLASSES = { STATUS_OFFLINE: 'warning', From 0d3b50a5e50ad218bfac1ede0c8559682cb7b384 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 16 Dec 2021 10:03:23 -0500 Subject: [PATCH 2/5] Support CSS class definition directly in CHOICES iterable --- netbox/circuits/choices.py | 21 ++---- netbox/circuits/models/circuits.py | 2 +- netbox/dcim/choices.py | 96 +++++++----------------- netbox/dcim/models/cables.py | 2 +- netbox/dcim/models/devices.py | 2 +- netbox/dcim/models/power.py | 4 +- netbox/dcim/models/racks.py | 2 +- netbox/dcim/models/sites.py | 2 +- netbox/extras/choices.py | 45 +++-------- netbox/extras/models/change_logging.py | 2 +- netbox/extras/models/models.py | 2 +- netbox/extras/templatetags/log_levels.py | 2 +- netbox/ipam/choices.py | 82 ++++++-------------- netbox/ipam/models/ip.py | 8 +- netbox/ipam/models/vlans.py | 2 +- netbox/utilities/choices.py | 25 +++++- netbox/virtualization/choices.py | 21 ++---- netbox/virtualization/models.py | 2 +- netbox/wireless/models.py | 2 +- 19 files changed, 110 insertions(+), 214 deletions(-) diff --git a/netbox/circuits/choices.py b/netbox/circuits/choices.py index e3177adb4..007b45298 100644 --- a/netbox/circuits/choices.py +++ b/netbox/circuits/choices.py @@ -16,23 +16,14 @@ class CircuitStatusChoices(ChoiceSet): STATUS_DECOMMISSIONED = 'decommissioned' CHOICES = [ - (STATUS_PLANNED, 'Planned'), - (STATUS_PROVISIONING, 'Provisioning'), - (STATUS_ACTIVE, 'Active'), - (STATUS_OFFLINE, 'Offline'), - (STATUS_DEPROVISIONING, 'Deprovisioning'), - (STATUS_DECOMMISSIONED, 'Decommissioned'), + (STATUS_PLANNED, 'Planned', 'info'), + (STATUS_PROVISIONING, 'Provisioning', 'primary'), + (STATUS_ACTIVE, 'Active', 'success'), + (STATUS_OFFLINE, 'Offline', 'danger'), + (STATUS_DEPROVISIONING, 'Deprovisioning', 'warning'), + (STATUS_DECOMMISSIONED, 'Decommissioned', 'secondary'), ] - CSS_CLASSES = { - STATUS_DEPROVISIONING: 'warning', - STATUS_ACTIVE: 'success', - STATUS_PLANNED: 'info', - STATUS_PROVISIONING: 'primary', - STATUS_OFFLINE: 'danger', - STATUS_DECOMMISSIONED: 'secondary', - } - # # CircuitTerminations diff --git a/netbox/circuits/models/circuits.py b/netbox/circuits/models/circuits.py index 602c0f403..013aef557 100644 --- a/netbox/circuits/models/circuits.py +++ b/netbox/circuits/models/circuits.py @@ -135,7 +135,7 @@ class Circuit(PrimaryModel): return reverse('circuits:circuit', args=[self.pk]) def get_status_class(self): - return CircuitStatusChoices.CSS_CLASSES.get(self.status) + return CircuitStatusChoices.colors.get(self.status, 'secondary') @extras_features('webhooks') diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 208a06c5d..bcc926580 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -15,21 +15,13 @@ class SiteStatusChoices(ChoiceSet): STATUS_RETIRED = 'retired' CHOICES = [ - (STATUS_PLANNED, 'Planned'), - (STATUS_STAGING, 'Staging'), - (STATUS_ACTIVE, 'Active'), - (STATUS_DECOMMISSIONING, 'Decommissioning'), - (STATUS_RETIRED, 'Retired'), + (STATUS_PLANNED, 'Planned', 'info'), + (STATUS_STAGING, 'Staging', 'primary'), + (STATUS_ACTIVE, 'Active', 'primary'), + (STATUS_DECOMMISSIONING, 'Decommissioning', 'warning'), + (STATUS_RETIRED, 'Retired', 'danger'), ] - CSS_CLASSES = { - STATUS_PLANNED: 'info', - STATUS_STAGING: 'primary', - STATUS_ACTIVE: 'success', - STATUS_DECOMMISSIONING: 'warning', - STATUS_RETIRED: 'danger', - } - # # Racks @@ -77,21 +69,13 @@ class RackStatusChoices(ChoiceSet): STATUS_DEPRECATED = 'deprecated' CHOICES = [ - (STATUS_RESERVED, 'Reserved'), - (STATUS_AVAILABLE, 'Available'), - (STATUS_PLANNED, 'Planned'), - (STATUS_ACTIVE, 'Active'), - (STATUS_DEPRECATED, 'Deprecated'), + (STATUS_RESERVED, 'Reserved', 'warning'), + (STATUS_AVAILABLE, 'Available', 'success'), + (STATUS_PLANNED, 'Planned', 'info'), + (STATUS_ACTIVE, 'Active', 'primary'), + (STATUS_DEPRECATED, 'Deprecated', 'danger'), ] - CSS_CLASSES = { - STATUS_RESERVED: 'warning', - STATUS_AVAILABLE: 'success', - STATUS_PLANNED: 'info', - STATUS_ACTIVE: 'primary', - STATUS_DEPRECATED: 'danger', - } - class RackDimensionUnitChoices(ChoiceSet): @@ -157,25 +141,15 @@ class DeviceStatusChoices(ChoiceSet): STATUS_DECOMMISSIONING = 'decommissioning' CHOICES = [ - (STATUS_OFFLINE, 'Offline'), - (STATUS_ACTIVE, 'Active'), - (STATUS_PLANNED, 'Planned'), - (STATUS_STAGED, 'Staged'), - (STATUS_FAILED, 'Failed'), - (STATUS_INVENTORY, 'Inventory'), - (STATUS_DECOMMISSIONING, 'Decommissioning'), + (STATUS_OFFLINE, 'Offline', 'warning'), + (STATUS_ACTIVE, 'Active', 'success'), + (STATUS_PLANNED, 'Planned', 'info'), + (STATUS_STAGED, 'Staged', 'primary'), + (STATUS_FAILED, 'Failed', 'danger'), + (STATUS_INVENTORY, 'Inventory', 'secondary'), + (STATUS_DECOMMISSIONING, 'Decommissioning', 'warning'), ] - CSS_CLASSES = { - STATUS_OFFLINE: 'warning', - STATUS_ACTIVE: 'success', - STATUS_PLANNED: 'info', - STATUS_STAGED: 'primary', - STATUS_FAILED: 'danger', - STATUS_INVENTORY: 'secondary', - STATUS_DECOMMISSIONING: 'warning', - } - class DeviceAirflowChoices(ChoiceSet): @@ -1147,17 +1121,11 @@ class LinkStatusChoices(ChoiceSet): STATUS_DECOMMISSIONING = 'decommissioning' CHOICES = ( - (STATUS_CONNECTED, 'Connected'), - (STATUS_PLANNED, 'Planned'), - (STATUS_DECOMMISSIONING, 'Decommissioning'), + (STATUS_CONNECTED, 'Connected', 'success'), + (STATUS_PLANNED, 'Planned', 'info'), + (STATUS_DECOMMISSIONING, 'Decommissioning', 'warning'), ) - CSS_CLASSES = { - STATUS_CONNECTED: 'success', - STATUS_PLANNED: 'info', - STATUS_DECOMMISSIONING: 'warning', - } - class CableLengthUnitChoices(ChoiceSet): @@ -1194,19 +1162,12 @@ class PowerFeedStatusChoices(ChoiceSet): STATUS_FAILED = 'failed' CHOICES = [ - (STATUS_OFFLINE, 'Offline'), - (STATUS_ACTIVE, 'Active'), - (STATUS_PLANNED, 'Planned'), - (STATUS_FAILED, 'Failed'), + (STATUS_OFFLINE, 'Offline', 'warning'), + (STATUS_ACTIVE, 'Active', 'success'), + (STATUS_PLANNED, 'Planned', 'info'), + (STATUS_FAILED, 'Failed', 'danger'), ] - CSS_CLASSES = { - STATUS_OFFLINE: 'warning', - STATUS_ACTIVE: 'success', - STATUS_PLANNED: 'info', - STATUS_FAILED: 'danger', - } - class PowerFeedTypeChoices(ChoiceSet): @@ -1214,15 +1175,10 @@ class PowerFeedTypeChoices(ChoiceSet): TYPE_REDUNDANT = 'redundant' CHOICES = ( - (TYPE_PRIMARY, 'Primary'), - (TYPE_REDUNDANT, 'Redundant'), + (TYPE_PRIMARY, 'Primary', 'success'), + (TYPE_REDUNDANT, 'Redundant', 'info'), ) - CSS_CLASSES = { - TYPE_PRIMARY: 'success', - TYPE_REDUNDANT: 'info', - } - class PowerFeedSupplyChoices(ChoiceSet): diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index 333972b21..12fe91036 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -289,7 +289,7 @@ class Cable(PrimaryModel): self._pk = self.pk def get_status_class(self): - return LinkStatusChoices.CSS_CLASSES.get(self.status) + return LinkStatusChoices.colors.get(self.status) def get_compatible_types(self): """ diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index a2ae20319..24eeb7ac3 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -862,7 +862,7 @@ class Device(PrimaryModel, ConfigContextModel): return Device.objects.filter(parent_bay__device=self.pk) def get_status_class(self): - return DeviceStatusChoices.CSS_CLASSES.get(self.status) + return DeviceStatusChoices.colors.get(self.status, 'secondary') # diff --git a/netbox/dcim/models/power.py b/netbox/dcim/models/power.py index b5d8d4c83..e3146c167 100644 --- a/netbox/dcim/models/power.py +++ b/netbox/dcim/models/power.py @@ -174,7 +174,7 @@ class PowerFeed(PrimaryModel, PathEndpoint, LinkTermination): return self.power_panel def get_type_class(self): - return PowerFeedTypeChoices.CSS_CLASSES.get(self.type) + return PowerFeedTypeChoices.colors.get(self.type) def get_status_class(self): - return PowerFeedStatusChoices.CSS_CLASSES.get(self.status) + return PowerFeedStatusChoices.colors.get(self.status, 'secondary') diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index 082ecfe57..c324d4cba 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -251,7 +251,7 @@ class Rack(PrimaryModel): return reversed(range(1, self.u_height + 1)) def get_status_class(self): - return RackStatusChoices.CSS_CLASSES.get(self.status) + return RackStatusChoices.colors.get(self.status, 'secondary') def get_rack_units(self, user=None, face=DeviceFaceChoices.FACE_FRONT, exclude=None, expand_devices=True): """ diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py index 0be7e4617..a71206224 100644 --- a/netbox/dcim/models/sites.py +++ b/netbox/dcim/models/sites.py @@ -315,7 +315,7 @@ class Site(PrimaryModel): return reverse('dcim:site', args=[self.pk]) def get_status_class(self): - return SiteStatusChoices.CSS_CLASSES.get(self.status) + return SiteStatusChoices.colors.get(self.status, 'secondary') # diff --git a/netbox/extras/choices.py b/netbox/extras/choices.py index 7503b4110..ff117c4e5 100644 --- a/netbox/extras/choices.py +++ b/netbox/extras/choices.py @@ -91,17 +91,11 @@ class ObjectChangeActionChoices(ChoiceSet): ACTION_DELETE = 'delete' CHOICES = ( - (ACTION_CREATE, 'Created'), - (ACTION_UPDATE, 'Updated'), - (ACTION_DELETE, 'Deleted'), + (ACTION_CREATE, 'Created', 'success'), + (ACTION_UPDATE, 'Updated', 'primary'), + (ACTION_DELETE, 'Deleted', 'danger'), ) - CSS_CLASSES = { - ACTION_CREATE: 'success', - ACTION_UPDATE: 'primary', - ACTION_DELETE: 'danger', - } - # # Jounral entries @@ -115,19 +109,12 @@ class JournalEntryKindChoices(ChoiceSet): KIND_DANGER = 'danger' CHOICES = ( - (KIND_INFO, 'Info'), - (KIND_SUCCESS, 'Success'), - (KIND_WARNING, 'Warning'), - (KIND_DANGER, 'Danger'), + (KIND_INFO, 'Info', 'info'), + (KIND_SUCCESS, 'Success', 'success'), + (KIND_WARNING, 'Warning', 'warning'), + (KIND_DANGER, 'Danger', 'danger'), ) - CSS_CLASSES = { - KIND_INFO: 'info', - KIND_SUCCESS: 'success', - KIND_WARNING: 'warning', - KIND_DANGER: 'danger', - } - # # Log Levels for Reports and Scripts @@ -142,21 +129,13 @@ class LogLevelChoices(ChoiceSet): LOG_FAILURE = 'failure' CHOICES = ( - (LOG_DEFAULT, 'Default'), - (LOG_SUCCESS, 'Success'), - (LOG_INFO, 'Info'), - (LOG_WARNING, 'Warning'), - (LOG_FAILURE, 'Failure'), + (LOG_DEFAULT, 'Default', 'secondary'), + (LOG_SUCCESS, 'Success', 'success'), + (LOG_INFO, 'Info', 'info'), + (LOG_WARNING, 'Warning', 'warning'), + (LOG_FAILURE, 'Failure', 'danger'), ) - CSS_CLASSES = { - LOG_DEFAULT: 'secondary', - LOG_SUCCESS: 'success', - LOG_INFO: 'info', - LOG_WARNING: 'warning', - LOG_FAILURE: 'danger', - } - # # Job results diff --git a/netbox/extras/models/change_logging.py b/netbox/extras/models/change_logging.py index 15bd3cbd8..8dfeb2f18 100644 --- a/netbox/extras/models/change_logging.py +++ b/netbox/extras/models/change_logging.py @@ -105,4 +105,4 @@ class ObjectChange(BigIDModel): return reverse('extras:objectchange', args=[self.pk]) def get_action_class(self): - return ObjectChangeActionChoices.CSS_CLASSES.get(self.action) + return ObjectChangeActionChoices.colors.get(self.action) diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 47da21e19..c20117b91 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -440,7 +440,7 @@ class JournalEntry(ChangeLoggedModel): return reverse('extras:journalentry', args=[self.pk]) def get_kind_class(self): - return JournalEntryKindChoices.CSS_CLASSES.get(self.kind) + return JournalEntryKindChoices.colors.get(self.kind) class JobResult(BigIDModel): diff --git a/netbox/extras/templatetags/log_levels.py b/netbox/extras/templatetags/log_levels.py index 050a6996d..0779a87eb 100644 --- a/netbox/extras/templatetags/log_levels.py +++ b/netbox/extras/templatetags/log_levels.py @@ -13,5 +13,5 @@ def log_level(level): """ return { 'name': LogLevelChoices.as_dict()[level], - 'class': LogLevelChoices.CSS_CLASSES.get(level) + 'class': LogLevelChoices.colors.get(level) } diff --git a/netbox/ipam/choices.py b/netbox/ipam/choices.py index c414fc115..693ee6689 100644 --- a/netbox/ipam/choices.py +++ b/netbox/ipam/choices.py @@ -25,19 +25,12 @@ class PrefixStatusChoices(ChoiceSet): STATUS_DEPRECATED = 'deprecated' CHOICES = [ - (STATUS_CONTAINER, 'Container'), - (STATUS_ACTIVE, 'Active'), - (STATUS_RESERVED, 'Reserved'), - (STATUS_DEPRECATED, 'Deprecated'), + (STATUS_CONTAINER, 'Container', 'secondary'), + (STATUS_ACTIVE, 'Active', 'primary'), + (STATUS_RESERVED, 'Reserved', 'info'), + (STATUS_DEPRECATED, 'Deprecated', 'danger'), ] - CSS_CLASSES = { - STATUS_CONTAINER: 'secondary', - STATUS_ACTIVE: 'primary', - STATUS_RESERVED: 'info', - STATUS_DEPRECATED: 'danger', - } - # # IP Ranges @@ -51,17 +44,11 @@ class IPRangeStatusChoices(ChoiceSet): STATUS_DEPRECATED = 'deprecated' CHOICES = [ - (STATUS_ACTIVE, 'Active'), - (STATUS_RESERVED, 'Reserved'), - (STATUS_DEPRECATED, 'Deprecated'), + (STATUS_ACTIVE, 'Active', 'primary'), + (STATUS_RESERVED, 'Reserved', 'info'), + (STATUS_DEPRECATED, 'Deprecated', 'danger'), ] - CSS_CLASSES = { - STATUS_ACTIVE: 'primary', - STATUS_RESERVED: 'info', - STATUS_DEPRECATED: 'danger', - } - # # IP Addresses @@ -77,21 +64,13 @@ class IPAddressStatusChoices(ChoiceSet): STATUS_SLAAC = 'slaac' CHOICES = [ - (STATUS_ACTIVE, 'Active'), - (STATUS_RESERVED, 'Reserved'), - (STATUS_DEPRECATED, 'Deprecated'), - (STATUS_DHCP, 'DHCP'), - (STATUS_SLAAC, 'SLAAC'), + (STATUS_ACTIVE, 'Active', 'primary'), + (STATUS_RESERVED, 'Reserved', 'info'), + (STATUS_DEPRECATED, 'Deprecated', 'danger'), + (STATUS_DHCP, 'DHCP', 'success'), + (STATUS_SLAAC, 'SLAAC', 'success'), ] - CSS_CLASSES = { - STATUS_ACTIVE: 'primary', - STATUS_RESERVED: 'info', - STATUS_DEPRECATED: 'danger', - STATUS_DHCP: 'success', - STATUS_SLAAC: 'success', - } - class IPAddressRoleChoices(ChoiceSet): @@ -105,27 +84,16 @@ class IPAddressRoleChoices(ChoiceSet): ROLE_CARP = 'carp' CHOICES = ( - (ROLE_LOOPBACK, 'Loopback'), - (ROLE_SECONDARY, 'Secondary'), - (ROLE_ANYCAST, 'Anycast'), + (ROLE_LOOPBACK, 'Loopback', 'secondary'), + (ROLE_SECONDARY, 'Secondary', 'primary'), + (ROLE_ANYCAST, 'Anycast', 'warning'), (ROLE_VIP, 'VIP'), - (ROLE_VRRP, 'VRRP'), - (ROLE_HSRP, 'HSRP'), - (ROLE_GLBP, 'GLBP'), - (ROLE_CARP, 'CARP'), + (ROLE_VRRP, 'VRRP', 'success'), + (ROLE_HSRP, 'HSRP', 'success'), + (ROLE_GLBP, 'GLBP', 'success'), + (ROLE_CARP, 'CARP'), 'success', ) - CSS_CLASSES = { - ROLE_LOOPBACK: 'secondary', - ROLE_SECONDARY: 'primary', - ROLE_ANYCAST: 'warning', - ROLE_VIP: 'success', - ROLE_VRRP: 'success', - ROLE_HSRP: 'success', - ROLE_GLBP: 'success', - ROLE_CARP: 'success', - } - # # FHRP @@ -171,17 +139,11 @@ class VLANStatusChoices(ChoiceSet): STATUS_DEPRECATED = 'deprecated' CHOICES = [ - (STATUS_ACTIVE, 'Active'), - (STATUS_RESERVED, 'Reserved'), - (STATUS_DEPRECATED, 'Deprecated'), + (STATUS_ACTIVE, 'Active', 'primary'), + (STATUS_RESERVED, 'Reserved', 'info'), + (STATUS_DEPRECATED, 'Deprecated', 'danger'), ] - CSS_CLASSES = { - STATUS_ACTIVE: 'primary', - STATUS_RESERVED: 'info', - STATUS_DEPRECATED: 'danger', - } - # # Services diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index aeb71e70f..0b03cbe79 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -403,7 +403,7 @@ class Prefix(PrimaryModel): prefix_length = property(fset=_set_prefix_length) def get_status_class(self): - return PrefixStatusChoices.CSS_CLASSES.get(self.status) + return PrefixStatusChoices.colors.get(self.status, 'secondary') def get_parents(self, include_self=False): """ @@ -692,7 +692,7 @@ class IPRange(PrimaryModel): prefix_length = property(fset=_set_prefix_length) def get_status_class(self): - return IPRangeStatusChoices.CSS_CLASSES.get(self.status) + return IPRangeStatusChoices.colors.get(self.status, 'secondary') def get_child_ips(self): """ @@ -909,7 +909,7 @@ class IPAddress(PrimaryModel): mask_length = property(fset=_set_mask_length) def get_status_class(self): - return IPAddressStatusChoices.CSS_CLASSES.get(self.status) + return IPAddressStatusChoices.colors.get(self.status, 'secondary') def get_role_class(self): - return IPAddressRoleChoices.CSS_CLASSES.get(self.role) + return IPAddressRoleChoices.colors.get(self.role) diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py index 1c1691a62..3a1725770 100644 --- a/netbox/ipam/models/vlans.py +++ b/netbox/ipam/models/vlans.py @@ -173,7 +173,7 @@ class VLAN(PrimaryModel): }) def get_status_class(self): - return VLANStatusChoices.CSS_CLASSES.get(self.status) + return VLANStatusChoices.colors.get(self.status, 'secondary') def get_interfaces(self): # Return all device interfaces assigned to this VLAN diff --git a/netbox/utilities/choices.py b/netbox/utilities/choices.py index 46d74490a..18fd2f5a6 100644 --- a/netbox/utilities/choices.py +++ b/netbox/utilities/choices.py @@ -8,20 +8,37 @@ class ChoiceSetMeta(type): def __new__(mcs, name, bases, attrs): # Extend static choices with any configured choices - if 'key' in attrs: + key = attrs.get('key') + if key: try: - attrs['CHOICES'].extend(settings.FIELD_CHOICES[attrs['key']]) + attrs['CHOICES'].extend(settings.FIELD_CHOICES[key]) except KeyError: pass + # Define choice tuples + # TODO: Support optgroup nesting + attrs['_choices'] = [ + (c[0], c[1]) for c in attrs['CHOICES'] + ] + + # Define color maps + # TODO: Support optgroup nesting + colors = {} + for c in attrs['CHOICES']: + try: + colors[c[0]] = c[2] + except IndexError: + pass + attrs['colors'] = colors + return super().__new__(mcs, name, bases, attrs) def __call__(cls, *args, **kwargs): # Django will check if a 'choices' value is callable, and if so assume that it returns an iterable - return getattr(cls, 'CHOICES', ()) + return getattr(cls, '_choices', ()) def __iter__(cls): - choices = getattr(cls, 'CHOICES', ()) + choices = getattr(cls, '_choices', ()) return iter(choices) diff --git a/netbox/virtualization/choices.py b/netbox/virtualization/choices.py index c121d052e..1aaaf6bf9 100644 --- a/netbox/virtualization/choices.py +++ b/netbox/virtualization/choices.py @@ -16,19 +16,10 @@ class VirtualMachineStatusChoices(ChoiceSet): STATUS_DECOMMISSIONING = 'decommissioning' CHOICES = [ - (STATUS_OFFLINE, 'Offline'), - (STATUS_ACTIVE, 'Active'), - (STATUS_PLANNED, 'Planned'), - (STATUS_STAGED, 'Staged'), - (STATUS_FAILED, 'Failed'), - (STATUS_DECOMMISSIONING, 'Decommissioning'), + (STATUS_OFFLINE, 'Offline', 'warning'), + (STATUS_ACTIVE, 'Active', 'success'), + (STATUS_PLANNED, 'Planned', 'info'), + (STATUS_STAGED, 'Staged', 'primary'), + (STATUS_FAILED, 'Failed', 'danger'), + (STATUS_DECOMMISSIONING, 'Decommissioning', 'warning'), ] - - CSS_CLASSES = { - STATUS_OFFLINE: 'warning', - STATUS_ACTIVE: 'success', - STATUS_PLANNED: 'info', - STATUS_STAGED: 'primary', - STATUS_FAILED: 'danger', - STATUS_DECOMMISSIONING: 'warning', - } diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py index 5a1bcd42f..b19715127 100644 --- a/netbox/virtualization/models.py +++ b/netbox/virtualization/models.py @@ -329,7 +329,7 @@ class VirtualMachine(PrimaryModel, ConfigContextModel): }) def get_status_class(self): - return VirtualMachineStatusChoices.CSS_CLASSES.get(self.status) + return VirtualMachineStatusChoices.colors.get(self.status, 'secondary') @property def primary_ip(self): diff --git a/netbox/wireless/models.py b/netbox/wireless/models.py index 151828c88..2fcfc97aa 100644 --- a/netbox/wireless/models.py +++ b/netbox/wireless/models.py @@ -182,7 +182,7 @@ class WirelessLink(WirelessAuthenticationBase, PrimaryModel): return reverse('wireless:wirelesslink', args=[self.pk]) def get_status_class(self): - return LinkStatusChoices.CSS_CLASSES.get(self.status) + return LinkStatusChoices.colors.get(self.status) def clean(self): From 124302908a1889549a896e7ed818af85f9bde79d Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 16 Dec 2021 10:19:16 -0500 Subject: [PATCH 3/5] Support nested choice groups --- netbox/utilities/choices.py | 34 +++++++++++++++++----------------- 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/netbox/utilities/choices.py b/netbox/utilities/choices.py index 18fd2f5a6..ade1bd6fb 100644 --- a/netbox/utilities/choices.py +++ b/netbox/utilities/choices.py @@ -15,21 +15,21 @@ class ChoiceSetMeta(type): except KeyError: pass - # Define choice tuples - # TODO: Support optgroup nesting - attrs['_choices'] = [ - (c[0], c[1]) for c in attrs['CHOICES'] - ] - - # Define color maps - # TODO: Support optgroup nesting - colors = {} - for c in attrs['CHOICES']: - try: - colors[c[0]] = c[2] - except IndexError: - pass - attrs['colors'] = colors + # Define choice tuples and color maps + attrs['_choices'] = [] + attrs['colors'] = {} + for choice in attrs['CHOICES']: + if isinstance(choice[1], (list, tuple)): + grouped_choices = [] + for c in choice[1]: + grouped_choices.append((c[0], c[1])) + if len(c) == 3: + attrs['colors'][c[0]] = c[2] + attrs['_choices'].append((choice[0], grouped_choices)) + else: + attrs['_choices'].append((choice[0], choice[1])) + if len(choice) == 3: + attrs['colors'][choice[0]] = choice[2] return super().__new__(mcs, name, bases, attrs) @@ -48,12 +48,12 @@ class ChoiceSet(metaclass=ChoiceSetMeta): @classmethod def values(cls): - return [c[0] for c in unpack_grouped_choices(cls.CHOICES)] + return [c[0] for c in unpack_grouped_choices(cls._choices)] @classmethod def as_dict(cls): # Unpack grouped choices before casting as a dict - return dict(unpack_grouped_choices(cls.CHOICES)) + return dict(unpack_grouped_choices(cls._choices)) def unpack_grouped_choices(choices): From 1902ecb8ca92c6a80b3113ba01770dd2078c6400 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 16 Dec 2021 10:22:05 -0500 Subject: [PATCH 4/5] Drop as_dict() method from ChoiceSet --- netbox/extras/reports.py | 4 ++-- netbox/extras/templatetags/log_levels.py | 2 +- netbox/utilities/choices.py | 5 ----- netbox/utilities/tests/test_choices.py | 5 ----- 4 files changed, 3 insertions(+), 13 deletions(-) diff --git a/netbox/extras/reports.py b/netbox/extras/reports.py index cc623b37c..f53c0ecd0 100644 --- a/netbox/extras/reports.py +++ b/netbox/extras/reports.py @@ -167,8 +167,8 @@ class Report(object): """ Log a message from a test method. Do not call this method directly; use one of the log_* wrappers below. """ - if level not in LogLevelChoices.as_dict(): - raise Exception("Unknown logging level: {}".format(level)) + if level not in LogLevelChoices.values(): + raise Exception(f"Unknown logging level: {level}") self._results[self.active_test]['log'].append(( timezone.now().isoformat(), level, diff --git a/netbox/extras/templatetags/log_levels.py b/netbox/extras/templatetags/log_levels.py index 0779a87eb..fba73a74f 100644 --- a/netbox/extras/templatetags/log_levels.py +++ b/netbox/extras/templatetags/log_levels.py @@ -12,6 +12,6 @@ def log_level(level): Display a label indicating a syslog severity (e.g. info, warning, etc.). """ return { - 'name': LogLevelChoices.as_dict()[level], + 'name': dict(LogLevelChoices)[level], 'class': LogLevelChoices.colors.get(level) } diff --git a/netbox/utilities/choices.py b/netbox/utilities/choices.py index ade1bd6fb..f5756ffc4 100644 --- a/netbox/utilities/choices.py +++ b/netbox/utilities/choices.py @@ -50,11 +50,6 @@ class ChoiceSet(metaclass=ChoiceSetMeta): def values(cls): return [c[0] for c in unpack_grouped_choices(cls._choices)] - @classmethod - def as_dict(cls): - # Unpack grouped choices before casting as a dict - return dict(unpack_grouped_choices(cls._choices)) - def unpack_grouped_choices(choices): """ diff --git a/netbox/utilities/tests/test_choices.py b/netbox/utilities/tests/test_choices.py index bbf75e40e..8dbf5d602 100644 --- a/netbox/utilities/tests/test_choices.py +++ b/netbox/utilities/tests/test_choices.py @@ -30,8 +30,3 @@ class ChoiceSetTestCase(TestCase): def test_values(self): self.assertListEqual(ExampleChoices.values(), ['a', 'b', 'c', 1, 2, 3]) - - def test_as_dict(self): - self.assertEqual(ExampleChoices.as_dict(), { - 'a': 'A', 'b': 'B', 'c': 'C', 1: 'One', 2: 'Two', 3: 'Three' - }) From d8be8e25a53d1484b6ffb601bfae8c4ac0b77192 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 16 Dec 2021 10:31:32 -0500 Subject: [PATCH 5/5] ChoiceSet cleanup --- netbox/utilities/choices.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/netbox/utilities/choices.py b/netbox/utilities/choices.py index f5756ffc4..712783448 100644 --- a/netbox/utilities/choices.py +++ b/netbox/utilities/choices.py @@ -34,16 +34,18 @@ class ChoiceSetMeta(type): return super().__new__(mcs, name, bases, attrs) def __call__(cls, *args, **kwargs): - # Django will check if a 'choices' value is callable, and if so assume that it returns an iterable + # django-filters will check if a 'choices' value is callable, and if so assume that it returns an iterable return getattr(cls, '_choices', ()) def __iter__(cls): - choices = getattr(cls, '_choices', ()) - return iter(choices) + return iter(getattr(cls, '_choices', ())) class ChoiceSet(metaclass=ChoiceSetMeta): - + """ + Holds an interable of choice tuples suitable for passing to a Django model or form field. Choices can be defined + statically within the class as CHOICES and/or gleaned from the FIELD_CHOICES configuration parameter. + """ CHOICES = list() @classmethod