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..007b45298 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,23 +15,14 @@ class CircuitStatusChoices(ChoiceSet): STATUS_OFFLINE = 'offline' STATUS_DECOMMISSIONED = 'decommissioned' - CHOICES = ( - (STATUS_PLANNED, 'Planned'), - (STATUS_PROVISIONING, 'Provisioning'), - (STATUS_ACTIVE, 'Active'), - (STATUS_OFFLINE, 'Offline'), - (STATUS_DEPROVISIONING, 'Deprovisioning'), - (STATUS_DECOMMISSIONED, 'Decommissioned'), - ) - - CSS_CLASSES = { - STATUS_DEPROVISIONING: 'warning', - STATUS_ACTIVE: 'success', - STATUS_PLANNED: 'info', - STATUS_PROVISIONING: 'primary', - STATUS_OFFLINE: 'danger', - STATUS_DECOMMISSIONED: 'secondary', - } + CHOICES = [ + (STATUS_PLANNED, 'Planned', 'info'), + (STATUS_PROVISIONING, 'Provisioning', 'primary'), + (STATUS_ACTIVE, 'Active', 'success'), + (STATUS_OFFLINE, 'Offline', 'danger'), + (STATUS_DEPROVISIONING, 'Deprovisioning', 'warning'), + (STATUS_DECOMMISSIONED, 'Decommissioned', 'secondary'), + ] # 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 fcb37211f..bcc926580 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,21 +14,13 @@ class SiteStatusChoices(ChoiceSet): STATUS_DECOMMISSIONING = 'decommissioning' STATUS_RETIRED = 'retired' - CHOICES = ( - (STATUS_PLANNED, 'Planned'), - (STATUS_STAGING, 'Staging'), - (STATUS_ACTIVE, 'Active'), - (STATUS_DECOMMISSIONING, 'Decommissioning'), - (STATUS_RETIRED, 'Retired'), - ) - - CSS_CLASSES = { - STATUS_PLANNED: 'info', - STATUS_STAGING: 'primary', - STATUS_ACTIVE: 'success', - STATUS_DECOMMISSIONING: 'warning', - STATUS_RETIRED: 'danger', - } + CHOICES = [ + (STATUS_PLANNED, 'Planned', 'info'), + (STATUS_STAGING, 'Staging', 'primary'), + (STATUS_ACTIVE, 'Active', 'primary'), + (STATUS_DECOMMISSIONING, 'Decommissioning', 'warning'), + (STATUS_RETIRED, 'Retired', 'danger'), + ] # @@ -67,6 +60,7 @@ class RackWidthChoices(ChoiceSet): class RackStatusChoices(ChoiceSet): + key = 'dcim.Rack.status' STATUS_RESERVED = 'reserved' STATUS_AVAILABLE = 'available' @@ -74,21 +68,13 @@ class RackStatusChoices(ChoiceSet): STATUS_ACTIVE = 'active' STATUS_DEPRECATED = 'deprecated' - CHOICES = ( - (STATUS_RESERVED, 'Reserved'), - (STATUS_AVAILABLE, 'Available'), - (STATUS_PLANNED, 'Planned'), - (STATUS_ACTIVE, 'Active'), - (STATUS_DEPRECATED, 'Deprecated'), - ) - - CSS_CLASSES = { - STATUS_RESERVED: 'warning', - STATUS_AVAILABLE: 'success', - STATUS_PLANNED: 'info', - STATUS_ACTIVE: 'primary', - STATUS_DEPRECATED: 'danger', - } + CHOICES = [ + (STATUS_RESERVED, 'Reserved', 'warning'), + (STATUS_AVAILABLE, 'Available', 'success'), + (STATUS_PLANNED, 'Planned', 'info'), + (STATUS_ACTIVE, 'Active', 'primary'), + (STATUS_DEPRECATED, 'Deprecated', 'danger'), + ] class RackDimensionUnitChoices(ChoiceSet): @@ -144,6 +130,7 @@ class DeviceFaceChoices(ChoiceSet): class DeviceStatusChoices(ChoiceSet): + key = 'dcim.Device.status' STATUS_OFFLINE = 'offline' STATUS_ACTIVE = 'active' @@ -153,25 +140,15 @@ class DeviceStatusChoices(ChoiceSet): STATUS_INVENTORY = 'inventory' 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'), - ) - - CSS_CLASSES = { - STATUS_OFFLINE: 'warning', - STATUS_ACTIVE: 'success', - STATUS_PLANNED: 'info', - STATUS_STAGED: 'primary', - STATUS_FAILED: 'danger', - STATUS_INVENTORY: 'secondary', - STATUS_DECOMMISSIONING: 'warning', - } + CHOICES = [ + (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'), + ] class DeviceAirflowChoices(ChoiceSet): @@ -1144,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): @@ -1183,25 +1154,19 @@ class CableLengthUnitChoices(ChoiceSet): # class PowerFeedStatusChoices(ChoiceSet): + key = 'dcim.PowerFeed.status' STATUS_OFFLINE = 'offline' STATUS_ACTIVE = 'active' STATUS_PLANNED = 'planned' STATUS_FAILED = 'failed' - CHOICES = ( - (STATUS_OFFLINE, 'Offline'), - (STATUS_ACTIVE, 'Active'), - (STATUS_PLANNED, 'Planned'), - (STATUS_FAILED, 'Failed'), - ) - - CSS_CLASSES = { - STATUS_OFFLINE: 'warning', - STATUS_ACTIVE: 'success', - STATUS_PLANNED: 'info', - STATUS_FAILED: 'danger', - } + CHOICES = [ + (STATUS_OFFLINE, 'Offline', 'warning'), + (STATUS_ACTIVE, 'Active', 'success'), + (STATUS_PLANNED, 'Planned', 'info'), + (STATUS_FAILED, 'Failed', 'danger'), + ] class PowerFeedTypeChoices(ChoiceSet): @@ -1210,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/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 050a6996d..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], - 'class': LogLevelChoices.CSS_CLASSES.get(level) + 'name': dict(LogLevelChoices)[level], + 'class': LogLevelChoices.colors.get(level) } diff --git a/netbox/ipam/choices.py b/netbox/ipam/choices.py index 638ef62f6..693ee6689 100644 --- a/netbox/ipam/choices.py +++ b/netbox/ipam/choices.py @@ -17,25 +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 = ( - (STATUS_CONTAINER, 'Container'), - (STATUS_ACTIVE, 'Active'), - (STATUS_RESERVED, 'Reserved'), - (STATUS_DEPRECATED, 'Deprecated'), - ) - - CSS_CLASSES = { - STATUS_CONTAINER: 'secondary', - STATUS_ACTIVE: 'primary', - STATUS_RESERVED: 'info', - STATUS_DEPRECATED: 'danger', - } + CHOICES = [ + (STATUS_CONTAINER, 'Container', 'secondary'), + (STATUS_ACTIVE, 'Active', 'primary'), + (STATUS_RESERVED, 'Reserved', 'info'), + (STATUS_DEPRECATED, 'Deprecated', 'danger'), + ] # @@ -43,22 +37,17 @@ class PrefixStatusChoices(ChoiceSet): # class IPRangeStatusChoices(ChoiceSet): + key = 'ipam.IPRange.status' STATUS_ACTIVE = 'active' STATUS_RESERVED = 'reserved' STATUS_DEPRECATED = 'deprecated' - CHOICES = ( - (STATUS_ACTIVE, 'Active'), - (STATUS_RESERVED, 'Reserved'), - (STATUS_DEPRECATED, 'Deprecated'), - ) - - CSS_CLASSES = { - STATUS_ACTIVE: 'primary', - STATUS_RESERVED: 'info', - STATUS_DEPRECATED: 'danger', - } + CHOICES = [ + (STATUS_ACTIVE, 'Active', 'primary'), + (STATUS_RESERVED, 'Reserved', 'info'), + (STATUS_DEPRECATED, 'Deprecated', 'danger'), + ] # @@ -66,6 +55,7 @@ class IPRangeStatusChoices(ChoiceSet): # class IPAddressStatusChoices(ChoiceSet): + key = 'ipam.IPAddress.status' STATUS_ACTIVE = 'active' STATUS_RESERVED = 'reserved' @@ -73,21 +63,13 @@ class IPAddressStatusChoices(ChoiceSet): STATUS_DHCP = 'dhcp' STATUS_SLAAC = 'slaac' - CHOICES = ( - (STATUS_ACTIVE, 'Active'), - (STATUS_RESERVED, 'Reserved'), - (STATUS_DEPRECATED, 'Deprecated'), - (STATUS_DHCP, 'DHCP'), - (STATUS_SLAAC, 'SLAAC'), - ) - - CSS_CLASSES = { - STATUS_ACTIVE: 'primary', - STATUS_RESERVED: 'info', - STATUS_DEPRECATED: 'danger', - STATUS_DHCP: 'success', - STATUS_SLAAC: 'success', - } + CHOICES = [ + (STATUS_ACTIVE, 'Active', 'primary'), + (STATUS_RESERVED, 'Reserved', 'info'), + (STATUS_DEPRECATED, 'Deprecated', 'danger'), + (STATUS_DHCP, 'DHCP', 'success'), + (STATUS_SLAAC, 'SLAAC', 'success'), + ] class IPAddressRoleChoices(ChoiceSet): @@ -102,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 @@ -161,22 +132,17 @@ class FHRPGroupAuthTypeChoices(ChoiceSet): # class VLANStatusChoices(ChoiceSet): + key = 'ipam.VLAN.status' STATUS_ACTIVE = 'active' STATUS_RESERVED = 'reserved' STATUS_DEPRECATED = 'deprecated' - CHOICES = ( - (STATUS_ACTIVE, 'Active'), - (STATUS_RESERVED, 'Reserved'), - (STATUS_DEPRECATED, 'Deprecated'), - ) - - CSS_CLASSES = { - STATUS_ACTIVE: 'primary', - STATUS_RESERVED: 'info', - STATUS_DEPRECATED: 'danger', - } + CHOICES = [ + (STATUS_ACTIVE, 'Active', 'primary'), + (STATUS_RESERVED, 'Reserved', 'info'), + (STATUS_DEPRECATED, 'Deprecated', 'danger'), + ] # 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/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..712783448 100644 --- a/netbox/utilities/choices.py +++ b/netbox/utilities/choices.py @@ -1,28 +1,56 @@ +from django.conf import settings + + class ChoiceSetMeta(type): """ Metaclass for ChoiceSet """ + def __new__(mcs, name, bases, attrs): + + # Extend static choices with any configured choices + key = attrs.get('key') + if key: + try: + attrs['CHOICES'].extend(settings.FIELD_CHOICES[key]) + except KeyError: + pass + + # 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) + 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', ()) + # 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 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)) + return [c[0] for c in 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' - }) diff --git a/netbox/virtualization/choices.py b/netbox/virtualization/choices.py index 9c4eb6cd5..1aaaf6bf9 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,20 +15,11 @@ class VirtualMachineStatusChoices(ChoiceSet): STATUS_FAILED = 'failed' STATUS_DECOMMISSIONING = 'decommissioning' - CHOICES = ( - (STATUS_OFFLINE, 'Offline'), - (STATUS_ACTIVE, 'Active'), - (STATUS_PLANNED, 'Planned'), - (STATUS_STAGED, 'Staged'), - (STATUS_FAILED, 'Failed'), - (STATUS_DECOMMISSIONING, 'Decommissioning'), - ) - - CSS_CLASSES = { - STATUS_OFFLINE: 'warning', - STATUS_ACTIVE: 'success', - STATUS_PLANNED: 'info', - STATUS_STAGED: 'primary', - STATUS_FAILED: 'danger', - STATUS_DECOMMISSIONING: 'warning', - } + CHOICES = [ + (STATUS_OFFLINE, 'Offline', 'warning'), + (STATUS_ACTIVE, 'Active', 'success'), + (STATUS_PLANNED, 'Planned', 'info'), + (STATUS_STAGED, 'Staged', 'primary'), + (STATUS_FAILED, 'Failed', 'danger'), + (STATUS_DECOMMISSIONING, '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):