Merge pull request #8372 from netbox-community/develop

Release v3.1.6
This commit is contained in:
Jeremy Stretch 2022-01-17 10:15:24 -05:00 committed by GitHub
commit 98571c62a6
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
73 changed files with 411 additions and 175 deletions

View File

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

View File

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

View File

@ -98,10 +98,6 @@ psycopg2-binary
# https://github.com/yaml/pyyaml
PyYAML
# In-memory key/value store used for caching and queuing
# https://github.com/andymccurdy/redis-py
redis
# Social authentication framework
# https://github.com/python-social-auth/social-core
social-auth-core[all]

View File

@ -114,6 +114,12 @@ This ensures that your development environment is now complete and operational.
!!! info "IDE Integration"
Some IDEs, such as PyCharm, will integrate with Django's development server and allow you to run it directly within the IDE. This is strongly encouraged as it makes for a much more convenient development environment.
## Populating Demo Data
Once you have your development environment up and running, it might be helpful to populate some "dummy" data to make interacting with the UI and APIs more convenient. Check out the [netbox-demo-data](https://github.com/netbox-community/netbox-demo-data) repo on GitHub, which houses a collection of sample data that can be easily imported to any new NetBox deployment. (This sample data is used to populate the public demo instance at <https://demo.netbox.dev>.)
The demo data is provided in JSON format and loaded into an empty database using Django's `loaddata` management command. Consult the demo data repo's `README` file for complete instructions on populating the data.
## Running Tests
Prior to committing any substantial changes to the code base, be sure to run NetBox's test suite to catch any potential errors. Tests are run using the `test` management command. Remember to ensure the Python virtual environment is active before running this command.

View File

@ -50,7 +50,7 @@ NetBox is built on the [Django](https://djangoproject.com/) Python framework and
| Application | Django/Python |
| Database | PostgreSQL 10+ |
| Task queuing | Redis/django-rq |
| Live device access | NAPALM |
| Live device access | NAPALM (optional) |
## Supported Python Versions
@ -58,4 +58,6 @@ NetBox supports Python 3.7, 3.8, and 3.9 environments currently. (Support for Py
## Getting Started
See the [installation guide](installation/index.md) for help getting NetBox up and running quickly.
Minor NetBox releases (e.g. v3.1) are published three times a year; in April, August, and December. These typically introduce major new features and may contain breaking API changes. Patch releases are published roughly every one to two weeks to resolve bugs and fulfill minor feature requests. These are backward-compatible with previous releases unless otherwise noted. The NetBox maintainers strongly recommend running the latest stable release whenever possible.
Please see the [official installation guide](installation/index.md) for detailed instructions on obtaining and installing NetBox.

View File

@ -1,5 +1,8 @@
# Plugin Development
!!! info "Help Improve the NetBox Plugins Framework!"
We're looking for volunteers to help improve NetBox's plugins framework. If you have experience developing plugins, we'd love to hear from you! You can find more information about this initiative [here](https://github.com/netbox-community/netbox/discussions/8338).
This documentation covers the development of custom plugins for NetBox. Plugins are essentially self-contained [Django apps](https://docs.djangoproject.com/en/stable/) which integrate with NetBox to provide custom functionality. Since the development of Django apps is already very well-documented, we'll only be covering the aspects that are specific to NetBox.
Plugins can do a lot, including:

View File

@ -1,6 +1,14 @@
# Release Notes
Listed below are the major features introduced in each NetBox release. For more detail on a specific release train, see its individual release notes page.
NetBox releases are numbered as major, minor, and patch releases. For example, version 3.1.0 is a minor release, and v3.1.5 is a patch release. Briefly, these can be described as follows:
* **Major** - Introduces or removes an entire API or other core functionality
* **Minor** - Implements major new features but may include breaking changes for API consumers or other integrations
* **Patch** - A maintenance release which fixes bugs and may introduce backward-compatible enhancements
Minor releases are published in April, August, and December of each calendar year. Patch releases are published as needed to address bugs and fulfill minor feature requests, typically around every one to two weeks.
This page contains a history of all major and minor releases since NetBox v2.0. For more detail on a specific patch release, please see the release notes page for that specific minor release.
#### [Version 3.1](./version-3.1.md) (December 2021)

View File

@ -1,5 +1,33 @@
# NetBox v3.1
## v3.1.6 (2022-01-17)
### Enhancements
* [#8246](https://github.com/netbox-community/netbox/issues/8246) - Show human-friendly values for commit rates in circuits table
* [#8262](https://github.com/netbox-community/netbox/issues/8262) - Add cable count to tenant stats
* [#8265](https://github.com/netbox-community/netbox/issues/8265) - Add Stackwise-n interface types
* [#8293](https://github.com/netbox-community/netbox/issues/8293) - Show 4-byte ASNs in ASDOT notation
* [#8302](https://github.com/netbox-community/netbox/issues/8302) - Linkify role column in device & VM tables
* [#8337](https://github.com/netbox-community/netbox/issues/8337) - Enable sorting object tables by created & updated times
### Bug Fixes
* [#8279](https://github.com/netbox-community/netbox/issues/8279) - Fix display of virtual chassis members in rack elevations
* [#8285](https://github.com/netbox-community/netbox/issues/8285) - Fix `cluster_count` under tenant REST API serializer
* [#8287](https://github.com/netbox-community/netbox/issues/8287) - Correct label in export template form
* [#8301](https://github.com/netbox-community/netbox/issues/8301) - Fix delete button for various object children views
* [#8305](https://github.com/netbox-community/netbox/issues/8305) - Fix assignment of custom field data to FHRP groups via UI
* [#8306](https://github.com/netbox-community/netbox/issues/8306) - Redirect user to previous page after login
* [#8314](https://github.com/netbox-community/netbox/issues/8314) - Prevent custom fields with default values from appearing as applied filters erroneously
* [#8317](https://github.com/netbox-community/netbox/issues/8317) - Fix CSV import of multi-select custom field values
* [#8319](https://github.com/netbox-community/netbox/issues/8319) - Custom URL fields should honor `ALLOWED_URL_SCHEMES` config parameter
* [#8342](https://github.com/netbox-community/netbox/issues/8342) - Restore `created` & `last_updated` fields missing from several REST API serializers
* [#8357](https://github.com/netbox-community/netbox/issues/8357) - Add missing tags field to location filter form
* [#8358](https://github.com/netbox-community/netbox/issues/8358) - Fix inconsistent styling of custom fields on filter & bulk edit forms
---
## v3.1.5 (2022-01-06)
### Enhancements

View File

@ -100,5 +100,5 @@ class CircuitTerminationSerializer(ValidatedModelSerializer, LinkTerminationSeri
fields = [
'id', 'url', 'display', 'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed',
'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'link_peer', 'link_peer_type',
'_occupied',
'_occupied', 'created', 'last_updated',
]

View File

@ -22,11 +22,32 @@ CIRCUITTERMINATION_LINK = """
{% endif %}
"""
#
# Table columns
#
class CommitRateColumn(tables.TemplateColumn):
"""
Humanize the commit rate in the column view
"""
template_code = """
{% load helpers %}
{{ record.commit_rate|humanize_speed }}
"""
def __init__(self, *args, **kwargs):
super().__init__(template_code=self.template_code, *args, **kwargs)
def value(self, value):
return str(value) if value else None
#
# Providers
#
class ProviderTable(BaseTable):
pk = ToggleColumn()
name = tables.Column(
@ -45,7 +66,7 @@ class ProviderTable(BaseTable):
model = Provider
fields = (
'pk', 'id', 'name', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'circuit_count',
'comments', 'tags',
'comments', 'tags', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'asn', 'account', 'circuit_count')
@ -69,7 +90,7 @@ class ProviderNetworkTable(BaseTable):
class Meta(BaseTable.Meta):
model = ProviderNetwork
fields = ('pk', 'id', 'name', 'provider', 'description', 'comments', 'tags')
fields = ('pk', 'id', 'name', 'provider', 'description', 'comments', 'tags', 'created', 'last_updated',)
default_columns = ('pk', 'name', 'provider', 'description')
@ -92,7 +113,7 @@ class CircuitTypeTable(BaseTable):
class Meta(BaseTable.Meta):
model = CircuitType
fields = ('pk', 'id', 'name', 'circuit_count', 'description', 'slug', 'tags', 'actions')
fields = ('pk', 'id', 'name', 'circuit_count', 'description', 'slug', 'tags', 'actions', 'created', 'last_updated',)
default_columns = ('pk', 'name', 'circuit_count', 'description', 'slug', 'actions')
@ -119,6 +140,7 @@ class CircuitTable(BaseTable):
template_code=CIRCUITTERMINATION_LINK,
verbose_name='Side Z'
)
commit_rate = CommitRateColumn()
comments = MarkdownColumn()
tags = TagColumn(
url_name='circuits:circuit_list'
@ -128,7 +150,7 @@ class CircuitTable(BaseTable):
model = Circuit
fields = (
'pk', 'id', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'install_date',
'commit_rate', 'description', 'comments', 'tags',
'commit_rate', 'description', 'comments', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'description',

View File

@ -219,7 +219,7 @@ class RackReservationSerializer(PrimaryModelSerializer):
class Meta:
model = RackReservation
fields = [
'id', 'url', 'display', 'rack', 'units', 'created', 'user', 'tenant', 'description', 'tags',
'id', 'url', 'display', 'rack', 'units', 'created', 'last_updated', 'user', 'tenant', 'description', 'tags',
'custom_fields',
]
@ -762,7 +762,7 @@ class CableSerializer(PrimaryModelSerializer):
fields = [
'id', 'url', 'display', 'termination_a_type', 'termination_a_id', 'termination_a', 'termination_b_type',
'termination_b_id', 'termination_b', 'type', 'status', 'tenant', 'label', 'color', 'length', 'length_unit',
'tags', 'custom_fields',
'tags', 'custom_fields', 'created', 'last_updated',
]
def _get_termination(self, obj, side):
@ -856,7 +856,10 @@ class VirtualChassisSerializer(PrimaryModelSerializer):
class Meta:
model = VirtualChassis
fields = ['id', 'url', 'display', 'name', 'domain', 'master', 'tags', 'custom_fields', 'member_count']
fields = [
'id', 'url', 'display', 'name', 'domain', 'master', 'tags', 'custom_fields', 'member_count',
'created', 'last_updated',
]
#
@ -875,7 +878,10 @@ class PowerPanelSerializer(PrimaryModelSerializer):
class Meta:
model = PowerPanel
fields = ['id', 'url', 'display', 'site', 'location', 'name', 'tags', 'custom_fields', 'powerfeed_count']
fields = [
'id', 'url', 'display', 'site', 'location', 'name', 'tags', 'custom_fields', 'powerfeed_count',
'created', 'last_updated',
]
class PowerFeedSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):

View File

@ -816,6 +816,10 @@ class InterfaceTypeChoices(ChoiceSet):
TYPE_STACKWISE_PLUS = 'cisco-stackwise-plus'
TYPE_FLEXSTACK = 'cisco-flexstack'
TYPE_FLEXSTACK_PLUS = 'cisco-flexstack-plus'
TYPE_STACKWISE80 = 'cisco-stackwise-80'
TYPE_STACKWISE160 = 'cisco-stackwise-160'
TYPE_STACKWISE320 = 'cisco-stackwise-320'
TYPE_STACKWISE480 = 'cisco-stackwise-480'
TYPE_JUNIPER_VCP = 'juniper-vcp'
TYPE_SUMMITSTACK = 'extreme-summitstack'
TYPE_SUMMITSTACK128 = 'extreme-summitstack-128'
@ -950,6 +954,10 @@ class InterfaceTypeChoices(ChoiceSet):
(TYPE_STACKWISE_PLUS, 'Cisco StackWise Plus'),
(TYPE_FLEXSTACK, 'Cisco FlexStack'),
(TYPE_FLEXSTACK_PLUS, 'Cisco FlexStack Plus'),
(TYPE_STACKWISE80, 'Cisco StackWise-80'),
(TYPE_STACKWISE160, 'Cisco StackWise-160'),
(TYPE_STACKWISE320, 'Cisco StackWise-320'),
(TYPE_STACKWISE480, 'Cisco StackWise-480'),
(TYPE_JUNIPER_VCP, 'Juniper VCP'),
(TYPE_SUMMITSTACK, 'Extreme SummitStack'),
(TYPE_SUMMITSTACK128, 'Extreme SummitStack-128'),

View File

@ -152,7 +152,7 @@ class SiteFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
class LocationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
model = Location
field_groups = [
['q'],
['q', 'tag'],
['region_id', 'site_group_id', 'site_id', 'parent_id'],
['tenant_group_id', 'tenant_id'],
]

View File

@ -19,7 +19,12 @@ __all__ = (
def get_device_name(device):
return device.name or str(device.device_type)
if device.virtual_chassis:
return f'{device.virtual_chassis.name}:{device.vc_position}'
elif device.name:
return device.name
else:
return str(device.device_type)
class RackElevationSVG:

View File

@ -56,7 +56,7 @@ class CableTable(BaseTable):
model = Cable
fields = (
'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b',
'status', 'type', 'tenant', 'color', 'length', 'tags',
'status', 'type', 'tenant', 'color', 'length', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b',

View File

@ -97,7 +97,7 @@ class DeviceRoleTable(BaseTable):
model = DeviceRole
fields = (
'pk', 'id', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'slug', 'tags',
'actions',
'actions', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'actions')
@ -130,7 +130,7 @@ class PlatformTable(BaseTable):
model = Platform
fields = (
'pk', 'id', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'napalm_driver', 'napalm_args',
'description', 'tags', 'actions',
'description', 'tags', 'actions', 'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'napalm_driver', 'description', 'actions',
@ -204,7 +204,8 @@ class DeviceTable(BaseTable):
fields = (
'pk', 'id', 'name', 'status', 'tenant', 'device_role', 'manufacturer', 'device_type', 'platform', 'serial',
'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'primary_ip', 'airflow', 'primary_ip4',
'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'tags',
'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'tags', 'created',
'last_updated',
)
default_columns = (
'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type',
@ -297,7 +298,7 @@ class ConsolePortTable(DeviceComponentTable, PathEndpointTable):
model = ConsolePort
fields = (
'pk', 'id', 'name', 'device', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color',
'link_peer', 'connection', 'tags',
'link_peer', 'connection', 'tags', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'device', 'label', 'type', 'speed', 'description')
@ -341,7 +342,7 @@ class ConsoleServerPortTable(DeviceComponentTable, PathEndpointTable):
model = ConsoleServerPort
fields = (
'pk', 'id', 'name', 'device', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable',
'cable_color', 'link_peer', 'connection', 'tags',
'cable_color', 'link_peer', 'connection', 'tags', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'device', 'label', 'type', 'speed', 'description')
@ -386,7 +387,7 @@ class PowerPortTable(DeviceComponentTable, PathEndpointTable):
model = PowerPort
fields = (
'pk', 'id', 'name', 'device', 'label', 'type', 'description', 'mark_connected', 'maximum_draw',
'allocated_draw', 'cable', 'cable_color', 'link_peer', 'connection', 'tags',
'allocated_draw', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'device', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description')
@ -437,7 +438,7 @@ class PowerOutletTable(DeviceComponentTable, PathEndpointTable):
model = PowerOutlet
fields = (
'pk', 'id', 'name', 'device', 'label', 'type', 'description', 'power_port', 'feed_leg', 'mark_connected',
'cable', 'cable_color', 'link_peer', 'connection', 'tags',
'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'device', 'label', 'type', 'power_port', 'feed_leg', 'description')
@ -515,7 +516,7 @@ class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable
'pk', 'id', 'name', 'device', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address', 'wwn',
'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description',
'mark_connected', 'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection',
'tags', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans',
'tags', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')
@ -586,7 +587,7 @@ class FrontPortTable(DeviceComponentTable, CableTerminationTable):
model = FrontPort
fields = (
'pk', 'id', 'name', 'device', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description',
'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags',
'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'device', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description',
@ -637,7 +638,7 @@ class RearPortTable(DeviceComponentTable, CableTerminationTable):
model = RearPort
fields = (
'pk', 'id', 'name', 'device', 'label', 'type', 'color', 'positions', 'description', 'mark_connected', 'cable',
'cable_color', 'link_peer', 'tags',
'cable_color', 'link_peer', 'tags', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'device', 'label', 'type', 'color', 'description')
@ -689,7 +690,11 @@ class DeviceBayTable(DeviceComponentTable):
class Meta(DeviceComponentTable.Meta):
model = DeviceBay
fields = ('pk', 'id', 'name', 'device', 'label', 'status', 'installed_device', 'description', 'tags')
fields = (
'pk', 'id', 'name', 'device', 'label', 'status', 'installed_device', 'description', 'tags',
'created', 'last_updated',
)
default_columns = ('pk', 'name', 'device', 'label', 'status', 'installed_device', 'description')
@ -736,7 +741,7 @@ class InventoryItemTable(DeviceComponentTable):
model = InventoryItem
fields = (
'pk', 'id', 'name', 'device', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description',
'discovered', 'tags',
'discovered', 'tags', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'device', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag')
@ -788,5 +793,5 @@ class VirtualChassisTable(BaseTable):
class Meta(BaseTable.Meta):
model = VirtualChassis
fields = ('pk', 'id', 'name', 'domain', 'master', 'member_count', 'tags')
fields = ('pk', 'id', 'name', 'domain', 'master', 'member_count', 'tags', 'created', 'last_updated',)
default_columns = ('pk', 'name', 'domain', 'master', 'member_count')

View File

@ -50,7 +50,7 @@ class ManufacturerTable(BaseTable):
model = Manufacturer
fields = (
'pk', 'id', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug',
'actions',
'actions', 'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', 'actions',
@ -84,7 +84,7 @@ class DeviceTypeTable(BaseTable):
model = DeviceType
fields = (
'pk', 'id', 'model', 'manufacturer', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role',
'airflow', 'comments', 'instance_count', 'tags',
'airflow', 'comments', 'instance_count', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'instance_count',

View File

@ -33,7 +33,7 @@ class PowerPanelTable(BaseTable):
class Meta(BaseTable.Meta):
model = PowerPanel
fields = ('pk', 'id', 'name', 'site', 'location', 'powerfeed_count', 'tags')
fields = ('pk', 'id', 'name', 'site', 'location', 'powerfeed_count', 'tags', 'created', 'last_updated',)
default_columns = ('pk', 'name', 'site', 'location', 'powerfeed_count')
@ -72,7 +72,7 @@ class PowerFeedTable(CableTerminationTable):
fields = (
'pk', 'id', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase',
'max_utilization', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'available_power',
'comments', 'tags',
'comments', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'cable',

View File

@ -31,7 +31,10 @@ class RackRoleTable(BaseTable):
class Meta(BaseTable.Meta):
model = RackRole
fields = ('pk', 'id', 'name', 'rack_count', 'color', 'description', 'slug', 'tags', 'actions')
fields = (
'pk', 'id', 'name', 'rack_count', 'color', 'description', 'slug', 'tags', 'actions',
'created', 'last_updated',
)
default_columns = ('pk', 'name', 'rack_count', 'color', 'description', 'actions')
@ -87,8 +90,9 @@ class RackTable(BaseTable):
class Meta(BaseTable.Meta):
model = Rack
fields = (
'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag', 'type',
'width', 'outer_width', 'outer_depth', 'u_height', 'comments', 'device_count', 'get_utilization', 'get_power_utilization', 'tags',
'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag',
'type', 'width', 'outer_width', 'outer_depth', 'u_height', 'comments', 'device_count', 'get_utilization',
'get_power_utilization', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count',
@ -127,7 +131,7 @@ class RackReservationTable(BaseTable):
model = RackReservation
fields = (
'pk', 'id', 'reservation', 'site', 'rack', 'unit_list', 'user', 'created', 'tenant', 'description', 'tags',
'actions',
'actions', 'created', 'last_updated',
)
default_columns = (
'pk', 'reservation', 'site', 'rack', 'unit_list', 'user', 'description', 'actions',

View File

@ -36,7 +36,7 @@ class RegionTable(BaseTable):
class Meta(BaseTable.Meta):
model = Region
fields = ('pk', 'id', 'name', 'slug', 'site_count', 'description', 'tags', 'actions')
fields = ('pk', 'id', 'name', 'slug', 'site_count', 'description', 'tags', 'actions', 'created', 'last_updated')
default_columns = ('pk', 'name', 'site_count', 'description', 'actions')
@ -61,7 +61,7 @@ class SiteGroupTable(BaseTable):
class Meta(BaseTable.Meta):
model = SiteGroup
fields = ('pk', 'id', 'name', 'slug', 'site_count', 'description', 'tags', 'actions')
fields = ('pk', 'id', 'name', 'slug', 'site_count', 'description', 'tags', 'actions', 'created', 'last_updated')
default_columns = ('pk', 'name', 'site_count', 'description', 'actions')
@ -98,7 +98,7 @@ class SiteTable(BaseTable):
fields = (
'pk', 'id', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'asn_count', 'time_zone',
'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name',
'contact_phone', 'contact_email', 'comments', 'tags',
'contact_phone', 'contact_email', 'comments', 'tags', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'status', 'facility', 'region', 'group', 'tenant', 'description')
@ -138,6 +138,6 @@ class LocationTable(BaseTable):
model = Location
fields = (
'pk', 'id', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'slug', 'tags',
'actions',
'actions', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'site', 'tenant', 'rack_count', 'device_count', 'description', 'actions')

View File

@ -61,7 +61,7 @@ class WebhookSerializer(ValidatedModelSerializer):
fields = [
'id', 'url', 'display', 'content_types', 'name', 'type_create', 'type_update', 'type_delete', 'payload_url',
'enabled', 'http_method', 'http_content_type', 'additional_headers', 'body_template', 'secret',
'conditions', 'ssl_verification', 'ca_file_path',
'conditions', 'ssl_verification', 'ca_file_path', 'created', 'last_updated',
]
@ -82,7 +82,8 @@ class CustomFieldSerializer(ValidatedModelSerializer):
model = CustomField
fields = [
'id', 'url', 'display', 'content_types', 'type', 'name', 'label', 'description', 'required', 'filter_logic',
'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choices',
'default', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choices', 'created',
'last_updated',
]
@ -100,7 +101,7 @@ class CustomLinkSerializer(ValidatedModelSerializer):
model = CustomLink
fields = [
'id', 'url', 'display', 'content_type', 'name', 'link_text', 'link_url', 'weight', 'group_name',
'button_class', 'new_window',
'button_class', 'new_window', 'created', 'last_updated',
]
@ -118,7 +119,7 @@ class ExportTemplateSerializer(ValidatedModelSerializer):
model = ExportTemplate
fields = [
'id', 'url', 'display', 'content_type', 'name', 'description', 'template_code', 'mime_type',
'file_extension', 'as_attachment',
'file_extension', 'as_attachment', 'created', 'last_updated',
]
@ -132,7 +133,9 @@ class TagSerializer(ValidatedModelSerializer):
class Meta:
model = Tag
fields = ['id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tagged_items']
fields = [
'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tagged_items', 'created', 'last_updated',
]
#

View File

@ -4,7 +4,7 @@ from django.db.models import Q
from extras.choices import *
from extras.models import *
from utilities.forms import BootstrapMixin, BulkEditForm, CSVModelForm, FilterForm
from utilities.forms import BootstrapMixin, BulkEditBaseForm, CSVModelForm
__all__ = (
'CustomFieldModelCSVForm',
@ -34,6 +34,9 @@ class CustomFieldsMixin:
raise NotImplementedError(f"{self.__class__.__name__} must specify a model class.")
return ContentType.objects.get_for_model(self.model)
def _get_custom_fields(self, content_type):
return CustomField.objects.filter(content_types=content_type)
def _get_form_field(self, customfield):
return customfield.to_form_field()
@ -41,10 +44,7 @@ class CustomFieldsMixin:
"""
Append form fields for all CustomFields assigned to this object type.
"""
content_type = self._get_content_type()
# Append form fields; assign initial values if modifying and existing object
for customfield in CustomField.objects.filter(content_types=content_type):
for customfield in self._get_custom_fields(self._get_content_type()):
field_name = f'cf_{customfield.name}'
self.fields[field_name] = self._get_form_field(customfield)
@ -86,40 +86,37 @@ class CustomFieldModelCSVForm(CSVModelForm, CustomFieldModelForm):
return customfield.to_form_field(for_csv_import=True)
class CustomFieldModelBulkEditForm(BulkEditForm):
class CustomFieldModelBulkEditForm(BootstrapMixin, CustomFieldsMixin, BulkEditBaseForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def _get_form_field(self, customfield):
return customfield.to_form_field(set_initial=False, enforce_required=False)
self.custom_fields = []
self.obj_type = ContentType.objects.get_for_model(self.model)
# Add all applicable CustomFields to the form
custom_fields = CustomField.objects.filter(content_types=self.obj_type)
for cf in custom_fields:
def _append_customfield_fields(self):
"""
Append form fields for all CustomFields assigned to this object type.
"""
for customfield in self._get_custom_fields(self._get_content_type()):
# Annotate non-required custom fields as nullable
if not cf.required:
self.nullable_fields.append(cf.name)
self.fields[cf.name] = cf.to_form_field(set_initial=False, enforce_required=False)
# Annotate this as a custom field
self.custom_fields.append(cf.name)
if not customfield.required:
self.nullable_fields.append(customfield.name)
self.fields[customfield.name] = self._get_form_field(customfield)
# Annotate the field in the list of CustomField form fields
self.custom_fields.append(customfield.name)
class CustomFieldModelFilterForm(FilterForm):
class CustomFieldModelFilterForm(BootstrapMixin, CustomFieldsMixin, forms.Form):
q = forms.CharField(
required=False,
label='Search'
)
def __init__(self, *args, **kwargs):
self.obj_type = ContentType.objects.get_for_model(self.model)
super().__init__(*args, **kwargs)
# Add all applicable CustomFields to the form
self.custom_field_filters = []
custom_fields = CustomField.objects.filter(content_types=self.obj_type).exclude(
def _get_custom_fields(self, content_type):
return CustomField.objects.filter(content_types=content_type).exclude(
Q(filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED) |
Q(type=CustomFieldTypeChoices.TYPE_JSON)
)
for cf in custom_fields:
field_name = f'cf_{cf.name}'
self.fields[field_name] = cf.to_form_field(set_initial=True, enforce_required=False)
self.custom_field_filters.append(field_name)
def _get_form_field(self, customfield):
return customfield.to_form_field(set_initial=False, enforce_required=False)

View File

@ -82,7 +82,7 @@ class ExportTemplateForm(BootstrapMixin, forms.ModelForm):
model = ExportTemplate
fields = '__all__'
fieldsets = (
('Custom Link', ('name', 'content_type', 'description')),
('Export Template', ('name', 'content_type', 'description')),
('Template', ('template_code',)),
('Rendering', ('mime_type', 'file_extension', 'as_attachment')),
)

View File

@ -16,7 +16,8 @@ from extras.utils import FeatureQuery, extras_features
from netbox.models import ChangeLoggedModel
from utilities import filters
from utilities.forms import (
CSVChoiceField, DatePicker, LaxURLField, StaticSelectMultiple, StaticSelect, add_blank_choice,
CSVChoiceField, CSVMultipleChoiceField, DatePicker, LaxURLField, StaticSelectMultiple, StaticSelect,
add_blank_choice,
)
from utilities.querysets import RestrictedQuerySet
from utilities.validators import validate_regex
@ -238,7 +239,7 @@ class CustomField(ChangeLoggedModel):
"""
Return a form field suitable for setting a CustomField's value for an object.
set_initial: Set initial date for the field. This should be False when generating a field for bulk editing.
set_initial: Set initial data for the field. This should be False when generating a field for bulk editing.
enforce_required: Honor the value of CustomField.required. Set to False for filtering/bulk editing.
for_csv_import: Return a form field suitable for bulk import of objects in CSV format.
"""
@ -287,7 +288,7 @@ class CustomField(ChangeLoggedModel):
choices=choices, required=required, initial=initial, widget=StaticSelect()
)
else:
field_class = CSVChoiceField if for_csv_import else forms.MultipleChoiceField
field_class = CSVMultipleChoiceField if for_csv_import else forms.MultipleChoiceField
field = field_class(
choices=choices, required=required, initial=initial, widget=StaticSelectMultiple()
)

View File

@ -58,7 +58,7 @@ class CustomFieldTable(BaseTable):
model = CustomField
fields = (
'pk', 'id', 'name', 'content_types', 'label', 'type', 'required', 'weight', 'default',
'description', 'filter_logic', 'choices',
'description', 'filter_logic', 'choices', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'content_types', 'label', 'type', 'required', 'description')
@ -79,7 +79,7 @@ class CustomLinkTable(BaseTable):
model = CustomLink
fields = (
'pk', 'id', 'name', 'content_type', 'link_text', 'link_url', 'weight', 'group_name',
'button_class', 'new_window',
'button_class', 'new_window', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'content_type', 'group_name', 'button_class', 'new_window')
@ -100,6 +100,7 @@ class ExportTemplateTable(BaseTable):
model = ExportTemplate
fields = (
'pk', 'id', 'name', 'content_type', 'description', 'mime_type', 'file_extension', 'as_attachment',
'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'content_type', 'description', 'mime_type', 'file_extension', 'as_attachment',
@ -134,7 +135,7 @@ class WebhookTable(BaseTable):
model = Webhook
fields = (
'pk', 'id', 'name', 'content_types', 'enabled', 'type_create', 'type_update', 'type_delete', 'http_method',
'payload_url', 'secret', 'ssl_validation', 'ca_file_path',
'payload_url', 'secret', 'ssl_validation', 'ca_file_path', 'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'content_types', 'enabled', 'type_create', 'type_update', 'type_delete', 'http_method',
@ -156,7 +157,7 @@ class TagTable(BaseTable):
class Meta(BaseTable.Meta):
model = Tag
fields = ('pk', 'id', 'name', 'items', 'slug', 'color', 'description', 'actions')
fields = ('pk', 'id', 'name', 'items', 'slug', 'color', 'description', 'actions', 'created', 'last_updated',)
default_columns = ('pk', 'name', 'items', 'slug', 'color', 'description', 'actions')
@ -193,7 +194,7 @@ class ConfigContextTable(BaseTable):
model = ConfigContext
fields = (
'pk', 'id', 'name', 'weight', 'is_active', 'description', 'regions', 'sites', 'roles',
'platforms', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants',
'platforms', 'cluster_groups', 'clusters', 'tenant_groups', 'tenants', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'weight', 'is_active', 'description')

View File

@ -122,13 +122,14 @@ class CustomFieldTest(TestCase):
def test_select_field(self):
obj_type = ContentType.objects.get_for_model(Site)
choices = ['Option A', 'Option B', 'Option C']
# Create a custom field
cf = CustomField(
type=CustomFieldTypeChoices.TYPE_SELECT,
name='my_field',
required=False,
choices=['Option A', 'Option B', 'Option C']
choices=choices
)
cf.save()
cf.content_types.set([obj_type])
@ -138,12 +139,47 @@ class CustomFieldTest(TestCase):
self.assertIsNone(site.custom_field_data[cf.name])
# Assign a value to the first Site
site.custom_field_data[cf.name] = 'Option A'
site.custom_field_data[cf.name] = choices[0]
site.save()
# Retrieve the stored value
site.refresh_from_db()
self.assertEqual(site.custom_field_data[cf.name], 'Option A')
self.assertEqual(site.custom_field_data[cf.name], choices[0])
# Delete the stored value
site.custom_field_data.pop(cf.name)
site.save()
site.refresh_from_db()
self.assertIsNone(site.custom_field_data.get(cf.name))
# Delete the custom field
cf.delete()
def test_multiselect_field(self):
obj_type = ContentType.objects.get_for_model(Site)
choices = ['Option A', 'Option B', 'Option C']
# Create a custom field
cf = CustomField(
type=CustomFieldTypeChoices.TYPE_MULTISELECT,
name='my_field',
required=False,
choices=choices
)
cf.save()
cf.content_types.set([obj_type])
# Check that the field has a null initial value
site = Site.objects.first()
self.assertIsNone(site.custom_field_data[cf.name])
# Assign a value to the first Site
site.custom_field_data[cf.name] = [choices[0], choices[1]]
site.save()
# Retrieve the stored value
site.refresh_from_db()
self.assertEqual(site.custom_field_data[cf.name], [choices[0], choices[1]])
# Delete the stored value
site.custom_field_data.pop(cf.name)
@ -597,6 +633,9 @@ class CustomFieldImportTest(TestCase):
CustomField(name='select', type=CustomFieldTypeChoices.TYPE_SELECT, choices=[
'Choice A', 'Choice B', 'Choice C',
]),
CustomField(name='multiselect', type=CustomFieldTypeChoices.TYPE_MULTISELECT, choices=[
'Choice A', 'Choice B', 'Choice C',
]),
)
for cf in custom_fields:
cf.save()
@ -607,19 +646,20 @@ class CustomFieldImportTest(TestCase):
Import a Site in CSV format, including a value for each CustomField.
"""
data = (
('name', 'slug', 'status', 'cf_text', 'cf_longtext', 'cf_integer', 'cf_boolean', 'cf_date', 'cf_url', 'cf_json', 'cf_select'),
('Site 1', 'site-1', 'active', 'ABC', 'Foo', '123', 'True', '2020-01-01', 'http://example.com/1', '{"foo": 123}', 'Choice A'),
('Site 2', 'site-2', 'active', 'DEF', 'Bar', '456', 'False', '2020-01-02', 'http://example.com/2', '{"bar": 456}', 'Choice B'),
('Site 3', 'site-3', 'active', '', '', '', '', '', '', '', ''),
('name', 'slug', 'status', 'cf_text', 'cf_longtext', 'cf_integer', 'cf_boolean', 'cf_date', 'cf_url', 'cf_json', 'cf_select', 'cf_multiselect'),
('Site 1', 'site-1', 'active', 'ABC', 'Foo', '123', 'True', '2020-01-01', 'http://example.com/1', '{"foo": 123}', 'Choice A', '"Choice A,Choice B"'),
('Site 2', 'site-2', 'active', 'DEF', 'Bar', '456', 'False', '2020-01-02', 'http://example.com/2', '{"bar": 456}', 'Choice B', '"Choice B,Choice C"'),
('Site 3', 'site-3', 'active', '', '', '', '', '', '', '', '', ''),
)
csv_data = '\n'.join(','.join(row) for row in data)
response = self.client.post(reverse('dcim:site_import'), {'csv': csv_data})
self.assertEqual(response.status_code, 200)
self.assertEqual(Site.objects.count(), 3)
# Validate data for site 1
site1 = Site.objects.get(name='Site 1')
self.assertEqual(len(site1.custom_field_data), 8)
self.assertEqual(len(site1.custom_field_data), 9)
self.assertEqual(site1.custom_field_data['text'], 'ABC')
self.assertEqual(site1.custom_field_data['longtext'], 'Foo')
self.assertEqual(site1.custom_field_data['integer'], 123)
@ -628,10 +668,11 @@ class CustomFieldImportTest(TestCase):
self.assertEqual(site1.custom_field_data['url'], 'http://example.com/1')
self.assertEqual(site1.custom_field_data['json'], {"foo": 123})
self.assertEqual(site1.custom_field_data['select'], 'Choice A')
self.assertEqual(site1.custom_field_data['multiselect'], ['Choice A', 'Choice B'])
# Validate data for site 2
site2 = Site.objects.get(name='Site 2')
self.assertEqual(len(site2.custom_field_data), 8)
self.assertEqual(len(site2.custom_field_data), 9)
self.assertEqual(site2.custom_field_data['text'], 'DEF')
self.assertEqual(site2.custom_field_data['longtext'], 'Bar')
self.assertEqual(site2.custom_field_data['integer'], 456)
@ -640,6 +681,7 @@ class CustomFieldImportTest(TestCase):
self.assertEqual(site2.custom_field_data['url'], 'http://example.com/2')
self.assertEqual(site2.custom_field_data['json'], {"bar": 456})
self.assertEqual(site2.custom_field_data['select'], 'Choice B')
self.assertEqual(site2.custom_field_data['multiselect'], ['Choice B', 'Choice C'])
# No custom field data should be set for site 3
site3 = Site.objects.get(name='Site 3')

View File

@ -592,6 +592,8 @@ class FHRPGroupForm(CustomFieldModelForm):
return instance
def clean(self):
super().clean()
ip_vrf = self.cleaned_data.get('ip_vrf')
ip_address = self.cleaned_data.get('ip_address')
ip_status = self.cleaned_data.get('ip_status')

View File

@ -125,11 +125,30 @@ class ASN(PrimaryModel):
verbose_name_plural = 'ASNs'
def __str__(self):
return f'AS{self.asn}'
return f'AS{self.asn_with_asdot}'
def get_absolute_url(self):
return reverse('ipam:asn', args=[self.pk])
@property
def asn_asdot(self):
"""
Return ASDOT notation for AS numbers greater than 16 bits.
"""
if self.asn > 65535:
return f'{self.asn // 65536}.{self.asn % 65536}'
return self.asn
@property
def asn_with_asdot(self):
"""
Return both plain and ASDOT notation, where applicable.
"""
if self.asn > 65535:
return f'{self.asn} ({self.asn // 65536}.{self.asn % 65536})'
else:
return self.asn
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Aggregate(GetAvailablePrefixesMixin, PrimaryModel):

View File

@ -38,7 +38,7 @@ class FHRPGroupTable(BaseTable):
model = FHRPGroup
fields = (
'pk', 'group_id', 'protocol', 'auth_type', 'auth_key', 'description', 'ip_addresses', 'interface_count',
'tags',
'tags', 'created', 'last_updated',
)
default_columns = ('pk', 'group_id', 'protocol', 'auth_type', 'description', 'ip_addresses', 'interface_count')
@ -60,7 +60,7 @@ class FHRPGroupAssignmentTable(BaseTable):
)
actions = ButtonsColumn(
model=FHRPGroupAssignment,
buttons=('edit', 'delete', 'foo')
buttons=('edit', 'delete')
)
class Meta(BaseTable.Meta):

View File

@ -93,7 +93,10 @@ class RIRTable(BaseTable):
class Meta(BaseTable.Meta):
model = RIR
fields = ('pk', 'id', 'name', 'slug', 'is_private', 'aggregate_count', 'description', 'tags', 'actions')
fields = (
'pk', 'id', 'name', 'slug', 'is_private', 'aggregate_count', 'description', 'tags', 'actions', 'created',
'last_updated',
)
default_columns = ('pk', 'name', 'is_private', 'aggregate_count', 'description', 'actions')
@ -104,8 +107,10 @@ class RIRTable(BaseTable):
class ASNTable(BaseTable):
pk = ToggleColumn()
asn = tables.Column(
accessor=tables.A('asn_asdot'),
linkify=True
)
site_count = LinkedCountColumn(
viewname='dcim:site_list',
url_params={'asn_id': 'pk'},
@ -115,7 +120,7 @@ class ASNTable(BaseTable):
class Meta(BaseTable.Meta):
model = ASN
fields = ('pk', 'asn', 'rir', 'site_count', 'tenant', 'description', 'actions')
fields = ('pk', 'asn', 'rir', 'site_count', 'tenant', 'description', 'actions', 'created', 'last_updated',)
default_columns = ('pk', 'asn', 'rir', 'site_count', 'sites', 'tenant', 'actions')
@ -147,7 +152,10 @@ class AggregateTable(BaseTable):
class Meta(BaseTable.Meta):
model = Aggregate
fields = ('pk', 'id', 'prefix', 'rir', 'tenant', 'child_count', 'utilization', 'date_added', 'description', 'tags')
fields = (
'pk', 'id', 'prefix', 'rir', 'tenant', 'child_count', 'utilization', 'date_added', 'description', 'tags',
'created', 'last_updated',
)
default_columns = ('pk', 'prefix', 'rir', 'tenant', 'child_count', 'utilization', 'date_added', 'description')
@ -177,7 +185,10 @@ class RoleTable(BaseTable):
class Meta(BaseTable.Meta):
model = Role
fields = ('pk', 'id', 'name', 'slug', 'prefix_count', 'vlan_count', 'description', 'weight', 'tags', 'actions')
fields = (
'pk', 'id', 'name', 'slug', 'prefix_count', 'vlan_count', 'description', 'weight', 'tags', 'actions',
'created', 'last_updated',
)
default_columns = ('pk', 'name', 'prefix_count', 'vlan_count', 'description', 'actions')
@ -264,8 +275,8 @@ class PrefixTable(BaseTable):
class Meta(BaseTable.Meta):
model = Prefix
fields = (
'pk', 'id', 'prefix', 'prefix_flat', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan_group',
'vlan', 'role', 'is_pool', 'mark_utilized', 'description', 'tags',
'pk', 'id', 'prefix', 'prefix_flat', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site',
'vlan_group', 'vlan', 'role', 'is_pool', 'mark_utilized', 'description', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'prefix', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role', 'description',
@ -306,7 +317,7 @@ class IPRangeTable(BaseTable):
model = IPRange
fields = (
'pk', 'id', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'description',
'utilization', 'tags',
'utilization', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'start_address', 'end_address', 'size', 'vrf', 'status', 'role', 'tenant', 'description',
@ -364,7 +375,7 @@ class IPAddressTable(BaseTable):
model = IPAddress
fields = (
'pk', 'id', 'address', 'vrf', 'status', 'role', 'tenant', 'nat_inside', 'assigned', 'dns_name', 'description',
'tags',
'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'address', 'vrf', 'status', 'role', 'tenant', 'assigned', 'dns_name', 'description',

View File

@ -31,5 +31,8 @@ class ServiceTable(BaseTable):
class Meta(BaseTable.Meta):
model = Service
fields = ('pk', 'id', 'name', 'parent', 'protocol', 'ports', 'ipaddresses', 'description', 'tags')
fields = (
'pk', 'id', 'name', 'parent', 'protocol', 'ports', 'ipaddresses', 'description', 'tags', 'created',
'last_updated',
)
default_columns = ('pk', 'name', 'parent', 'protocol', 'ports', 'description')

View File

@ -84,7 +84,10 @@ class VLANGroupTable(BaseTable):
class Meta(BaseTable.Meta):
model = VLANGroup
fields = ('pk', 'id', 'name', 'scope_type', 'scope', 'vlan_count', 'slug', 'description', 'tags', 'actions')
fields = (
'pk', 'id', 'name', 'scope_type', 'scope', 'vlan_count', 'slug', 'description', 'tags', 'actions',
'created', 'last_updated',
)
default_columns = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'description', 'actions')
@ -125,7 +128,10 @@ class VLANTable(BaseTable):
class Meta(BaseTable.Meta):
model = VLAN
fields = ('pk', 'id', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'status', 'role', 'description', 'tags')
fields = (
'pk', 'id', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'status', 'role', 'description', 'tags',
'created', 'last_updated',
)
default_columns = ('pk', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'status', 'role', 'description')
row_attrs = {
'class': lambda record: 'success' if not isinstance(record, VLAN) else '',

View File

@ -47,7 +47,8 @@ class VRFTable(BaseTable):
class Meta(BaseTable.Meta):
model = VRF
fields = (
'pk', 'id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'import_targets', 'export_targets', 'tags',
'pk', 'id', 'name', 'rd', 'tenant', 'enforce_unique', 'description', 'import_targets', 'export_targets',
'tags', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'rd', 'tenant', 'description')
@ -68,5 +69,5 @@ class RouteTargetTable(BaseTable):
class Meta(BaseTable.Meta):
model = RouteTarget
fields = ('pk', 'id', 'name', 'tenant', 'description', 'tags')
fields = ('pk', 'id', 'name', 'tenant', 'description', 'tags', 'created', 'last_updated',)
default_columns = ('pk', 'name', 'tenant', 'description')

View File

@ -19,7 +19,7 @@ from netbox.config import PARAMS
# Environment setup
#
VERSION = '3.1.5'
VERSION = '3.1.6'
# Hostname
HOSTNAME = platform.node()

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -597,6 +597,10 @@ span.color-label {
box-shadow: $box-shadow-sm;
}
.badge a {
color: inherit;
}
.btn {
white-space: nowrap;
}

View File

@ -223,11 +223,6 @@
font-weight: $font-weight-bold;
color: var(--nbx-sidenav-parent-color);
&.active {
color: $accordion-button-active-color;
background: $accordion-button-active-bg;
}
&:after {
display: inline-block;
margin-left: auto;
@ -284,7 +279,7 @@
font-size: $font-size-sm;
color: var(--nbx-sidenav-link-color);
white-space: nowrap;
transition: $transition-100ms-ease-in-out;
transition-duration: 0ms;
&.active {
background-color: var(--nbx-sidebar-link-active-bg);

View File

@ -5,7 +5,7 @@
--nbx-sidebar-bg: #{$gray-200};
--nbx-sidebar-scroll: #{$gray-500};
--nbx-sidebar-link-hover-bg: #{rgba($gray-600, 0.15)};
--nbx-sidebar-link-active-bg: #{$blue-100};
--nbx-sidebar-link-active-bg: #9cc8f8;
--nbx-sidebar-title-color: #{$text-muted};
--nbx-sidebar-shadow: inset 0px -25px 20px -25px rgba(0, 0, 0, 0.25);
--nbx-breadcrumb-bg: #{$light};

View File

@ -45,5 +45,6 @@
{% endblock %}
{% block modals %}
{{ block.super }}
{% table_config_form table %}
{% endblock modals %}

View File

@ -45,5 +45,6 @@
{% endblock %}
{% block modals %}
{{ block.super }}
{% table_config_form table %}
{% endblock modals %}

View File

@ -42,5 +42,6 @@
{% endblock %}
{% block modals %}
{{ block.super }}
{% table_config_form table %}
{% endblock modals %}

View File

@ -45,5 +45,6 @@
{% endblock %}
{% block modals %}
{{ block.super }}
{% table_config_form table %}
{% endblock modals %}

View File

@ -80,5 +80,6 @@
{% endblock %}
{% block modals %}
{{ block.super }}
{% table_config_form table %}
{% endblock modals %}

View File

@ -42,5 +42,6 @@
{% endblock %}
{% block modals %}
{{ block.super }}
{% table_config_form table %}
{% endblock modals %}

View File

@ -45,5 +45,6 @@
{% endblock %}
{% block modals %}
{{ block.super }}
{% table_config_form table %}
{% endblock modals %}

View File

@ -45,5 +45,6 @@
{% endblock %}
{% block modals %}
{{ block.super }}
{% table_config_form table %}
{% endblock modals %}

View File

@ -45,5 +45,6 @@
{% endblock %}
{% block modals %}
{{ block.super }}
{% table_config_form table %}
{% endblock modals %}

View File

@ -24,17 +24,17 @@
{% else %}
{# List all non-customfield filters as declared in the form class #}
{% for field in filter_form.visible_fields %}
{% if not filter_form.custom_field_filters or field.name not in filter_form.custom_field_filters %}
{% if not filter_form.custom_fields or field.name not in filter_form.custom_fields %}
<div class="col col-12">
{% render_field field %}
</div>
{% endif %}
{% endfor %}
{% endif %}
{% if filter_form.custom_field_filters %}
{% if filter_form.custom_fields %}
{# List all custom field filters #}
<hr class="card-divider mt-0" />
{% for name in filter_form.custom_field_filters %}
{% for name in filter_form.custom_fields %}
<div class="col col-12">
{% with field=filter_form|get_item:name %}
{% render_field field %}

View File

@ -38,7 +38,7 @@
</div>
{% else %}
<div class="btn-group">
<a class="btn btn-primary ws-nowrap" type="button" href="{% url 'login' %}">
<a class="btn btn-primary ws-nowrap" type="button" href="{% url 'login' %}?next={{ request.path }}">
<i class="mdi mdi-login-variant"></i> Log In
</a>
<button type="button" class="btn btn-primary dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown">

View File

@ -40,5 +40,6 @@
{% endblock %}
{% block modals %}
{{ block.super }}
{% table_config_form table %}
{% endblock modals %}

View File

@ -18,7 +18,7 @@
<table class="table table-hover attr-table">
<tr>
<td>AS Number</td>
<td>{{ object.asn }}</td>
<td>{{ object.asn_with_asdot }}</td>
</tr>
<tr>
<td>RIR</td>

View File

@ -38,5 +38,6 @@
{% endblock %}
{% block modals %}
{{ block.super }}
{% table_config_form table %}
{% endblock modals %}

View File

@ -38,5 +38,6 @@
{% endblock %}
{% block modals %}
{{ block.super }}
{% table_config_form table %}
{% endblock modals %}

View File

@ -38,5 +38,6 @@
{% endblock %}
{% block modals %}
{{ block.super }}
{% table_config_form table %}
{% endblock modals %}

View File

@ -40,5 +40,6 @@
{% endblock %}
{% block modals %}
{{ block.super }}
{% table_config_form table %}
{% endblock modals %}

View File

@ -14,5 +14,6 @@
{% endblock content %}
{% block modals %}
{{ block.super }}
{% table_config_form table %}
{% endblock modals %}

View File

@ -14,5 +14,6 @@
{% endblock content %}
{% block modals %}
{{ block.super }}
{% table_config_form table %}
{% endblock modals %}

View File

@ -95,6 +95,10 @@
<h2><a href="{% url 'virtualization:cluster_list' %}?tenant_id={{ object.pk }}" class="stat-btn btn {% if stats.cluster_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.cluster_count }}</a></h2>
<p>Clusters</p>
</div>
<div class="col col-md-4 text-center">
<h2><a href="{% url 'dcim:cable_list' %}?tenant_id={{ object.pk }}" class="stat-btn btn {% if stats.cable_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.cable_count }}</a></h2>
<p>Cables</p>
</div>
</div>
</div>
{% plugin_right_page object %}

View File

@ -24,5 +24,6 @@
{% endblock content %}
{% block modals %}
{{ block.super }}
{% table_config_form table %}
{% endblock modals %}

View File

@ -29,5 +29,6 @@
{% endblock content %}
{% block modals %}
{{ block.super }}
{% table_config_form table %}
{% endblock modals %}

View File

@ -40,5 +40,6 @@
{% endblock content %}
{% block modals %}
{{ block.super }}
{% table_config_form table %}
{% endblock modals %}

View File

@ -1,13 +1,13 @@
from rest_framework.routers import APIRootView
from circuits.models import Circuit
from dcim.models import Device, Rack, Site
from dcim.models import Device, Rack, Site, Cable
from extras.api.views import CustomFieldModelViewSet
from ipam.models import IPAddress, Prefix, VLAN, VRF
from tenancy import filtersets
from tenancy.models import *
from utilities.utils import count_related
from virtualization.models import VirtualMachine
from virtualization.models import VirtualMachine, Cluster
from . import serializers
@ -47,7 +47,8 @@ class TenantViewSet(CustomFieldModelViewSet):
site_count=count_related(Site, 'tenant'),
virtualmachine_count=count_related(VirtualMachine, 'tenant'),
vlan_count=count_related(VLAN, 'tenant'),
vrf_count=count_related(VRF, 'tenant')
vrf_count=count_related(VRF, 'tenant'),
cluster_count=count_related(Cluster, 'tenant')
)
serializer_class = serializers.TenantSerializer
filterset_class = filtersets.TenantFilterSet

View File

@ -63,7 +63,9 @@ class TenantGroupTable(BaseTable):
class Meta(BaseTable.Meta):
model = TenantGroup
fields = ('pk', 'id', 'name', 'tenant_count', 'description', 'slug', 'tags', 'actions')
fields = (
'pk', 'id', 'name', 'tenant_count', 'description', 'slug', 'tags', 'actions', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'tenant_count', 'description', 'actions')
@ -82,7 +84,7 @@ class TenantTable(BaseTable):
class Meta(BaseTable.Meta):
model = Tenant
fields = ('pk', 'id', 'name', 'slug', 'group', 'description', 'comments', 'tags')
fields = ('pk', 'id', 'name', 'slug', 'group', 'description', 'comments', 'tags', 'created', 'last_updated',)
default_columns = ('pk', 'name', 'group', 'description')
@ -107,7 +109,7 @@ class ContactGroupTable(BaseTable):
class Meta(BaseTable.Meta):
model = ContactGroup
fields = ('pk', 'name', 'contact_count', 'description', 'slug', 'tags', 'actions')
fields = ('pk', 'name', 'contact_count', 'description', 'slug', 'tags', 'actions', 'created', 'last_updated',)
default_columns = ('pk', 'name', 'contact_count', 'description', 'actions')
@ -120,7 +122,7 @@ class ContactRoleTable(BaseTable):
class Meta(BaseTable.Meta):
model = ContactRole
fields = ('pk', 'name', 'description', 'slug', 'actions')
fields = ('pk', 'name', 'description', 'slug', 'actions', 'created', 'last_updated',)
default_columns = ('pk', 'name', 'description', 'actions')
@ -145,7 +147,10 @@ class ContactTable(BaseTable):
class Meta(BaseTable.Meta):
model = Contact
fields = ('pk', 'name', 'group', 'title', 'phone', 'email', 'address', 'comments', 'assignment_count', 'tags')
fields = (
'pk', 'name', 'group', 'title', 'phone', 'email', 'address', 'comments', 'assignment_count', 'tags',
'created', 'last_updated',
)
default_columns = ('pk', 'name', 'group', 'assignment_count', 'title', 'phone', 'email')

View File

@ -3,7 +3,7 @@ from django.http import Http404
from django.shortcuts import get_object_or_404
from circuits.models import Circuit
from dcim.models import Site, Rack, Device, RackReservation
from dcim.models import Site, Rack, Device, RackReservation, Cable
from ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF
from netbox.views import generic
from utilities.tables import paginate_table
@ -112,6 +112,7 @@ class TenantView(generic.ObjectView):
'circuit_count': Circuit.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
'virtualmachine_count': VirtualMachine.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
'cluster_count': Cluster.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
'cable_count': Cable.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
}
return {

View File

@ -31,6 +31,7 @@ __all__ = (
'CSVDataField',
'CSVFileField',
'CSVModelChoiceField',
'CSVMultipleChoiceField',
'CSVMultipleContentTypeField',
'CSVTypedChoiceField',
'DynamicModelChoiceField',
@ -263,10 +264,7 @@ class CSVFileField(forms.FileField):
return value
class CSVChoiceField(forms.ChoiceField):
"""
Invert the provided set of choices to take the human-friendly label as input, and return the database value.
"""
class CSVChoicesMixin:
STATIC_CHOICES = True
def __init__(self, *, choices=(), **kwargs):
@ -274,6 +272,25 @@ class CSVChoiceField(forms.ChoiceField):
self.choices = unpack_grouped_choices(choices)
class CSVChoiceField(CSVChoicesMixin, forms.ChoiceField):
"""
A CSV field which accepts a single selection value.
"""
pass
class CSVMultipleChoiceField(CSVChoicesMixin, forms.MultipleChoiceField):
"""
A CSV field which accepts multiple selection values.
"""
def to_python(self, value):
if not value:
return []
if not isinstance(value, str):
raise forms.ValidationError(f"Invalid value for a multiple choice field: {value}")
return value.split(',')
class CSVTypedChoiceField(forms.TypedChoiceField):
STATIC_CHOICES = True

View File

@ -3,7 +3,6 @@ import re
import yaml
from django import forms
from django.utils.translation import gettext as _
from .widgets import APISelect, APISelectMultiple, ClearableFileInput, StaticSelect
@ -11,6 +10,7 @@ from .widgets import APISelect, APISelectMultiple, ClearableFileInput, StaticSel
__all__ = (
'BootstrapMixin',
'BulkEditForm',
'BulkEditBaseForm',
'BulkRenameForm',
'ConfirmationForm',
'CSVModelForm',
@ -75,11 +75,10 @@ class ConfirmationForm(BootstrapMixin, ReturnURLForm):
confirm = forms.BooleanField(required=True, widget=forms.HiddenInput(), initial=True)
class BulkEditForm(BootstrapMixin, forms.Form):
class BulkEditBaseForm(forms.Form):
"""
Base form for editing multiple objects in bulk
"""
def __init__(self, model, *args, **kwargs):
super().__init__(*args, **kwargs)
self.model = model
@ -90,6 +89,10 @@ class BulkEditForm(BootstrapMixin, forms.Form):
self.nullable_fields = self.Meta.nullable_fields
class BulkEditForm(BootstrapMixin, BulkEditBaseForm):
pass
class BulkRenameForm(BootstrapMixin, forms.Form):
"""
An extendable form to be used for renaming objects in bulk.
@ -185,10 +188,7 @@ class FilterForm(BootstrapMixin, forms.Form):
"""
q = forms.CharField(
required=False,
widget=forms.TextInput(
attrs={'placeholder': _('All fields')}
),
label=_('Search')
label='Search'
)

View File

@ -330,15 +330,15 @@ class ColoredLabelColumn(tables.TemplateColumn):
Render a colored label (e.g. for DeviceRoles).
"""
template_code = """
{% load helpers %}
{% if value %}
<span class="badge" style="color: {{ value.color|fgcolor }}; background-color: #{{ value.color }}">
{{ value }}
</span>
{% else %}
&mdash;
{% endif %}
"""
{% load helpers %}
{% if value %}
<span class="badge" style="color: {{ value.color|fgcolor }}; background-color: #{{ value.color }}">
<a href="{{ value.get_absolute_url }}">{{ value }}</a>
</span>
{% else %}
&mdash;
{% endif %}
"""
def __init__(self, *args, **kwargs):
super().__init__(template_code=self.template_code, *args, **kwargs)

View File

@ -20,11 +20,13 @@ class EnhancedURLValidator(URLValidator):
r'(?::\d{2,5})?' # Port number
r'(?:[/?#][^\s]*)?' # Path
r'\Z', re.IGNORECASE)
schemes = None
def __init__(self, schemes=None, **kwargs):
super().__init__(**kwargs)
if schemes is not None:
def __call__(self, value):
if self.schemes is None:
# We can't load the allowed schemes until the configuration has been initialized
self.schemes = get_config().ALLOWED_URL_SCHEMES
return super().__call__(value)
class ExclusionValidator(BaseValidator):

View File

@ -44,7 +44,9 @@ class ClusterTypeTable(BaseTable):
class Meta(BaseTable.Meta):
model = ClusterType
fields = ('pk', 'id', 'name', 'slug', 'cluster_count', 'description', 'tags', 'actions')
fields = (
'pk', 'id', 'name', 'slug', 'cluster_count', 'description', 'tags', 'actions', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'cluster_count', 'description', 'actions')
@ -67,7 +69,9 @@ class ClusterGroupTable(BaseTable):
class Meta(BaseTable.Meta):
model = ClusterGroup
fields = ('pk', 'id', 'name', 'slug', 'cluster_count', 'description', 'tags', 'actions')
fields = (
'pk', 'id', 'name', 'slug', 'cluster_count', 'description', 'tags', 'actions', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'cluster_count', 'description', 'actions')
@ -109,7 +113,10 @@ class ClusterTable(BaseTable):
class Meta(BaseTable.Meta):
model = Cluster
fields = ('pk', 'id', 'name', 'type', 'group', 'tenant', 'site', 'comments', 'device_count', 'vm_count', 'tags')
fields = (
'pk', 'id', 'name', 'type', 'group', 'tenant', 'site', 'comments', 'device_count', 'vm_count', 'tags',
'created', 'last_updated',
)
default_columns = ('pk', 'name', 'type', 'group', 'tenant', 'site', 'device_count', 'vm_count')
@ -150,8 +157,8 @@ class VirtualMachineTable(BaseTable):
class Meta(BaseTable.Meta):
model = VirtualMachine
fields = (
'pk', 'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk', 'primary_ip4',
'primary_ip6', 'primary_ip', 'comments', 'tags',
'pk', 'id', 'name', 'status', 'cluster', 'role', 'tenant', 'platform', 'vcpus', 'memory', 'disk',
'primary_ip4', 'primary_ip6', 'primary_ip', 'comments', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'status', 'cluster', 'role', 'tenant', 'vcpus', 'memory', 'disk', 'primary_ip',
@ -178,7 +185,7 @@ class VMInterfaceTable(BaseInterfaceTable):
model = VMInterface
fields = (
'pk', 'id', 'name', 'virtual_machine', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'tags',
'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans',
'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'virtual_machine', 'enabled', 'description')

View File

@ -30,7 +30,9 @@ class WirelessLANGroupTable(BaseTable):
class Meta(BaseTable.Meta):
model = WirelessLANGroup
fields = ('pk', 'name', 'wirelesslan_count', 'description', 'slug', 'tags', 'actions')
fields = (
'pk', 'name', 'wirelesslan_count', 'description', 'slug', 'tags', 'actions', 'created', 'last_updated',
)
default_columns = ('pk', 'name', 'wirelesslan_count', 'description', 'actions')
@ -53,7 +55,7 @@ class WirelessLANTable(BaseTable):
model = WirelessLAN
fields = (
'pk', 'ssid', 'group', 'description', 'vlan', 'interface_count', 'auth_type', 'auth_cipher', 'auth_psk',
'tags',
'tags', 'created', 'last_updated',
)
default_columns = ('pk', 'ssid', 'group', 'description', 'vlan', 'auth_type', 'interface_count')
@ -102,7 +104,7 @@ class WirelessLinkTable(BaseTable):
model = WirelessLink
fields = (
'pk', 'id', 'status', 'device_a', 'interface_a', 'device_b', 'interface_b', 'ssid', 'description',
'auth_type', 'auth_cipher', 'auth_psk', 'tags',
'auth_type', 'auth_cipher', 'auth_psk', 'tags', 'created', 'last_updated',
)
default_columns = (
'pk', 'id', 'status', 'device_a', 'interface_a', 'device_b', 'interface_b', 'ssid', 'auth_type',

View File

@ -1,5 +1,5 @@
Django==3.2.11
django-cors-headers==3.10.1
django-cors-headers==3.11.0
django-debug-toolbar==3.2.4
django-filter==21.1
django-graphiql-debug-toolbar==0.2.0
@ -10,7 +10,7 @@ django-redis==5.2.0
django-rq==2.5.1
django-tables2==2.4.1
django-taggit==2.0.0
django-timezone-field==4.2.1
django-timezone-field==4.2.3
djangorestframework==3.12.4
drf-yasg[validation]==1.20.0
graphene_django==2.15.0
@ -18,7 +18,7 @@ gunicorn==20.1.0
Jinja2==3.0.3
Markdown==3.3.6
markdown-include==0.6.0
mkdocs-material==8.1.4
mkdocs-material==8.1.7
netaddr==0.8.0
Pillow==8.4.0
psycopg2-binary==2.9.3