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: attributes:
label: NetBox version label: NetBox version
description: What version of NetBox are you currently running? description: What version of NetBox are you currently running?
placeholder: v3.1.5 placeholder: v3.1.6
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

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

View File

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

View File

@ -114,6 +114,12 @@ This ensures that your development environment is now complete and operational.
!!! info "IDE Integration" !!! 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. 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 ## 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. 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 | | Application | Django/Python |
| Database | PostgreSQL 10+ | | Database | PostgreSQL 10+ |
| Task queuing | Redis/django-rq | | Task queuing | Redis/django-rq |
| Live device access | NAPALM | | Live device access | NAPALM (optional) |
## Supported Python Versions ## Supported Python Versions
@ -58,4 +58,6 @@ NetBox supports Python 3.7, 3.8, and 3.9 environments currently. (Support for Py
## Getting Started ## 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 # 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. 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: Plugins can do a lot, including:

View File

@ -1,6 +1,14 @@
# Release Notes # 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) #### [Version 3.1](./version-3.1.md) (December 2021)

View File

@ -1,5 +1,33 @@
# NetBox v3.1 # 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) ## v3.1.5 (2022-01-06)
### Enhancements ### Enhancements

View File

@ -100,5 +100,5 @@ class CircuitTerminationSerializer(ValidatedModelSerializer, LinkTerminationSeri
fields = [ fields = [
'id', 'url', 'display', 'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed', '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', '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 %} {% 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 # Providers
# #
class ProviderTable(BaseTable): class ProviderTable(BaseTable):
pk = ToggleColumn() pk = ToggleColumn()
name = tables.Column( name = tables.Column(
@ -45,7 +66,7 @@ class ProviderTable(BaseTable):
model = Provider model = Provider
fields = ( fields = (
'pk', 'id', 'name', 'asn', 'account', 'portal_url', 'noc_contact', 'admin_contact', 'circuit_count', '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') default_columns = ('pk', 'name', 'asn', 'account', 'circuit_count')
@ -69,7 +90,7 @@ class ProviderNetworkTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = ProviderNetwork 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') default_columns = ('pk', 'name', 'provider', 'description')
@ -92,7 +113,7 @@ class CircuitTypeTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = CircuitType 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') default_columns = ('pk', 'name', 'circuit_count', 'description', 'slug', 'actions')
@ -119,6 +140,7 @@ class CircuitTable(BaseTable):
template_code=CIRCUITTERMINATION_LINK, template_code=CIRCUITTERMINATION_LINK,
verbose_name='Side Z' verbose_name='Side Z'
) )
commit_rate = CommitRateColumn()
comments = MarkdownColumn() comments = MarkdownColumn()
tags = TagColumn( tags = TagColumn(
url_name='circuits:circuit_list' url_name='circuits:circuit_list'
@ -128,7 +150,7 @@ class CircuitTable(BaseTable):
model = Circuit model = Circuit
fields = ( fields = (
'pk', 'id', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'install_date', '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 = ( default_columns = (
'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'description', 'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'description',

View File

@ -219,7 +219,7 @@ class RackReservationSerializer(PrimaryModelSerializer):
class Meta: class Meta:
model = RackReservation model = RackReservation
fields = [ 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', 'custom_fields',
] ]
@ -762,7 +762,7 @@ class CableSerializer(PrimaryModelSerializer):
fields = [ fields = [
'id', 'url', 'display', 'termination_a_type', 'termination_a_id', 'termination_a', 'termination_b_type', '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', '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): def _get_termination(self, obj, side):
@ -856,7 +856,10 @@ class VirtualChassisSerializer(PrimaryModelSerializer):
class Meta: class Meta:
model = VirtualChassis 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: class Meta:
model = PowerPanel 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): class PowerFeedSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):

View File

@ -816,6 +816,10 @@ class InterfaceTypeChoices(ChoiceSet):
TYPE_STACKWISE_PLUS = 'cisco-stackwise-plus' TYPE_STACKWISE_PLUS = 'cisco-stackwise-plus'
TYPE_FLEXSTACK = 'cisco-flexstack' TYPE_FLEXSTACK = 'cisco-flexstack'
TYPE_FLEXSTACK_PLUS = 'cisco-flexstack-plus' 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_JUNIPER_VCP = 'juniper-vcp'
TYPE_SUMMITSTACK = 'extreme-summitstack' TYPE_SUMMITSTACK = 'extreme-summitstack'
TYPE_SUMMITSTACK128 = 'extreme-summitstack-128' TYPE_SUMMITSTACK128 = 'extreme-summitstack-128'
@ -950,6 +954,10 @@ class InterfaceTypeChoices(ChoiceSet):
(TYPE_STACKWISE_PLUS, 'Cisco StackWise Plus'), (TYPE_STACKWISE_PLUS, 'Cisco StackWise Plus'),
(TYPE_FLEXSTACK, 'Cisco FlexStack'), (TYPE_FLEXSTACK, 'Cisco FlexStack'),
(TYPE_FLEXSTACK_PLUS, 'Cisco FlexStack Plus'), (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_JUNIPER_VCP, 'Juniper VCP'),
(TYPE_SUMMITSTACK, 'Extreme SummitStack'), (TYPE_SUMMITSTACK, 'Extreme SummitStack'),
(TYPE_SUMMITSTACK128, 'Extreme SummitStack-128'), (TYPE_SUMMITSTACK128, 'Extreme SummitStack-128'),

View File

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

View File

@ -19,7 +19,12 @@ __all__ = (
def get_device_name(device): 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: class RackElevationSVG:

View File

@ -56,7 +56,7 @@ class CableTable(BaseTable):
model = Cable model = Cable
fields = ( fields = (
'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b', '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 = ( default_columns = (
'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b', 'pk', 'id', 'label', 'termination_a_parent', 'termination_a', 'termination_b_parent', 'termination_b',

View File

@ -97,7 +97,7 @@ class DeviceRoleTable(BaseTable):
model = DeviceRole model = DeviceRole
fields = ( fields = (
'pk', 'id', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'slug', 'tags', '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') default_columns = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description', 'actions')
@ -130,7 +130,7 @@ class PlatformTable(BaseTable):
model = Platform model = Platform
fields = ( fields = (
'pk', 'id', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'napalm_driver', 'napalm_args', 'pk', 'id', 'name', 'manufacturer', 'device_count', 'vm_count', 'slug', 'napalm_driver', 'napalm_args',
'description', 'tags', 'actions', 'description', 'tags', 'actions', 'created', 'last_updated',
) )
default_columns = ( default_columns = (
'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'napalm_driver', 'description', 'actions', 'pk', 'name', 'manufacturer', 'device_count', 'vm_count', 'napalm_driver', 'description', 'actions',
@ -204,7 +204,8 @@ class DeviceTable(BaseTable):
fields = ( fields = (
'pk', 'id', 'name', 'status', 'tenant', 'device_role', 'manufacturer', 'device_type', 'platform', 'serial', 'pk', 'id', 'name', 'status', 'tenant', 'device_role', 'manufacturer', 'device_type', 'platform', 'serial',
'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'primary_ip', 'airflow', 'primary_ip4', '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 = ( default_columns = (
'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type', 'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type',
@ -297,7 +298,7 @@ class ConsolePortTable(DeviceComponentTable, PathEndpointTable):
model = ConsolePort model = ConsolePort
fields = ( fields = (
'pk', 'id', 'name', 'device', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color', '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') default_columns = ('pk', 'name', 'device', 'label', 'type', 'speed', 'description')
@ -341,7 +342,7 @@ class ConsoleServerPortTable(DeviceComponentTable, PathEndpointTable):
model = ConsoleServerPort model = ConsoleServerPort
fields = ( fields = (
'pk', 'id', 'name', 'device', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', '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') default_columns = ('pk', 'name', 'device', 'label', 'type', 'speed', 'description')
@ -386,7 +387,7 @@ class PowerPortTable(DeviceComponentTable, PathEndpointTable):
model = PowerPort model = PowerPort
fields = ( fields = (
'pk', 'id', 'name', 'device', 'label', 'type', 'description', 'mark_connected', 'maximum_draw', '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') default_columns = ('pk', 'name', 'device', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description')
@ -437,7 +438,7 @@ class PowerOutletTable(DeviceComponentTable, PathEndpointTable):
model = PowerOutlet model = PowerOutlet
fields = ( fields = (
'pk', 'id', 'name', 'device', 'label', 'type', 'description', 'power_port', 'feed_leg', 'mark_connected', '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') 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', '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', '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', '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') default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')
@ -586,7 +587,7 @@ class FrontPortTable(DeviceComponentTable, CableTerminationTable):
model = FrontPort model = FrontPort
fields = ( fields = (
'pk', 'id', 'name', 'device', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description', '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 = ( default_columns = (
'pk', 'name', 'device', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description', 'pk', 'name', 'device', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description',
@ -637,7 +638,7 @@ class RearPortTable(DeviceComponentTable, CableTerminationTable):
model = RearPort model = RearPort
fields = ( fields = (
'pk', 'id', 'name', 'device', 'label', 'type', 'color', 'positions', 'description', 'mark_connected', 'cable', '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') default_columns = ('pk', 'name', 'device', 'label', 'type', 'color', 'description')
@ -689,7 +690,11 @@ class DeviceBayTable(DeviceComponentTable):
class Meta(DeviceComponentTable.Meta): class Meta(DeviceComponentTable.Meta):
model = DeviceBay 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') default_columns = ('pk', 'name', 'device', 'label', 'status', 'installed_device', 'description')
@ -736,7 +741,7 @@ class InventoryItemTable(DeviceComponentTable):
model = InventoryItem model = InventoryItem
fields = ( fields = (
'pk', 'id', 'name', 'device', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', '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') default_columns = ('pk', 'name', 'device', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag')
@ -788,5 +793,5 @@ class VirtualChassisTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = VirtualChassis 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') default_columns = ('pk', 'name', 'domain', 'master', 'member_count')

View File

@ -50,7 +50,7 @@ class ManufacturerTable(BaseTable):
model = Manufacturer model = Manufacturer
fields = ( fields = (
'pk', 'id', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', 'pk', 'id', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug',
'actions', 'actions', 'created', 'last_updated',
) )
default_columns = ( default_columns = (
'pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', 'actions', 'pk', 'name', 'devicetype_count', 'inventoryitem_count', 'platform_count', 'description', 'slug', 'actions',
@ -84,7 +84,7 @@ class DeviceTypeTable(BaseTable):
model = DeviceType model = DeviceType
fields = ( fields = (
'pk', 'id', 'model', 'manufacturer', 'slug', 'part_number', 'u_height', 'is_full_depth', 'subdevice_role', '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 = ( default_columns = (
'pk', 'model', 'manufacturer', 'part_number', 'u_height', 'is_full_depth', 'instance_count', '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): class Meta(BaseTable.Meta):
model = PowerPanel 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') default_columns = ('pk', 'name', 'site', 'location', 'powerfeed_count')
@ -72,7 +72,7 @@ class PowerFeedTable(CableTerminationTable):
fields = ( fields = (
'pk', 'id', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'pk', 'id', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase',
'max_utilization', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'available_power', 'max_utilization', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'available_power',
'comments', 'tags', 'comments', 'tags', 'created', 'last_updated',
) )
default_columns = ( default_columns = (
'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'cable', '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): class Meta(BaseTable.Meta):
model = RackRole 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') default_columns = ('pk', 'name', 'rack_count', 'color', 'description', 'actions')
@ -87,8 +90,9 @@ class RackTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = Rack model = Rack
fields = ( fields = (
'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag', 'type', 'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'serial', 'asset_tag',
'width', 'outer_width', 'outer_depth', 'u_height', 'comments', 'device_count', 'get_utilization', 'get_power_utilization', 'tags', 'type', 'width', 'outer_width', 'outer_depth', 'u_height', 'comments', 'device_count', 'get_utilization',
'get_power_utilization', 'tags', 'created', 'last_updated',
) )
default_columns = ( default_columns = (
'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count', 'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count',
@ -127,7 +131,7 @@ class RackReservationTable(BaseTable):
model = RackReservation model = RackReservation
fields = ( fields = (
'pk', 'id', 'reservation', 'site', 'rack', 'unit_list', 'user', 'created', 'tenant', 'description', 'tags', 'pk', 'id', 'reservation', 'site', 'rack', 'unit_list', 'user', 'created', 'tenant', 'description', 'tags',
'actions', 'actions', 'created', 'last_updated',
) )
default_columns = ( default_columns = (
'pk', 'reservation', 'site', 'rack', 'unit_list', 'user', 'description', 'actions', 'pk', 'reservation', 'site', 'rack', 'unit_list', 'user', 'description', 'actions',

View File

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

View File

@ -61,7 +61,7 @@ class WebhookSerializer(ValidatedModelSerializer):
fields = [ fields = [
'id', 'url', 'display', 'content_types', 'name', 'type_create', 'type_update', 'type_delete', 'payload_url', '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', '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 model = CustomField
fields = [ fields = [
'id', 'url', 'display', 'content_types', 'type', 'name', 'label', 'description', 'required', 'filter_logic', '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 model = CustomLink
fields = [ fields = [
'id', 'url', 'display', 'content_type', 'name', 'link_text', 'link_url', 'weight', 'group_name', '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 model = ExportTemplate
fields = [ fields = [
'id', 'url', 'display', 'content_type', 'name', 'description', 'template_code', 'mime_type', '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: class Meta:
model = Tag 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.choices import *
from extras.models import * from extras.models import *
from utilities.forms import BootstrapMixin, BulkEditForm, CSVModelForm, FilterForm from utilities.forms import BootstrapMixin, BulkEditBaseForm, CSVModelForm
__all__ = ( __all__ = (
'CustomFieldModelCSVForm', 'CustomFieldModelCSVForm',
@ -34,6 +34,9 @@ class CustomFieldsMixin:
raise NotImplementedError(f"{self.__class__.__name__} must specify a model class.") raise NotImplementedError(f"{self.__class__.__name__} must specify a model class.")
return ContentType.objects.get_for_model(self.model) 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): def _get_form_field(self, customfield):
return customfield.to_form_field() return customfield.to_form_field()
@ -41,10 +44,7 @@ class CustomFieldsMixin:
""" """
Append form fields for all CustomFields assigned to this object type. Append form fields for all CustomFields assigned to this object type.
""" """
content_type = self._get_content_type() for customfield in self._get_custom_fields(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):
field_name = f'cf_{customfield.name}' field_name = f'cf_{customfield.name}'
self.fields[field_name] = self._get_form_field(customfield) 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) return customfield.to_form_field(for_csv_import=True)
class CustomFieldModelBulkEditForm(BulkEditForm): class CustomFieldModelBulkEditForm(BootstrapMixin, CustomFieldsMixin, BulkEditBaseForm):
def __init__(self, *args, **kwargs): def _get_form_field(self, customfield):
super().__init__(*args, **kwargs) return customfield.to_form_field(set_initial=False, enforce_required=False)
self.custom_fields = [] def _append_customfield_fields(self):
self.obj_type = ContentType.objects.get_for_model(self.model) """
Append form fields for all CustomFields assigned to this object type.
# Add all applicable CustomFields to the form """
custom_fields = CustomField.objects.filter(content_types=self.obj_type) for customfield in self._get_custom_fields(self._get_content_type()):
for cf in custom_fields:
# Annotate non-required custom fields as nullable # Annotate non-required custom fields as nullable
if not cf.required: if not customfield.required:
self.nullable_fields.append(cf.name) self.nullable_fields.append(customfield.name)
self.fields[cf.name] = cf.to_form_field(set_initial=False, enforce_required=False)
# Annotate this as a custom field self.fields[customfield.name] = self._get_form_field(customfield)
self.custom_fields.append(cf.name)
# 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): def _get_custom_fields(self, content_type):
return CustomField.objects.filter(content_types=content_type).exclude(
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(
Q(filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED) | Q(filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED) |
Q(type=CustomFieldTypeChoices.TYPE_JSON) Q(type=CustomFieldTypeChoices.TYPE_JSON)
) )
for cf in custom_fields:
field_name = f'cf_{cf.name}' def _get_form_field(self, customfield):
self.fields[field_name] = cf.to_form_field(set_initial=True, enforce_required=False) return customfield.to_form_field(set_initial=False, enforce_required=False)
self.custom_field_filters.append(field_name)

View File

@ -82,7 +82,7 @@ class ExportTemplateForm(BootstrapMixin, forms.ModelForm):
model = ExportTemplate model = ExportTemplate
fields = '__all__' fields = '__all__'
fieldsets = ( fieldsets = (
('Custom Link', ('name', 'content_type', 'description')), ('Export Template', ('name', 'content_type', 'description')),
('Template', ('template_code',)), ('Template', ('template_code',)),
('Rendering', ('mime_type', 'file_extension', 'as_attachment')), ('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 netbox.models import ChangeLoggedModel
from utilities import filters from utilities import filters
from utilities.forms import ( 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.querysets import RestrictedQuerySet
from utilities.validators import validate_regex 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. 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. 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. 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() choices=choices, required=required, initial=initial, widget=StaticSelect()
) )
else: else:
field_class = CSVChoiceField if for_csv_import else forms.MultipleChoiceField field_class = CSVMultipleChoiceField if for_csv_import else forms.MultipleChoiceField
field = field_class( field = field_class(
choices=choices, required=required, initial=initial, widget=StaticSelectMultiple() choices=choices, required=required, initial=initial, widget=StaticSelectMultiple()
) )

View File

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

View File

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

View File

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

View File

@ -125,11 +125,30 @@ class ASN(PrimaryModel):
verbose_name_plural = 'ASNs' verbose_name_plural = 'ASNs'
def __str__(self): def __str__(self):
return f'AS{self.asn}' return f'AS{self.asn_with_asdot}'
def get_absolute_url(self): def get_absolute_url(self):
return reverse('ipam:asn', args=[self.pk]) 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') @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
class Aggregate(GetAvailablePrefixesMixin, PrimaryModel): class Aggregate(GetAvailablePrefixesMixin, PrimaryModel):

View File

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

View File

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

View File

@ -31,5 +31,8 @@ class ServiceTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = Service 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') default_columns = ('pk', 'name', 'parent', 'protocol', 'ports', 'description')

View File

@ -84,7 +84,10 @@ class VLANGroupTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = VLANGroup 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') default_columns = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'description', 'actions')
@ -125,7 +128,10 @@ class VLANTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = VLAN 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') default_columns = ('pk', 'vid', 'name', 'site', 'group', 'prefixes', 'tenant', 'status', 'role', 'description')
row_attrs = { row_attrs = {
'class': lambda record: 'success' if not isinstance(record, VLAN) else '', 'class': lambda record: 'success' if not isinstance(record, VLAN) else '',

View File

@ -47,7 +47,8 @@ class VRFTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = VRF model = VRF
fields = ( 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') default_columns = ('pk', 'name', 'rd', 'tenant', 'description')
@ -68,5 +69,5 @@ class RouteTargetTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = RouteTarget 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') default_columns = ('pk', 'name', 'tenant', 'description')

View File

@ -19,7 +19,7 @@ from netbox.config import PARAMS
# Environment setup # Environment setup
# #
VERSION = '3.1.5' VERSION = '3.1.6'
# Hostname # Hostname
HOSTNAME = platform.node() 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; box-shadow: $box-shadow-sm;
} }
.badge a {
color: inherit;
}
.btn { .btn {
white-space: nowrap; white-space: nowrap;
} }

View File

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

View File

@ -5,7 +5,7 @@
--nbx-sidebar-bg: #{$gray-200}; --nbx-sidebar-bg: #{$gray-200};
--nbx-sidebar-scroll: #{$gray-500}; --nbx-sidebar-scroll: #{$gray-500};
--nbx-sidebar-link-hover-bg: #{rgba($gray-600, 0.15)}; --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-title-color: #{$text-muted};
--nbx-sidebar-shadow: inset 0px -25px 20px -25px rgba(0, 0, 0, 0.25); --nbx-sidebar-shadow: inset 0px -25px 20px -25px rgba(0, 0, 0, 0.25);
--nbx-breadcrumb-bg: #{$light}; --nbx-breadcrumb-bg: #{$light};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -38,7 +38,7 @@
</div> </div>
{% else %} {% else %}
<div class="btn-group"> <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 <i class="mdi mdi-login-variant"></i> Log In
</a> </a>
<button type="button" class="btn btn-primary dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown"> <button type="button" class="btn btn-primary dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -14,5 +14,6 @@
{% endblock content %} {% endblock content %}
{% block modals %} {% block modals %}
{{ block.super }}
{% table_config_form table %} {% table_config_form table %}
{% endblock modals %} {% 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> <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> <p>Clusters</p>
</div> </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>
</div> </div>
{% plugin_right_page object %} {% plugin_right_page object %}

View File

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

View File

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

View File

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

View File

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

View File

@ -63,7 +63,9 @@ class TenantGroupTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = TenantGroup 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') default_columns = ('pk', 'name', 'tenant_count', 'description', 'actions')
@ -82,7 +84,7 @@ class TenantTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = Tenant 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') default_columns = ('pk', 'name', 'group', 'description')
@ -107,7 +109,7 @@ class ContactGroupTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = ContactGroup 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') default_columns = ('pk', 'name', 'contact_count', 'description', 'actions')
@ -120,7 +122,7 @@ class ContactRoleTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = ContactRole model = ContactRole
fields = ('pk', 'name', 'description', 'slug', 'actions') fields = ('pk', 'name', 'description', 'slug', 'actions', 'created', 'last_updated',)
default_columns = ('pk', 'name', 'description', 'actions') default_columns = ('pk', 'name', 'description', 'actions')
@ -145,7 +147,10 @@ class ContactTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = Contact 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') 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 django.shortcuts import get_object_or_404
from circuits.models import Circuit 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 ipam.models import Aggregate, IPAddress, Prefix, VLAN, VRF
from netbox.views import generic from netbox.views import generic
from utilities.tables import paginate_table 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(), 'circuit_count': Circuit.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
'virtualmachine_count': VirtualMachine.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(), '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 { return {

View File

@ -31,6 +31,7 @@ __all__ = (
'CSVDataField', 'CSVDataField',
'CSVFileField', 'CSVFileField',
'CSVModelChoiceField', 'CSVModelChoiceField',
'CSVMultipleChoiceField',
'CSVMultipleContentTypeField', 'CSVMultipleContentTypeField',
'CSVTypedChoiceField', 'CSVTypedChoiceField',
'DynamicModelChoiceField', 'DynamicModelChoiceField',
@ -263,10 +264,7 @@ class CSVFileField(forms.FileField):
return value return value
class CSVChoiceField(forms.ChoiceField): class CSVChoicesMixin:
"""
Invert the provided set of choices to take the human-friendly label as input, and return the database value.
"""
STATIC_CHOICES = True STATIC_CHOICES = True
def __init__(self, *, choices=(), **kwargs): def __init__(self, *, choices=(), **kwargs):
@ -274,6 +272,25 @@ class CSVChoiceField(forms.ChoiceField):
self.choices = unpack_grouped_choices(choices) 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): class CSVTypedChoiceField(forms.TypedChoiceField):
STATIC_CHOICES = True STATIC_CHOICES = True

View File

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

View File

@ -330,15 +330,15 @@ class ColoredLabelColumn(tables.TemplateColumn):
Render a colored label (e.g. for DeviceRoles). Render a colored label (e.g. for DeviceRoles).
""" """
template_code = """ template_code = """
{% load helpers %} {% load helpers %}
{% if value %} {% if value %}
<span class="badge" style="color: {{ value.color|fgcolor }}; background-color: #{{ value.color }}"> <span class="badge" style="color: {{ value.color|fgcolor }}; background-color: #{{ value.color }}">
{{ value }} <a href="{{ value.get_absolute_url }}">{{ value }}</a>
</span> </span>
{% else %} {% else %}
&mdash; &mdash;
{% endif %} {% endif %}
""" """
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(template_code=self.template_code, *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'(?::\d{2,5})?' # Port number
r'(?:[/?#][^\s]*)?' # Path r'(?:[/?#][^\s]*)?' # Path
r'\Z', re.IGNORECASE) r'\Z', re.IGNORECASE)
schemes = None
def __init__(self, schemes=None, **kwargs): def __call__(self, value):
super().__init__(**kwargs) if self.schemes is None:
if schemes is not None: # We can't load the allowed schemes until the configuration has been initialized
self.schemes = get_config().ALLOWED_URL_SCHEMES self.schemes = get_config().ALLOWED_URL_SCHEMES
return super().__call__(value)
class ExclusionValidator(BaseValidator): class ExclusionValidator(BaseValidator):

View File

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

View File

@ -30,7 +30,9 @@ class WirelessLANGroupTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = WirelessLANGroup 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') default_columns = ('pk', 'name', 'wirelesslan_count', 'description', 'actions')
@ -53,7 +55,7 @@ class WirelessLANTable(BaseTable):
model = WirelessLAN model = WirelessLAN
fields = ( fields = (
'pk', 'ssid', 'group', 'description', 'vlan', 'interface_count', 'auth_type', 'auth_cipher', 'auth_psk', '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') default_columns = ('pk', 'ssid', 'group', 'description', 'vlan', 'auth_type', 'interface_count')
@ -102,7 +104,7 @@ class WirelessLinkTable(BaseTable):
model = WirelessLink model = WirelessLink
fields = ( fields = (
'pk', 'id', 'status', 'device_a', 'interface_a', 'device_b', 'interface_b', 'ssid', 'description', '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 = ( default_columns = (
'pk', 'id', 'status', 'device_a', 'interface_a', 'device_b', 'interface_b', 'ssid', 'auth_type', '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==3.2.11
django-cors-headers==3.10.1 django-cors-headers==3.11.0
django-debug-toolbar==3.2.4 django-debug-toolbar==3.2.4
django-filter==21.1 django-filter==21.1
django-graphiql-debug-toolbar==0.2.0 django-graphiql-debug-toolbar==0.2.0
@ -10,7 +10,7 @@ django-redis==5.2.0
django-rq==2.5.1 django-rq==2.5.1
django-tables2==2.4.1 django-tables2==2.4.1
django-taggit==2.0.0 django-taggit==2.0.0
django-timezone-field==4.2.1 django-timezone-field==4.2.3
djangorestframework==3.12.4 djangorestframework==3.12.4
drf-yasg[validation]==1.20.0 drf-yasg[validation]==1.20.0
graphene_django==2.15.0 graphene_django==2.15.0
@ -18,7 +18,7 @@ gunicorn==20.1.0
Jinja2==3.0.3 Jinja2==3.0.3
Markdown==3.3.6 Markdown==3.3.6
markdown-include==0.6.0 markdown-include==0.6.0
mkdocs-material==8.1.4 mkdocs-material==8.1.7
netaddr==0.8.0 netaddr==0.8.0
Pillow==8.4.0 Pillow==8.4.0
psycopg2-binary==2.9.3 psycopg2-binary==2.9.3