Merge branch 'master' into master

This commit is contained in:
PieterL75 2022-03-14 12:41:28 +01:00 committed by GitHub
commit f1fdd00427
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
75 changed files with 784 additions and 286 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.7 placeholder: v3.1.9
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.7 placeholder: v3.1.9
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -39,13 +39,25 @@ jobs:
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
- name: Install Yarn Package Manager
run: npm install -g yarn
- name: Setup Node.js with Yarn Caching
uses: actions/setup-node@v2
with:
node-version: ${{ matrix.node-version }}
cache: yarn
cache-dependency-path: netbox/project-static/yarn.lock
- name: Install Frontend Dependencies
run: yarn --cwd netbox/project-static
- name: Install dependencies & set up configuration - name: Install dependencies & set up configuration
run: | run: |
python -m pip install --upgrade pip python -m pip install --upgrade pip
pip install -r requirements.txt pip install -r requirements.txt
pip install pycodestyle coverage pip install pycodestyle coverage
ln -s configuration.testing.py netbox/netbox/configuration.py ln -s configuration.testing.py netbox/netbox/configuration.py
yarn --cwd netbox/project-static
- name: Build documentation - name: Build documentation
run: mkdocs build run: mkdocs build
@ -63,7 +75,7 @@ jobs:
run: scripts/verify-bundles.sh run: scripts/verify-bundles.sh
- name: Run tests - name: Run tests
run: coverage run --source="netbox/" netbox/manage.py test netbox/ run: coverage run --source="netbox/" netbox/manage.py test netbox/ --parallel
- name: Show coverage report - name: Show coverage report
run: coverage report --skip-covered --omit *migrations* run: coverage report --skip-covered --omit *migrations*

View File

@ -16,13 +16,6 @@ categories for discussions:
feature request feature request
* **Q&A** - Request help with installing or using NetBox * **Q&A** - Request help with installing or using NetBox
### Mailing List
We also have a Google Groups [mailing list](https://groups.google.com/g/netbox-discuss)
for general discussion, however we're encouraging people to use GitHub
discussions where possible, as it's much easier for newcomers to review past
discussions.
### Slack ### Slack
For real-time chat, you can join the **#netbox** Slack channel on [NetDev Community](https://netdev.chat/). For real-time chat, you can join the **#netbox** Slack channel on [NetDev Community](https://netdev.chat/).

View File

@ -2,6 +2,8 @@
<img src="https://raw.githubusercontent.com/netbox-community/netbox/develop/docs/netbox_logo.svg" width="400" alt="NetBox logo" /> <img src="https://raw.githubusercontent.com/netbox-community/netbox/develop/docs/netbox_logo.svg" width="400" alt="NetBox logo" />
</div> </div>
:loudspeaker: The **[2022 NetBox community survey](https://forms.gle/KR8YbR8GiJ9EYXM28)** is now open! We collect this feedback and demographic data from NetBox users around the world to help shape the project's long-term development goals. Please take a few minutes to share your responses!
![Master branch build status](https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master) ![Master branch build status](https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master)
NetBox is an infrastructure resource modeling (IRM) tool designed to empower NetBox is an infrastructure resource modeling (IRM) tool designed to empower
@ -68,7 +70,6 @@ The complete documentation for NetBox can be found at [Read the Docs](https://ne
* [GitHub Discussions](https://github.com/netbox-community/netbox/discussions) - Discussion forum hosted by GitHub; ideal for Q&A and other structured discussions * [GitHub Discussions](https://github.com/netbox-community/netbox/discussions) - Discussion forum hosted by GitHub; ideal for Q&A and other structured discussions
* [Slack](https://netdev.chat/) - Real-time chat hosted by the NetDev Community; best for unstructured discussion or just hanging out * [Slack](https://netdev.chat/) - Real-time chat hosted by the NetDev Community; best for unstructured discussion or just hanging out
* [Google Group](https://groups.google.com/g/netbox-discuss) - Legacy mailing list; slowly being replaced by GitHub discussions
### Installation ### Installation

View File

@ -35,7 +35,7 @@ The list of groups to assign a new user account when created using remote authen
Default: `{}` (Empty dictionary) Default: `{}` (Empty dictionary)
A mapping of permissions to assign a new user account when created using remote authentication. Each key in the dictionary should be set to a dictionary of the attributes to be applied to the permission, or `None` to allow all objects. (Requires `REMOTE_AUTH_ENABLED`.) A mapping of permissions to assign a new user account when created using remote authentication. Each key in the dictionary should be set to a dictionary of the attributes to be applied to the permission, or `None` to allow all objects. (Requires `REMOTE_AUTH_ENABLED` as True and `REMOTE_AUTH_GROUP_SYNC_ENABLED` as False.)
--- ---
@ -43,7 +43,7 @@ A mapping of permissions to assign a new user account when created using remote
Default: `False` Default: `False`
NetBox can be configured to support remote user authentication by inferring user authentication from an HTTP header set by the HTTP reverse proxy (e.g. nginx or Apache). Set this to `True` to enable this functionality. (Local authentication will still take effect as a fallback.) NetBox can be configured to support remote user authentication by inferring user authentication from an HTTP header set by the HTTP reverse proxy (e.g. nginx or Apache). Set this to `True` to enable this functionality. (Local authentication will still take effect as a fallback.) (`REMOTE_AUTH_DEFAULT_GROUPS` will not function if `REMOTE_AUTH_ENABLED` is enabled)
--- ---

View File

@ -7,9 +7,8 @@ NetBox is maintained as a [GitHub project](https://github.com/netbox-community/n
There are several official forums for communication among the developers and community members: There are several official forums for communication among the developers and community members:
* [GitHub issues](https://github.com/netbox-community/netbox/issues) - All feature requests, bug reports, and other substantial changes to the code base **must** be documented in a GitHub issue. * [GitHub issues](https://github.com/netbox-community/netbox/issues) - All feature requests, bug reports, and other substantial changes to the code base **must** be documented in a GitHub issue.
* [GitHub Discussions](https://github.com/netbox-community/netbox/discussions) - The preferred forum for general discussion and support issues. Ideal for shaping a feature request prior to submitting an issue. * [GitHub discussions](https://github.com/netbox-community/netbox/discussions) - The preferred forum for general discussion and support issues. Ideal for shaping a feature request prior to submitting an issue.
* [#netbox on NetDev Community Slack](https://netdev.chat/) - Good for quick chats. Avoid any discussion that might need to be referenced later on, as the chat history is not retained long. * [#netbox on NetDev Community Slack](https://netdev.chat/) - Good for quick chats. Avoid any discussion that might need to be referenced later on, as the chat history is not retained long.
* [Google Group](https://groups.google.com/g/netbox-discuss) - Legacy mailing list; slowly being phased out in favor of GitHub discussions.
## Governance ## Governance

View File

@ -1,5 +1,7 @@
![NetBox](netbox_logo.svg "NetBox logo"){style="height: 100px; margin-bottom: 3em"} ![NetBox](netbox_logo.svg "NetBox logo"){style="height: 100px; margin-bottom: 3em"}
:loudspeaker: The **[2022 NetBox community survey](https://forms.gle/KR8YbR8GiJ9EYXM28)** is now open! We collect this feedback and demographic data from NetBox users around the world to help shape the project's long-term development goals. Please take a few minutes to share your responses!
# What is NetBox? # What is NetBox?
NetBox is an infrastructure resource modeling (IRM) application designed to empower network automation. Initially conceived by the network engineering team at [DigitalOcean](https://www.digitalocean.com/), NetBox was developed specifically to address the needs of network and infrastructure engineers. NetBox is made available as open source under the Apache 2 license. It encompasses the following aspects of network management: NetBox is an infrastructure resource modeling (IRM) application designed to empower network automation. Initially conceived by the network engineering team at [DigitalOcean](https://www.digitalocean.com/), NetBox was developed specifically to address the needs of network and infrastructure engineers. NetBox is made available as open source under the Apache 2 license. It encompasses the following aspects of network management:

View File

@ -11,10 +11,6 @@ The following sections detail how to set up a new instance of NetBox:
5. [HTTP server](5-http-server.md) 5. [HTTP server](5-http-server.md)
6. [LDAP authentication](6-ldap.md) (optional) 6. [LDAP authentication](6-ldap.md) (optional)
The video below demonstrates the installation of NetBox v3.0 on Ubuntu 20.04 for your reference.
<iframe width="560" height="315" src="https://www.youtube.com/embed/7Fpd2-q9_28" title="YouTube video player" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe>
## Requirements ## Requirements
| Dependency | Minimum Version | | Dependency | Minimum Version |

View File

@ -1,5 +1,56 @@
# NetBox v3.1 # NetBox v3.1
## v3.1.9 (2022-03-07)
### Enhancements
* [#8594](https://github.com/netbox-community/netbox/issues/8594) - Enable filtering by exact description match for all applicable models
* [#8629](https://github.com/netbox-community/netbox/issues/8629) - Add description to tag table search function
* [#8664](https://github.com/netbox-community/netbox/issues/8664) - Show assigned ASNs/sites under list views
* [#8736](https://github.com/netbox-community/netbox/issues/8736) - Add PC and UPC fiber end faces for LC/SC/LSH port types
* [#8758](https://github.com/netbox-community/netbox/issues/8758) - Allow empty string substitution when renaming objects in bulk
* [#8762](https://github.com/netbox-community/netbox/issues/8762) - Link to rack elevations list from site view
* [#8766](https://github.com/netbox-community/netbox/issues/8766) - Add SCTP to service protocols list
### Bug Fixes
* [#8546](https://github.com/netbox-community/netbox/issues/8546) - Fix bulk import to restrict bridge, parent, and LAG to device interfaces
* [#8633](https://github.com/netbox-community/netbox/issues/8633) - Prevent navigation sidebar pin from disappearing at certain breakpoints
* [#8674](https://github.com/netbox-community/netbox/issues/8674) - Fix rendering of tabbed content in documentation
* [#8710](https://github.com/netbox-community/netbox/issues/8710) - Fix dynamic scope selection form fields when creating a VLAN group
* [#8713](https://github.com/netbox-community/netbox/issues/8713) - Restore missing "add" button on services list view
* [#8715](https://github.com/netbox-community/netbox/issues/8715) - Avoid returning multiple objects when restricting querysets using multiple tags in permissions
* [#8717](https://github.com/netbox-community/netbox/issues/8717) - Fix redirection after bulk edit/delete of prefixes from aggregate view
* [#8724](https://github.com/netbox-community/netbox/issues/8724) - Fix exception during device import with invalid device type
* [#8807](https://github.com/netbox-community/netbox/issues/8807) - Correct REST API URL for FHRP group assignments
* [#8808](https://github.com/netbox-community/netbox/issues/8808) - Fix members count under FHRP group list
---
## v3.1.8 (2022-02-15)
### Enhancements
* [#7150](https://github.com/netbox-community/netbox/issues/7150) - Linkify devices on the far side of a rack elevation
* [#8398](https://github.com/netbox-community/netbox/issues/8398) - Embiggen configuration form fields for banner message content
* [#8556](https://github.com/netbox-community/netbox/issues/8556) - Add full username column to changelog table
* [#8620](https://github.com/netbox-community/netbox/issues/8620) - Enable tab completion for `nbshell`
### Bug Fixes
* [#8331](https://github.com/netbox-community/netbox/issues/8331) - Implement `replaceAll` string utility function to improve browser compatibility
* [#8391](https://github.com/netbox-community/netbox/issues/8391) - Null date columns should return empty strings during CSV export
* [#8548](https://github.com/netbox-community/netbox/issues/8548) - Fix display of VC members when position is zero
* [#8561](https://github.com/netbox-community/netbox/issues/8561) - Include option to connect a rear port to a console port
* [#8564](https://github.com/netbox-community/netbox/issues/8564) - Fix errant table configuration key `available_columns`
* [#8577](https://github.com/netbox-community/netbox/issues/8577) - Show contact assignment counts in global search results
* [#8578](https://github.com/netbox-community/netbox/issues/8578) - Object change log tables should honor user's configured preferences
* [#8604](https://github.com/netbox-community/netbox/issues/8604) - Fix tag filter on config context list filter form
* [#8609](https://github.com/netbox-community/netbox/issues/8609) - Display validation error when attempting to assign VLANs to interface with no mode during bulk edit
* [#8611](https://github.com/netbox-community/netbox/issues/8611) - Fix bulk editing for certain custom link, webhook, and journal entry fields
---
## v3.1.7 (2022-02-03) ## v3.1.7 (2022-02-03)
### Enhancements ### Enhancements

View File

@ -8,11 +8,13 @@ theme:
icon: icon:
repo: fontawesome/brands/github repo: fontawesome/brands/github
palette: palette:
- scheme: default - media: "(prefers-color-scheme: light)"
scheme: default
toggle: toggle:
icon: material/lightbulb-outline icon: material/lightbulb-outline
name: Switch to Dark Mode name: Switch to Dark Mode
- scheme: slate - media: "(prefers-color-scheme: dark)"
scheme: slate
toggle: toggle:
icon: material/lightbulb icon: material/lightbulb
name: Switch to Light Mode name: Switch to Light Mode
@ -34,7 +36,8 @@ markdown_extensions:
emoji_index: !!python/name:materialx.emoji.twemoji emoji_index: !!python/name:materialx.emoji.twemoji
emoji_generator: !!python/name:materialx.emoji.to_svg emoji_generator: !!python/name:materialx.emoji.to_svg
- pymdownx.superfences - pymdownx.superfences
- pymdownx.tabbed - pymdownx.tabbed:
alternate_style: true
nav: nav:
- Introduction: 'index.md' - Introduction: 'index.md'
- Installation: - Installation:

View File

@ -98,7 +98,7 @@ class ProviderNetworkFilterSet(PrimaryModelFilterSet):
class Meta: class Meta:
model = ProviderNetwork model = ProviderNetwork
fields = ['id', 'name'] fields = ['id', 'name', 'description']
def search(self, queryset, name, value): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():
@ -115,7 +115,7 @@ class CircuitTypeFilterSet(OrganizationalModelFilterSet):
class Meta: class Meta:
model = CircuitType model = CircuitType
fields = ['id', 'name', 'slug'] fields = ['id', 'name', 'slug', 'description']
class CircuitFilterSet(PrimaryModelFilterSet, TenancyFilterSet): class CircuitFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
@ -193,7 +193,7 @@ class CircuitFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
class Meta: class Meta:
model = Circuit model = Circuit
fields = ['id', 'cid', 'install_date', 'commit_rate'] fields = ['id', 'cid', 'description', 'install_date', 'commit_rate']
def search(self, queryset, name, value): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():
@ -234,7 +234,7 @@ class CircuitTerminationFilterSet(ChangeLoggedModelFilterSet, CableTerminationFi
class Meta: class Meta:
model = CircuitTermination model = CircuitTermination
fields = ['id', 'term_side', 'port_speed', 'upstream_speed', 'xconnect_id'] fields = ['id', 'term_side', 'port_speed', 'upstream_speed', 'xconnect_id', 'description']
def search(self, queryset, name, value): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():

View File

@ -108,8 +108,8 @@ class CircuitTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
def setUpTestData(cls): def setUpTestData(cls):
CircuitType.objects.bulk_create(( CircuitType.objects.bulk_create((
CircuitType(name='Circuit Type 1', slug='circuit-type-1'), CircuitType(name='Circuit Type 1', slug='circuit-type-1', description='foobar1'),
CircuitType(name='Circuit Type 2', slug='circuit-type-2'), CircuitType(name='Circuit Type 2', slug='circuit-type-2', description='foobar2'),
CircuitType(name='Circuit Type 3', slug='circuit-type-3'), CircuitType(name='Circuit Type 3', slug='circuit-type-3'),
)) ))
@ -121,6 +121,10 @@ class CircuitTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'slug': ['circuit-type-1']} params = {'slug': ['circuit-type-1']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_description(self):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests): class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = Circuit.objects.all() queryset = Circuit.objects.all()
@ -187,8 +191,8 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
ProviderNetwork.objects.bulk_create(provider_networks) ProviderNetwork.objects.bulk_create(provider_networks)
circuits = ( circuits = (
Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 1', install_date='2020-01-01', commit_rate=1000, status=CircuitStatusChoices.STATUS_ACTIVE), Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 1', install_date='2020-01-01', commit_rate=1000, status=CircuitStatusChoices.STATUS_ACTIVE, description='foobar1'),
Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 2', install_date='2020-01-02', commit_rate=2000, status=CircuitStatusChoices.STATUS_ACTIVE), Circuit(provider=providers[0], tenant=tenants[0], type=circuit_types[0], cid='Test Circuit 2', install_date='2020-01-02', commit_rate=2000, status=CircuitStatusChoices.STATUS_ACTIVE, description='foobar2'),
Circuit(provider=providers[0], tenant=tenants[1], type=circuit_types[0], cid='Test Circuit 3', install_date='2020-01-03', commit_rate=3000, status=CircuitStatusChoices.STATUS_PLANNED), Circuit(provider=providers[0], tenant=tenants[1], type=circuit_types[0], cid='Test Circuit 3', install_date='2020-01-03', commit_rate=3000, status=CircuitStatusChoices.STATUS_PLANNED),
Circuit(provider=providers[1], tenant=tenants[1], type=circuit_types[1], cid='Test Circuit 4', install_date='2020-01-04', commit_rate=4000, status=CircuitStatusChoices.STATUS_PLANNED), Circuit(provider=providers[1], tenant=tenants[1], type=circuit_types[1], cid='Test Circuit 4', install_date='2020-01-04', commit_rate=4000, status=CircuitStatusChoices.STATUS_PLANNED),
Circuit(provider=providers[1], tenant=tenants[2], type=circuit_types[1], cid='Test Circuit 5', install_date='2020-01-05', commit_rate=5000, status=CircuitStatusChoices.STATUS_OFFLINE), Circuit(provider=providers[1], tenant=tenants[2], type=circuit_types[1], cid='Test Circuit 5', install_date='2020-01-05', commit_rate=5000, status=CircuitStatusChoices.STATUS_OFFLINE),
@ -241,6 +245,10 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'status': [CircuitStatusChoices.STATUS_ACTIVE, CircuitStatusChoices.STATUS_PLANNED]} params = {'status': [CircuitStatusChoices.STATUS_ACTIVE, CircuitStatusChoices.STATUS_PLANNED]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_description(self):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_region(self): def test_region(self):
regions = Region.objects.all()[:2] regions = Region.objects.all()[:2]
params = {'region_id': [regions[0].pk, regions[1].pk]} params = {'region_id': [regions[0].pk, regions[1].pk]}
@ -319,8 +327,8 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
Circuit.objects.bulk_create(circuits) Circuit.objects.bulk_create(circuits)
circuit_terminations = (( circuit_terminations = ((
CircuitTermination(circuit=circuits[0], site=sites[0], term_side='A', port_speed=1000, upstream_speed=1000, xconnect_id='ABC'), CircuitTermination(circuit=circuits[0], site=sites[0], term_side='A', port_speed=1000, upstream_speed=1000, xconnect_id='ABC', description='foobar1'),
CircuitTermination(circuit=circuits[0], site=sites[1], term_side='Z', port_speed=1000, upstream_speed=1000, xconnect_id='DEF'), CircuitTermination(circuit=circuits[0], site=sites[1], term_side='Z', port_speed=1000, upstream_speed=1000, xconnect_id='DEF', description='foobar2'),
CircuitTermination(circuit=circuits[1], site=sites[1], term_side='A', port_speed=2000, upstream_speed=2000, xconnect_id='GHI'), CircuitTermination(circuit=circuits[1], site=sites[1], term_side='A', port_speed=2000, upstream_speed=2000, xconnect_id='GHI'),
CircuitTermination(circuit=circuits[1], site=sites[2], term_side='Z', port_speed=2000, upstream_speed=2000, xconnect_id='JKL'), CircuitTermination(circuit=circuits[1], site=sites[2], term_side='Z', port_speed=2000, upstream_speed=2000, xconnect_id='JKL'),
CircuitTermination(circuit=circuits[2], site=sites[2], term_side='A', port_speed=3000, upstream_speed=3000, xconnect_id='MNO'), CircuitTermination(circuit=circuits[2], site=sites[2], term_side='A', port_speed=3000, upstream_speed=3000, xconnect_id='MNO'),
@ -349,6 +357,10 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'xconnect_id': ['ABC', 'DEF']} params = {'xconnect_id': ['ABC', 'DEF']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_description(self):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_circuit_id(self): def test_circuit_id(self):
circuits = Circuit.objects.all()[:2] circuits = Circuit.objects.all()[:2]
params = {'circuit_id': [circuits[0].pk, circuits[1].pk]} params = {'circuit_id': [circuits[0].pk, circuits[1].pk]}
@ -386,8 +398,8 @@ class ProviderNetworkTestCase(TestCase, ChangeLoggedFilterSetTests):
Provider.objects.bulk_create(providers) Provider.objects.bulk_create(providers)
provider_networks = ( provider_networks = (
ProviderNetwork(name='Provider Network 1', provider=providers[0]), ProviderNetwork(name='Provider Network 1', provider=providers[0], description='foobar1'),
ProviderNetwork(name='Provider Network 2', provider=providers[1]), ProviderNetwork(name='Provider Network 2', provider=providers[1], description='foobar2'),
ProviderNetwork(name='Provider Network 3', provider=providers[2]), ProviderNetwork(name='Provider Network 3', provider=providers[2]),
) )
ProviderNetwork.objects.bulk_create(provider_networks) ProviderNetwork.objects.bulk_create(provider_networks)
@ -396,6 +408,10 @@ class ProviderNetworkTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'name': ['Provider Network 1', 'Provider Network 2']} params = {'name': ['Provider Network 1', 'Provider Network 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_description(self):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_provider(self): def test_provider(self):
providers = Provider.objects.all()[:2] providers = Provider.objects.all()[:2]
params = {'provider_id': [providers[0].pk, providers[1].pk]} params = {'provider_id': [providers[0].pk, providers[1].pk]}

View File

@ -1013,13 +1013,19 @@ class PortTypeChoices(ChoiceSet):
TYPE_MRJ21 = 'mrj21' TYPE_MRJ21 = 'mrj21'
TYPE_ST = 'st' TYPE_ST = 'st'
TYPE_SC = 'sc' TYPE_SC = 'sc'
TYPE_SC_PC = 'sc-pc'
TYPE_SC_UPC = 'sc-upc'
TYPE_SC_APC = 'sc-apc' TYPE_SC_APC = 'sc-apc'
TYPE_FC = 'fc' TYPE_FC = 'fc'
TYPE_LC = 'lc' TYPE_LC = 'lc'
TYPE_LC_PC = 'lc-pc'
TYPE_LC_UPC = 'lc-upc'
TYPE_LC_APC = 'lc-apc' TYPE_LC_APC = 'lc-apc'
TYPE_MTRJ = 'mtrj' TYPE_MTRJ = 'mtrj'
TYPE_MPO = 'mpo' TYPE_MPO = 'mpo'
TYPE_LSH = 'lsh' TYPE_LSH = 'lsh'
TYPE_LSH_PC = 'lsh-pc'
TYPE_LSH_UPC = 'lsh-upc'
TYPE_LSH_APC = 'lsh-apc' TYPE_LSH_APC = 'lsh-apc'
TYPE_SPLICE = 'splice' TYPE_SPLICE = 'splice'
TYPE_CS = 'cs' TYPE_CS = 'cs'
@ -1059,12 +1065,18 @@ class PortTypeChoices(ChoiceSet):
( (
(TYPE_FC, 'FC'), (TYPE_FC, 'FC'),
(TYPE_LC, 'LC'), (TYPE_LC, 'LC'),
(TYPE_LC_PC, 'LC/PC'),
(TYPE_LC_UPC, 'LC/UPC'),
(TYPE_LC_APC, 'LC/APC'), (TYPE_LC_APC, 'LC/APC'),
(TYPE_LSH, 'LSH'), (TYPE_LSH, 'LSH'),
(TYPE_LSH_PC, 'LSH/PC'),
(TYPE_LSH_UPC, 'LSH/UPC'),
(TYPE_LSH_APC, 'LSH/APC'), (TYPE_LSH_APC, 'LSH/APC'),
(TYPE_MPO, 'MPO'), (TYPE_MPO, 'MPO'),
(TYPE_MTRJ, 'MTRJ'), (TYPE_MTRJ, 'MTRJ'),
(TYPE_SC, 'SC'), (TYPE_SC, 'SC'),
(TYPE_SC_PC, 'SC/PC'),
(TYPE_SC_UPC, 'SC/UPC'),
(TYPE_SC_APC, 'SC/APC'), (TYPE_SC_APC, 'SC/APC'),
(TYPE_ST, 'ST'), (TYPE_ST, 'ST'),
(TYPE_CS, 'CS'), (TYPE_CS, 'CS'),

View File

@ -142,7 +142,7 @@ class SiteFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
model = Site model = Site
fields = [ fields = [
'id', 'name', 'slug', 'facility', 'asn', 'latitude', 'longitude', 'contact_name', 'contact_phone', 'id', 'name', 'slug', 'facility', 'asn', 'latitude', 'longitude', 'contact_name', 'contact_phone',
'contact_email', 'contact_email', 'description'
] ]
def search(self, queryset, name, value): def search(self, queryset, name, value):
@ -237,7 +237,7 @@ class RackRoleFilterSet(OrganizationalModelFilterSet):
class Meta: class Meta:
model = RackRole model = RackRole
fields = ['id', 'name', 'slug', 'color'] fields = ['id', 'name', 'slug', 'color', 'description']
class RackFilterSet(PrimaryModelFilterSet, TenancyFilterSet): class RackFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
@ -385,7 +385,7 @@ class RackReservationFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
class Meta: class Meta:
model = RackReservation model = RackReservation
fields = ['id', 'created'] fields = ['id', 'created', 'description']
def search(self, queryset, name, value): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():
@ -586,7 +586,7 @@ class DeviceRoleFilterSet(OrganizationalModelFilterSet):
class Meta: class Meta:
model = DeviceRole model = DeviceRole
fields = ['id', 'name', 'slug', 'color', 'vm_role'] fields = ['id', 'name', 'slug', 'color', 'vm_role', 'description']
class PlatformFilterSet(OrganizationalModelFilterSet): class PlatformFilterSet(OrganizationalModelFilterSet):

View File

@ -1043,8 +1043,14 @@ class InterfaceBulkEditForm(
def clean(self): def clean(self):
super().clean() super().clean()
if not self.cleaned_data['mode']:
if self.cleaned_data['untagged_vlan']:
raise forms.ValidationError({'untagged_vlan': "Interface mode must be specified to assign VLANs"})
elif self.cleaned_data['tagged_vlans']:
raise forms.ValidationError({'tagged_vlans': "Interface mode must be specified to assign VLANs"})
# Untagged interfaces cannot be assigned tagged VLANs # Untagged interfaces cannot be assigned tagged VLANs
if self.cleaned_data['mode'] == InterfaceModeChoices.MODE_ACCESS and self.cleaned_data['tagged_vlans']: elif self.cleaned_data['mode'] == InterfaceModeChoices.MODE_ACCESS and self.cleaned_data['tagged_vlans']:
raise forms.ValidationError({ raise forms.ValidationError({
'mode': "An access interface cannot have tagged VLANs assigned." 'mode': "An access interface cannot have tagged VLANs assigned."
}) })

View File

@ -605,6 +605,19 @@ class InterfaceCSVForm(CustomFieldModelCSVForm):
'rf_channel_width', 'tx_power', 'rf_channel_width', 'tx_power',
) )
def __init__(self, data=None, *args, **kwargs):
super().__init__(data, *args, **kwargs)
if data:
# Limit interface choices for parent, bridge and lag to device only
params = {}
if data.get('device'):
params[f"device__{self.fields['device'].to_field_name}"] = data.get('device')
if params:
self.fields['parent'].queryset = self.fields['parent'].queryset.filter(**params)
self.fields['bridge'].queryset = self.fields['bridge'].queryset.filter(**params)
self.fields['lag'].queryset = self.fields['lag'].queryset.filter(**params)
def clean_enabled(self): def clean_enabled(self):
# Make sure enabled is True when it's not included in the uploaded data # Make sure enabled is True when it's not included in the uploaded data
if 'enabled' not in self.data: if 'enabled' not in self.data:

View File

@ -670,6 +670,7 @@ class Device(PrimaryModel, ConfigContextModel):
}) })
# Prevent 0U devices from being assigned to a specific position # Prevent 0U devices from being assigned to a specific position
if hasattr(self, 'device_type'):
if self.position and self.device_type.u_height == 0: if self.position and self.device_type.u_height == 0:
raise ValidationError({ raise ValidationError({
'position': f"A U0 device type ({self.device_type}) cannot be assigned to a rack position." 'position': f"A U0 device type ({self.device_type}) cannot be assigned to a rack position."

View File

@ -126,10 +126,16 @@ class RackElevationSVG:
link.add(drawing.text(str(name), insert=text, fill='white', class_='device-image-label')) link.add(drawing.text(str(name), insert=text, fill='white', class_='device-image-label'))
def _draw_device_rear(self, drawing, device, start, end, text): def _draw_device_rear(self, drawing, device, start, end, text):
rect = drawing.rect(start, end, class_="slot blocked") link = drawing.add(
rect.set_desc(self._get_device_description(device)) drawing.a(
drawing.add(rect) href='{}{}'.format(self.base_url, reverse('dcim:device', kwargs={'pk': device.pk})),
drawing.add(drawing.text(get_device_name(device), insert=text)) target='_top',
fill='black'
)
)
link.set_desc(self._get_device_description(device))
link.add(drawing.rect(start, end, class_="slot blocked"))
link.add(drawing.text(get_device_name(device), insert=text))
# Embed rear device type image if one exists # Embed rear device type image if one exists
if self.include_images and device.device_type.rear_image: if self.include_images and device.device_type.rear_image:

View File

@ -85,6 +85,10 @@ class SiteTable(BaseTable):
accessor=tables.A('asns__count'), accessor=tables.A('asns__count'),
viewname='ipam:asn_list', viewname='ipam:asn_list',
url_params={'site_id': 'pk'}, url_params={'site_id': 'pk'},
verbose_name='ASN Count'
)
asns = tables.ManyToManyColumn(
linkify_item=True,
verbose_name='ASNs' verbose_name='ASNs'
) )
tenant = TenantColumn() tenant = TenantColumn()
@ -96,8 +100,8 @@ class SiteTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = Site model = Site
fields = ( fields = (
'pk', 'id', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'asn_count', 'time_zone', 'pk', 'id', 'name', 'slug', 'status', 'facility', 'region', 'group', 'tenant', 'asns', 'asn_count',
'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name', 'time_zone', 'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'contact_name',
'contact_phone', 'contact_email', 'comments', 'tags', 'created', 'last_updated', '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')

View File

@ -298,6 +298,8 @@ REARPORT_BUTTONS = """
</button> </button>
<ul class="dropdown-menu dropdown-menu-end"> <ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='interface' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Interface</a></li> <li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='interface' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Interface</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='console-server-port' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Console Server Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='console-port' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Console Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='front-port' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Front Port</a></li> <li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='front-port' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Front Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='rear-port' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Rear Port</a></li> <li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='rear-port' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Rear Port</a></li>
<li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='circuit-termination' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Circuit Termination</a></li> <li><a class="dropdown-item" href="{% url 'dcim:rearport_connect' termination_a_id=record.pk termination_b_type='circuit-termination' %}?return_url={% url 'dcim:device_rearports' pk=object.pk %}">Circuit Termination</a></li>

View File

@ -151,8 +151,8 @@ class SiteTestCase(TestCase, ChangeLoggedFilterSetTests):
ASN.objects.bulk_create(asns) ASN.objects.bulk_create(asns)
sites = ( sites = (
Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0], tenant=tenants[0], status=SiteStatusChoices.STATUS_ACTIVE, facility='Facility 1', asn=65001, latitude=10, longitude=10, contact_name='Contact 1', contact_phone='123-555-0001', contact_email='contact1@example.com'), Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0], tenant=tenants[0], status=SiteStatusChoices.STATUS_ACTIVE, facility='Facility 1', asn=65001, latitude=10, longitude=10, contact_name='Contact 1', contact_phone='123-555-0001', contact_email='contact1@example.com', description='foobar1'),
Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1], tenant=tenants[1], status=SiteStatusChoices.STATUS_PLANNED, facility='Facility 2', asn=65002, latitude=20, longitude=20, contact_name='Contact 2', contact_phone='123-555-0002', contact_email='contact2@example.com'), Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1], tenant=tenants[1], status=SiteStatusChoices.STATUS_PLANNED, facility='Facility 2', asn=65002, latitude=20, longitude=20, contact_name='Contact 2', contact_phone='123-555-0002', contact_email='contact2@example.com', description='foobar2'),
Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2], tenant=tenants[2], status=SiteStatusChoices.STATUS_RETIRED, facility='Facility 3', asn=65003, latitude=30, longitude=30, contact_name='Contact 3', contact_phone='123-555-0003', contact_email='contact3@example.com'), Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2], tenant=tenants[2], status=SiteStatusChoices.STATUS_RETIRED, facility='Facility 3', asn=65003, latitude=30, longitude=30, contact_name='Contact 3', contact_phone='123-555-0003', contact_email='contact3@example.com'),
) )
Site.objects.bulk_create(sites) Site.objects.bulk_create(sites)
@ -201,6 +201,10 @@ class SiteTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'contact_email': ['contact1@example.com', 'contact2@example.com']} params = {'contact_email': ['contact1@example.com', 'contact2@example.com']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_description(self):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_status(self): def test_status(self):
params = {'status': [SiteStatusChoices.STATUS_ACTIVE, SiteStatusChoices.STATUS_PLANNED]} params = {'status': [SiteStatusChoices.STATUS_ACTIVE, SiteStatusChoices.STATUS_PLANNED]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@ -329,8 +333,8 @@ class RackRoleTestCase(TestCase, ChangeLoggedFilterSetTests):
def setUpTestData(cls): def setUpTestData(cls):
rack_roles = ( rack_roles = (
RackRole(name='Rack Role 1', slug='rack-role-1', color='ff0000'), RackRole(name='Rack Role 1', slug='rack-role-1', color='ff0000', description='foobar1'),
RackRole(name='Rack Role 2', slug='rack-role-2', color='00ff00'), RackRole(name='Rack Role 2', slug='rack-role-2', color='00ff00', description='foobar2'),
RackRole(name='Rack Role 3', slug='rack-role-3', color='0000ff'), RackRole(name='Rack Role 3', slug='rack-role-3', color='0000ff'),
) )
RackRole.objects.bulk_create(rack_roles) RackRole.objects.bulk_create(rack_roles)
@ -347,6 +351,10 @@ class RackRoleTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'color': ['ff0000', '00ff00']} params = {'color': ['ff0000', '00ff00']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_description(self):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class RackTestCase(TestCase, ChangeLoggedFilterSetTests): class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = Rack.objects.all() queryset = Rack.objects.all()
@ -570,8 +578,8 @@ class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests):
Tenant.objects.bulk_create(tenants) Tenant.objects.bulk_create(tenants)
reservations = ( reservations = (
RackReservation(rack=racks[0], units=[1, 2, 3], user=users[0], tenant=tenants[0]), RackReservation(rack=racks[0], units=[1, 2, 3], user=users[0], tenant=tenants[0], description='foobar1'),
RackReservation(rack=racks[1], units=[4, 5, 6], user=users[1], tenant=tenants[1]), RackReservation(rack=racks[1], units=[4, 5, 6], user=users[1], tenant=tenants[1], description='foobar2'),
RackReservation(rack=racks[2], units=[7, 8, 9], user=users[2], tenant=tenants[2]), RackReservation(rack=racks[2], units=[7, 8, 9], user=users[2], tenant=tenants[2]),
) )
RackReservation.objects.bulk_create(reservations) RackReservation.objects.bulk_create(reservations)
@ -604,6 +612,10 @@ class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'tenant': [tenants[0].slug, tenants[1].slug]} params = {'tenant': [tenants[0].slug, tenants[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_description(self):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_tenant_group(self): def test_tenant_group(self):
tenant_groups = TenantGroup.objects.all()[:2] tenant_groups = TenantGroup.objects.all()[:2]
params = {'tenant_group_id': [tenant_groups[0].pk, tenant_groups[1].pk]} params = {'tenant_group_id': [tenant_groups[0].pk, tenant_groups[1].pk]}
@ -1088,8 +1100,8 @@ class DeviceRoleTestCase(TestCase, ChangeLoggedFilterSetTests):
def setUpTestData(cls): def setUpTestData(cls):
device_roles = ( device_roles = (
DeviceRole(name='Device Role 1', slug='device-role-1', color='ff0000', vm_role=True), DeviceRole(name='Device Role 1', slug='device-role-1', color='ff0000', vm_role=True, description='foobar1'),
DeviceRole(name='Device Role 2', slug='device-role-2', color='00ff00', vm_role=True), DeviceRole(name='Device Role 2', slug='device-role-2', color='00ff00', vm_role=True, description='foobar2'),
DeviceRole(name='Device Role 3', slug='device-role-3', color='0000ff', vm_role=False), DeviceRole(name='Device Role 3', slug='device-role-3', color='0000ff', vm_role=False),
) )
DeviceRole.objects.bulk_create(device_roles) DeviceRole.objects.bulk_create(device_roles)
@ -1112,6 +1124,10 @@ class DeviceRoleTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'vm_role': 'false'} params = {'vm_role': 'false'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_description(self):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class PlatformTestCase(TestCase, ChangeLoggedFilterSetTests): class PlatformTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = Platform.objects.all() queryset = Platform.objects.all()

View File

@ -62,7 +62,7 @@ class CustomFieldFilterSet(BaseFilterSet):
class Meta: class Meta:
model = CustomField model = CustomField
fields = ['id', 'content_types', 'name', 'required', 'filter_logic', 'weight'] fields = ['id', 'content_types', 'name', 'required', 'filter_logic', 'weight', 'description']
def search(self, queryset, name, value): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():
@ -103,7 +103,7 @@ class ExportTemplateFilterSet(BaseFilterSet):
class Meta: class Meta:
model = ExportTemplate model = ExportTemplate
fields = ['id', 'content_type', 'name'] fields = ['id', 'content_type', 'name', 'description']
def search(self, queryset, name, value): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():
@ -177,14 +177,15 @@ class TagFilterSet(ChangeLoggedModelFilterSet):
class Meta: class Meta:
model = Tag model = Tag
fields = ['id', 'name', 'slug', 'color'] fields = ['id', 'name', 'slug', 'color', 'description']
def search(self, queryset, name, value): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():
return queryset return queryset
return queryset.filter( return queryset.filter(
Q(name__icontains=value) | Q(name__icontains=value) |
Q(slug__icontains=value) Q(slug__icontains=value) |
Q(description__icontains=value)
) )
def _content_type(self, queryset, name, values): def _content_type(self, queryset, name, values):
@ -317,6 +318,11 @@ class ConfigContextFilterSet(ChangeLoggedModelFilterSet):
to_field_name='slug', to_field_name='slug',
label='Tenant (slug)', label='Tenant (slug)',
) )
tag_id = django_filters.ModelMultipleChoiceFilter(
field_name='tags',
queryset=Tag.objects.all(),
label='Tag',
)
tag = django_filters.ModelMultipleChoiceFilter( tag = django_filters.ModelMultipleChoiceFilter(
field_name='tags__slug', field_name='tags__slug',
queryset=Tag.objects.all(), queryset=Tag.objects.all(),

View File

@ -4,7 +4,9 @@ from django.contrib.contenttypes.models import ContentType
from extras.choices import * from extras.choices import *
from extras.models import * from extras.models import *
from extras.utils import FeatureQuery from extras.utils import FeatureQuery
from utilities.forms import BulkEditForm, BulkEditNullBooleanSelect, ColorField, ContentTypeChoiceField, StaticSelect from utilities.forms import (
add_blank_choice, BulkEditForm, BulkEditNullBooleanSelect, ColorField, ContentTypeChoiceField, StaticSelect,
)
__all__ = ( __all__ = (
'ConfigContextBulkEditForm', 'ConfigContextBulkEditForm',
@ -55,7 +57,7 @@ class CustomLinkBulkEditForm(BulkEditForm):
required=False required=False
) )
button_class = forms.ChoiceField( button_class = forms.ChoiceField(
choices=CustomLinkButtonClassChoices, choices=add_blank_choice(CustomLinkButtonClassChoices),
required=False, required=False,
widget=StaticSelect() widget=StaticSelect()
) )
@ -117,21 +119,25 @@ class WebhookBulkEditForm(BulkEditForm):
widget=BulkEditNullBooleanSelect() widget=BulkEditNullBooleanSelect()
) )
http_method = forms.ChoiceField( http_method = forms.ChoiceField(
choices=WebhookHttpMethodChoices, choices=add_blank_choice(WebhookHttpMethodChoices),
required=False required=False,
label='HTTP method'
) )
payload_url = forms.CharField( payload_url = forms.CharField(
required=False required=False,
label='Payload URL'
) )
ssl_verification = forms.NullBooleanField( ssl_verification = forms.NullBooleanField(
required=False, required=False,
widget=BulkEditNullBooleanSelect() widget=BulkEditNullBooleanSelect(),
label='SSL verification'
) )
secret = forms.CharField( secret = forms.CharField(
required=False required=False
) )
ca_file_path = forms.CharField( ca_file_path = forms.CharField(
required=False required=False,
label='CA file path'
) )
class Meta: class Meta:
@ -185,7 +191,7 @@ class JournalEntryBulkEditForm(BulkEditForm):
widget=forms.MultipleHiddenInput widget=forms.MultipleHiddenInput
) )
kind = forms.ChoiceField( kind = forms.ChoiceField(
choices=JournalEntryKindChoices, choices=add_blank_choice(JournalEntryKindChoices),
required=False required=False
) )
comments = forms.CharField( comments = forms.CharField(

View File

@ -155,7 +155,7 @@ class TagFilterForm(FilterForm):
class ConfigContextFilterForm(FilterForm): class ConfigContextFilterForm(FilterForm):
field_groups = [ field_groups = [
['q', 'tag'], ['q', 'tag_id'],
['region_id', 'site_group_id', 'site_id'], ['region_id', 'site_group_id', 'site_id'],
['device_type_id', 'platform_id', 'role_id'], ['device_type_id', 'platform_id', 'role_id'],
['cluster_group_id', 'cluster_id'], ['cluster_group_id', 'cluster_id'],
@ -211,9 +211,8 @@ class ConfigContextFilterForm(FilterForm):
required=False, required=False,
label=_('Tenant') label=_('Tenant')
) )
tag = DynamicModelMultipleChoiceField( tag_id = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(), queryset=Tag.objects.all(),
to_field_name='slug',
required=False, required=False,
label=_('Tags') label=_('Tags')
) )

View File

@ -70,10 +70,23 @@ class Command(BaseCommand):
return namespace return namespace
def handle(self, **options): def handle(self, **options):
namespace = self.get_namespace()
# If Python code has been passed, execute it and exit. # If Python code has been passed, execute it and exit.
if options['command']: if options['command']:
exec(options['command'], self.get_namespace()) exec(options['command'], namespace)
return return
shell = code.interact(banner=BANNER_TEXT, local=self.get_namespace()) # Try to enable tab-complete
try:
import readline
import rlcompleter
except ModuleNotFoundError:
pass
else:
readline.set_completer(rlcompleter.Completer(namespace).complete)
readline.parse_and_bind('tab: complete')
# Run interactive shell
shell = code.interact(banner=BANNER_TEXT, local=namespace)
return shell return shell

View File

@ -29,6 +29,11 @@ CONFIGCONTEXT_ACTIONS = """
{% endif %} {% endif %}
""" """
OBJECTCHANGE_FULL_NAME = """
{% load helpers %}
{{ record.user.get_full_name|placeholder }}
"""
OBJECTCHANGE_OBJECT = """ OBJECTCHANGE_OBJECT = """
{% if record.changed_object and record.changed_object.get_absolute_url %} {% if record.changed_object and record.changed_object.get_absolute_url %}
<a href="{{ record.changed_object.get_absolute_url }}">{{ record.object_repr }}</a> <a href="{{ record.changed_object.get_absolute_url }}">{{ record.object_repr }}</a>
@ -204,6 +209,14 @@ class ObjectChangeTable(BaseTable):
linkify=True, linkify=True,
format=settings.SHORT_DATETIME_FORMAT format=settings.SHORT_DATETIME_FORMAT
) )
user_name = tables.Column(
verbose_name='Username'
)
full_name = tables.TemplateColumn(
template_code=OBJECTCHANGE_FULL_NAME,
verbose_name='Full Name',
orderable=False
)
action = ChoiceFieldColumn() action = ChoiceFieldColumn()
changed_object_type = ContentTypeColumn( changed_object_type = ContentTypeColumn(
verbose_name='Type' verbose_name='Type'
@ -219,7 +232,7 @@ class ObjectChangeTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = ObjectChange model = ObjectChange
fields = ('id', 'time', 'user_name', 'action', 'changed_object_type', 'object_repr', 'request_id') fields = ('id', 'time', 'user_name', 'full_name', 'action', 'changed_object_type', 'object_repr', 'request_id')
class ObjectJournalTable(BaseTable): class ObjectJournalTable(BaseTable):

View File

@ -12,7 +12,7 @@ from extras.filtersets import *
from extras.models import * from extras.models import *
from ipam.models import IPAddress from ipam.models import IPAddress
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
from utilities.testing import BaseFilterSetTests, ChangeLoggedFilterSetTests from utilities.testing import BaseFilterSetTests, ChangeLoggedFilterSetTests, create_tags
from virtualization.models import Cluster, ClusterGroup, ClusterType from virtualization.models import Cluster, ClusterGroup, ClusterType
@ -153,8 +153,8 @@ class ExportTemplateTestCase(TestCase, BaseFilterSetTests):
content_types = ContentType.objects.filter(model__in=['site', 'rack', 'device']) content_types = ContentType.objects.filter(model__in=['site', 'rack', 'device'])
export_templates = ( export_templates = (
ExportTemplate(name='Export Template 1', content_type=content_types[0], template_code='TESTING'), ExportTemplate(name='Export Template 1', content_type=content_types[0], template_code='TESTING', description='foobar1'),
ExportTemplate(name='Export Template 2', content_type=content_types[1], template_code='TESTING'), ExportTemplate(name='Export Template 2', content_type=content_types[1], template_code='TESTING', description='foobar2'),
ExportTemplate(name='Export Template 3', content_type=content_types[2], template_code='TESTING'), ExportTemplate(name='Export Template 3', content_type=content_types[2], template_code='TESTING'),
) )
ExportTemplate.objects.bulk_create(export_templates) ExportTemplate.objects.bulk_create(export_templates)
@ -167,6 +167,10 @@ class ExportTemplateTestCase(TestCase, BaseFilterSetTests):
params = {'content_type': ContentType.objects.get(model='site').pk} params = {'content_type': ContentType.objects.get(model='site').pk}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_description(self):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class ImageAttachmentTestCase(TestCase, BaseFilterSetTests): class ImageAttachmentTestCase(TestCase, BaseFilterSetTests):
queryset = ImageAttachment.objects.all() queryset = ImageAttachment.objects.all()
@ -429,6 +433,8 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
) )
Tenant.objects.bulk_create(tenants) Tenant.objects.bulk_create(tenants)
tags = create_tags('Alpha', 'Bravo', 'Charlie')
for i in range(0, 3): for i in range(0, 3):
is_active = bool(i % 2) is_active = bool(i % 2)
c = ConfigContext.objects.create( c = ConfigContext.objects.create(
@ -446,6 +452,7 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
c.clusters.set([clusters[i]]) c.clusters.set([clusters[i]])
c.tenant_groups.set([tenant_groups[i]]) c.tenant_groups.set([tenant_groups[i]])
c.tenants.set([tenants[i]]) c.tenants.set([tenants[i]])
c.tags.set([tags[i]])
def test_name(self): def test_name(self):
params = {'name': ['Config Context 1', 'Config Context 2']} params = {'name': ['Config Context 1', 'Config Context 2']}
@ -516,13 +523,20 @@ class ConfigContextTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]} params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_tenant_(self): def test_tenant(self):
tenants = Tenant.objects.all()[:2] tenants = Tenant.objects.all()[:2]
params = {'tenant_id': [tenants[0].pk, tenants[1].pk]} params = {'tenant_id': [tenants[0].pk, tenants[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'tenant': [tenants[0].slug, tenants[1].slug]} params = {'tenant': [tenants[0].slug, tenants[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_tags(self):
tags = Tag.objects.all()[:2]
params = {'tag_id': [tags[0].pk, tags[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'tag': [tags[0].slug, tags[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class TagTestCase(TestCase, ChangeLoggedFilterSetTests): class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = Tag.objects.all() queryset = Tag.objects.all()
@ -532,8 +546,8 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
def setUpTestData(cls): def setUpTestData(cls):
tags = ( tags = (
Tag(name='Tag 1', slug='tag-1', color='ff0000'), Tag(name='Tag 1', slug='tag-1', color='ff0000', description='foobar1'),
Tag(name='Tag 2', slug='tag-2', color='00ff00'), Tag(name='Tag 2', slug='tag-2', color='00ff00', description='foobar2'),
Tag(name='Tag 3', slug='tag-3', color='0000ff'), Tag(name='Tag 3', slug='tag-3', color='0000ff'),
) )
Tag.objects.bulk_create(tags) Tag.objects.bulk_create(tags)
@ -557,6 +571,10 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'color': ['ff0000', '00ff00']} params = {'color': ['ff0000', '00ff00']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_description(self):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_content_type(self): def test_content_type(self):
params = {'content_type': ['dcim.site', 'circuits.provider']} params = {'content_type': ['dcim.site', 'circuits.provider']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

View File

@ -448,7 +448,8 @@ class ObjectChangeLogView(View):
) )
objectchanges_table = tables.ObjectChangeTable( objectchanges_table = tables.ObjectChangeTable(
data=objectchanges, data=objectchanges,
orderable=False orderable=False,
user=request.user
) )
paginate_table(objectchanges_table, request) paginate_table(objectchanges_table, request)

View File

@ -126,7 +126,7 @@ class FHRPGroupSerializer(PrimaryModelSerializer):
class FHRPGroupAssignmentSerializer(PrimaryModelSerializer): class FHRPGroupAssignmentSerializer(PrimaryModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contactassignment-detail') url = serializers.HyperlinkedIdentityField(view_name='ipam-api:fhrpgroupassignment-detail')
group = NestedFHRPGroupSerializer() group = NestedFHRPGroupSerializer()
interface_type = ContentTypeField( interface_type = ContentTypeField(
queryset=ContentType.objects.all() queryset=ContentType.objects.all()

View File

@ -189,8 +189,10 @@ class ServiceProtocolChoices(ChoiceSet):
PROTOCOL_TCP = 'tcp' PROTOCOL_TCP = 'tcp'
PROTOCOL_UDP = 'udp' PROTOCOL_UDP = 'udp'
PROTOCOL_SCTP = 'sctp'
CHOICES = ( CHOICES = (
(PROTOCOL_TCP, 'TCP'), (PROTOCOL_TCP, 'TCP'),
(PROTOCOL_UDP, 'UDP'), (PROTOCOL_UDP, 'UDP'),
(PROTOCOL_SCTP, 'SCTP'),
) )

View File

@ -75,7 +75,7 @@ class VRFFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
class Meta: class Meta:
model = VRF model = VRF
fields = ['id', 'name', 'rd', 'enforce_unique'] fields = ['id', 'name', 'rd', 'enforce_unique', 'description']
class RouteTargetFilterSet(PrimaryModelFilterSet, TenancyFilterSet): class RouteTargetFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
@ -117,7 +117,7 @@ class RouteTargetFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
class Meta: class Meta:
model = RouteTarget model = RouteTarget
fields = ['id', 'name'] fields = ['id', 'name', 'description']
class RIRFilterSet(OrganizationalModelFilterSet): class RIRFilterSet(OrganizationalModelFilterSet):
@ -155,7 +155,7 @@ class AggregateFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
class Meta: class Meta:
model = Aggregate model = Aggregate
fields = ['id', 'date_added'] fields = ['id', 'date_added', 'description']
def search(self, queryset, name, value): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():
@ -203,7 +203,7 @@ class ASNFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
class Meta: class Meta:
model = ASN model = ASN
fields = ['id', 'asn'] fields = ['id', 'asn', 'description']
def search(self, queryset, name, value): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():
@ -225,7 +225,7 @@ class RoleFilterSet(OrganizationalModelFilterSet):
class Meta: class Meta:
model = Role model = Role
fields = ['id', 'name', 'slug'] fields = ['id', 'name', 'slug', 'description']
class PrefixFilterSet(PrimaryModelFilterSet, TenancyFilterSet): class PrefixFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
@ -354,7 +354,7 @@ class PrefixFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
class Meta: class Meta:
model = Prefix model = Prefix
fields = ['id', 'is_pool', 'mark_utilized'] fields = ['id', 'is_pool', 'mark_utilized', 'description']
def search(self, queryset, name, value): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():
@ -460,7 +460,7 @@ class IPRangeFilterSet(TenancyFilterSet, PrimaryModelFilterSet):
class Meta: class Meta:
model = IPRange model = IPRange
fields = ['id'] fields = ['id', 'description']
def search(self, queryset, name, value): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():
@ -839,7 +839,7 @@ class VLANFilterSet(PrimaryModelFilterSet, TenancyFilterSet):
class Meta: class Meta:
model = VLAN model = VLAN
fields = ['id', 'vid', 'name'] fields = ['id', 'vid', 'name', 'description']
def search(self, queryset, name, value): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():
@ -891,7 +891,7 @@ class ServiceFilterSet(PrimaryModelFilterSet):
class Meta: class Meta:
model = Service model = Service
fields = ['id', 'name', 'protocol'] fields = ['id', 'name', 'protocol', 'description']
def search(self, queryset, name, value): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():

View File

@ -27,8 +27,8 @@ class FHRPGroupTable(BaseTable):
orderable=False, orderable=False,
verbose_name='IP Addresses' verbose_name='IP Addresses'
) )
interface_count = tables.Column( member_count = tables.Column(
verbose_name='Interfaces' verbose_name='Members'
) )
tags = TagColumn( tags = TagColumn(
url_name='ipam:fhrpgroup_list' url_name='ipam:fhrpgroup_list'
@ -37,10 +37,10 @@ class FHRPGroupTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
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', 'member_count',
'tags', 'created', 'last_updated', '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', 'member_count')
class FHRPGroupAssignmentTable(BaseTable): class FHRPGroupAssignmentTable(BaseTable):

View File

@ -117,6 +117,10 @@ class ASNTable(BaseTable):
site_count = LinkedCountColumn( site_count = LinkedCountColumn(
viewname='dcim:site_list', viewname='dcim:site_list',
url_params={'asn_id': 'pk'}, url_params={'asn_id': 'pk'},
verbose_name='Site Count'
)
sites = tables.ManyToManyColumn(
linkify_item=True,
verbose_name='Sites' verbose_name='Sites'
) )
tenant = TenantColumn() tenant = TenantColumn()
@ -129,7 +133,7 @@ class ASNTable(BaseTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = ASN model = ASN
fields = ( fields = (
'pk', 'asn', 'asn_asdot', 'rir', 'site_count', 'tenant', 'description', 'actions', 'created', 'pk', 'asn', 'asn_asdot', 'rir', 'site_count', 'tenant', 'description', 'sites', 'actions', 'created',
'last_updated', 'tags', 'last_updated', 'tags',
) )
default_columns = ('pk', 'asn', 'rir', 'site_count', 'sites', 'description', 'tenant', 'actions') default_columns = ('pk', 'asn', 'rir', 'site_count', 'sites', 'description', 'tenant', 'actions')

View File

@ -35,8 +35,8 @@ class ASNTestCase(TestCase, ChangeLoggedFilterSetTests):
] ]
asns = ( asns = (
ASN(asn=64512, rir=rirs[0], tenant=tenants[0]), ASN(asn=64512, rir=rirs[0], tenant=tenants[0], description='foobar1'),
ASN(asn=64513, rir=rirs[0], tenant=tenants[0]), ASN(asn=64513, rir=rirs[0], tenant=tenants[0], description='foobar2'),
ASN(asn=64514, rir=rirs[0], tenant=tenants[1]), ASN(asn=64514, rir=rirs[0], tenant=tenants[1]),
ASN(asn=64515, rir=rirs[0], tenant=tenants[2]), ASN(asn=64515, rir=rirs[0], tenant=tenants[2]),
ASN(asn=64516, rir=rirs[0], tenant=tenants[3]), ASN(asn=64516, rir=rirs[0], tenant=tenants[3]),
@ -86,6 +86,10 @@ class ASNTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'site': [sites[0].slug, sites[1].slug]} params = {'site': [sites[0].slug, sites[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 9) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 9)
def test_description(self):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class VRFTestCase(TestCase, ChangeLoggedFilterSetTests): class VRFTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = VRF.objects.all() queryset = VRF.objects.all()
@ -117,8 +121,8 @@ class VRFTestCase(TestCase, ChangeLoggedFilterSetTests):
Tenant.objects.bulk_create(tenants) Tenant.objects.bulk_create(tenants)
vrfs = ( vrfs = (
VRF(name='VRF 1', rd='65000:100', tenant=tenants[0], enforce_unique=False), VRF(name='VRF 1', rd='65000:100', tenant=tenants[0], enforce_unique=False, description='foobar1'),
VRF(name='VRF 2', rd='65000:200', tenant=tenants[0], enforce_unique=False), VRF(name='VRF 2', rd='65000:200', tenant=tenants[0], enforce_unique=False, description='foobar2'),
VRF(name='VRF 3', rd='65000:300', tenant=tenants[1], enforce_unique=False), VRF(name='VRF 3', rd='65000:300', tenant=tenants[1], enforce_unique=False),
VRF(name='VRF 4', rd='65000:400', tenant=tenants[1], enforce_unique=True), VRF(name='VRF 4', rd='65000:400', tenant=tenants[1], enforce_unique=True),
VRF(name='VRF 5', rd='65000:500', tenant=tenants[2], enforce_unique=True), VRF(name='VRF 5', rd='65000:500', tenant=tenants[2], enforce_unique=True),
@ -174,6 +178,10 @@ class VRFTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]} params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_description(self):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class RouteTargetTestCase(TestCase, ChangeLoggedFilterSetTests): class RouteTargetTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = RouteTarget.objects.all() queryset = RouteTarget.objects.all()
@ -198,8 +206,8 @@ class RouteTargetTestCase(TestCase, ChangeLoggedFilterSetTests):
Tenant.objects.bulk_create(tenants) Tenant.objects.bulk_create(tenants)
route_targets = ( route_targets = (
RouteTarget(name='65000:1001', tenant=tenants[0]), RouteTarget(name='65000:1001', tenant=tenants[0], description='foobar1'),
RouteTarget(name='65000:1002', tenant=tenants[0]), RouteTarget(name='65000:1002', tenant=tenants[0], description='foobar2'),
RouteTarget(name='65000:1003', tenant=tenants[0]), RouteTarget(name='65000:1003', tenant=tenants[0]),
RouteTarget(name='65000:1004', tenant=tenants[0]), RouteTarget(name='65000:1004', tenant=tenants[0]),
RouteTarget(name='65000:2001', tenant=tenants[1]), RouteTarget(name='65000:2001', tenant=tenants[1]),
@ -256,6 +264,10 @@ class RouteTargetTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]} params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8)
def test_description(self):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class RIRTestCase(TestCase, ChangeLoggedFilterSetTests): class RIRTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = RIR.objects.all() queryset = RIR.objects.all()
@ -323,8 +335,8 @@ class AggregateTestCase(TestCase, ChangeLoggedFilterSetTests):
Tenant.objects.bulk_create(tenants) Tenant.objects.bulk_create(tenants)
aggregates = ( aggregates = (
Aggregate(prefix='10.1.0.0/16', rir=rirs[0], tenant=tenants[0], date_added='2020-01-01'), Aggregate(prefix='10.1.0.0/16', rir=rirs[0], tenant=tenants[0], date_added='2020-01-01', description='foobar1'),
Aggregate(prefix='10.2.0.0/16', rir=rirs[0], tenant=tenants[1], date_added='2020-01-02'), Aggregate(prefix='10.2.0.0/16', rir=rirs[0], tenant=tenants[1], date_added='2020-01-02', description='foobar2'),
Aggregate(prefix='10.3.0.0/16', rir=rirs[1], tenant=tenants[2], date_added='2020-01-03'), Aggregate(prefix='10.3.0.0/16', rir=rirs[1], tenant=tenants[2], date_added='2020-01-03'),
Aggregate(prefix='2001:db8:1::/48', rir=rirs[1], tenant=tenants[0], date_added='2020-01-04'), Aggregate(prefix='2001:db8:1::/48', rir=rirs[1], tenant=tenants[0], date_added='2020-01-04'),
Aggregate(prefix='2001:db8:2::/48', rir=rirs[2], tenant=tenants[1], date_added='2020-01-05'), Aggregate(prefix='2001:db8:2::/48', rir=rirs[2], tenant=tenants[1], date_added='2020-01-05'),
@ -340,6 +352,10 @@ class AggregateTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'date_added': ['2020-01-01', '2020-01-02']} params = {'date_added': ['2020-01-01', '2020-01-02']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_description(self):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
# TODO: Test for multiple values # TODO: Test for multiple values
def test_prefix(self): def test_prefix(self):
params = {'prefix': '10.1.0.0/16'} params = {'prefix': '10.1.0.0/16'}
@ -375,8 +391,8 @@ class RoleTestCase(TestCase, ChangeLoggedFilterSetTests):
def setUpTestData(cls): def setUpTestData(cls):
roles = ( roles = (
Role(name='Role 1', slug='role-1'), Role(name='Role 1', slug='role-1', description='foobar1'),
Role(name='Role 2', slug='role-2'), Role(name='Role 2', slug='role-2', description='foobar2'),
Role(name='Role 3', slug='role-3'), Role(name='Role 3', slug='role-3'),
) )
Role.objects.bulk_create(roles) Role.objects.bulk_create(roles)
@ -389,6 +405,10 @@ class RoleTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'slug': ['role-1', 'role-2']} params = {'slug': ['role-1', 'role-2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_description(self):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class PrefixTestCase(TestCase, ChangeLoggedFilterSetTests): class PrefixTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = Prefix.objects.all() queryset = Prefix.objects.all()
@ -467,8 +487,8 @@ class PrefixTestCase(TestCase, ChangeLoggedFilterSetTests):
Tenant.objects.bulk_create(tenants) Tenant.objects.bulk_create(tenants)
prefixes = ( prefixes = (
Prefix(prefix='10.0.0.0/24', tenant=None, site=None, vrf=None, vlan=None, role=None, is_pool=True, mark_utilized=True), Prefix(prefix='10.0.0.0/24', tenant=None, site=None, vrf=None, vlan=None, role=None, is_pool=True, mark_utilized=True, description='foobar1'),
Prefix(prefix='10.0.1.0/24', tenant=tenants[0], site=sites[0], vrf=vrfs[0], vlan=vlans[0], role=roles[0]), Prefix(prefix='10.0.1.0/24', tenant=tenants[0], site=sites[0], vrf=vrfs[0], vlan=vlans[0], role=roles[0], description='foobar2'),
Prefix(prefix='10.0.2.0/24', tenant=tenants[1], site=sites[1], vrf=vrfs[1], vlan=vlans[1], role=roles[1], status=PrefixStatusChoices.STATUS_DEPRECATED), Prefix(prefix='10.0.2.0/24', tenant=tenants[1], site=sites[1], vrf=vrfs[1], vlan=vlans[1], role=roles[1], status=PrefixStatusChoices.STATUS_DEPRECATED),
Prefix(prefix='10.0.3.0/24', tenant=tenants[2], site=sites[2], vrf=vrfs[2], vlan=vlans[2], role=roles[2], status=PrefixStatusChoices.STATUS_RESERVED), Prefix(prefix='10.0.3.0/24', tenant=tenants[2], site=sites[2], vrf=vrfs[2], vlan=vlans[2], role=roles[2], status=PrefixStatusChoices.STATUS_RESERVED),
Prefix(prefix='2001:db8::/64', tenant=None, site=None, vrf=None, vlan=None, role=None, is_pool=True, mark_utilized=True), Prefix(prefix='2001:db8::/64', tenant=None, site=None, vrf=None, vlan=None, role=None, is_pool=True, mark_utilized=True),
@ -601,6 +621,10 @@ class PrefixTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]} params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_description(self):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class IPRangeTestCase(TestCase, ChangeLoggedFilterSetTests): class IPRangeTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = IPRange.objects.all() queryset = IPRange.objects.all()
@ -639,8 +663,8 @@ class IPRangeTestCase(TestCase, ChangeLoggedFilterSetTests):
Tenant.objects.bulk_create(tenants) Tenant.objects.bulk_create(tenants)
ip_ranges = ( ip_ranges = (
IPRange(start_address='10.0.1.100/24', end_address='10.0.1.199/24', size=100, vrf=None, tenant=None, role=None, status=IPRangeStatusChoices.STATUS_ACTIVE), IPRange(start_address='10.0.1.100/24', end_address='10.0.1.199/24', size=100, vrf=None, tenant=None, role=None, status=IPRangeStatusChoices.STATUS_ACTIVE, description='foobar1'),
IPRange(start_address='10.0.2.100/24', end_address='10.0.2.199/24', size=100, vrf=vrfs[0], tenant=tenants[0], role=roles[0], status=IPRangeStatusChoices.STATUS_ACTIVE), IPRange(start_address='10.0.2.100/24', end_address='10.0.2.199/24', size=100, vrf=vrfs[0], tenant=tenants[0], role=roles[0], status=IPRangeStatusChoices.STATUS_ACTIVE, description='foobar2'),
IPRange(start_address='10.0.3.100/24', end_address='10.0.3.199/24', size=100, vrf=vrfs[1], tenant=tenants[1], role=roles[1], status=IPRangeStatusChoices.STATUS_DEPRECATED), IPRange(start_address='10.0.3.100/24', end_address='10.0.3.199/24', size=100, vrf=vrfs[1], tenant=tenants[1], role=roles[1], status=IPRangeStatusChoices.STATUS_DEPRECATED),
IPRange(start_address='10.0.4.100/24', end_address='10.0.4.199/24', size=100, vrf=vrfs[2], tenant=tenants[2], role=roles[2], status=IPRangeStatusChoices.STATUS_RESERVED), IPRange(start_address='10.0.4.100/24', end_address='10.0.4.199/24', size=100, vrf=vrfs[2], tenant=tenants[2], role=roles[2], status=IPRangeStatusChoices.STATUS_RESERVED),
IPRange(start_address='2001:db8:0:1::1/64', end_address='2001:db8:0:1::100/64', size=100, vrf=None, tenant=None, role=None, status=IPRangeStatusChoices.STATUS_ACTIVE), IPRange(start_address='2001:db8:0:1::1/64', end_address='2001:db8:0:1::100/64', size=100, vrf=None, tenant=None, role=None, status=IPRangeStatusChoices.STATUS_ACTIVE),
@ -692,6 +716,10 @@ class IPRangeTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]} params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_description(self):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests): class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = IPAddress.objects.all() queryset = IPAddress.objects.all()
@ -1201,8 +1229,8 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
vlans = ( vlans = (
# Create one VLAN per VLANGroup # Create one VLAN per VLANGroup
VLAN(vid=1, name='Region 1', group=groups[0]), VLAN(vid=1, name='Region 1', group=groups[0], description='foobar1'),
VLAN(vid=2, name='Region 2', group=groups[1]), VLAN(vid=2, name='Region 2', group=groups[1], description='foobar2'),
VLAN(vid=3, name='Region 3', group=groups[2]), VLAN(vid=3, name='Region 3', group=groups[2]),
VLAN(vid=4, name='Site Group 1', group=groups[3]), VLAN(vid=4, name='Site Group 1', group=groups[3]),
VLAN(vid=5, name='Site Group 2', group=groups[4]), VLAN(vid=5, name='Site Group 2', group=groups[4]),
@ -1271,6 +1299,10 @@ class VLANTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'group': [groups[0].slug, groups[1].slug]} params = {'group': [groups[0].slug, groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_description(self):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_role(self): def test_role(self):
roles = Role.objects.all()[:2] roles = Role.objects.all()[:2]
params = {'role_id': [roles[0].pk, roles[1].pk]} params = {'role_id': [roles[0].pk, roles[1].pk]}
@ -1337,8 +1369,8 @@ class ServiceTestCase(TestCase, ChangeLoggedFilterSetTests):
VirtualMachine.objects.bulk_create(virtual_machines) VirtualMachine.objects.bulk_create(virtual_machines)
services = ( services = (
Service(device=devices[0], name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[1001]), Service(device=devices[0], name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[1001], description='foobar1'),
Service(device=devices[1], name='Service 2', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[1002]), Service(device=devices[1], name='Service 2', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[1002], description='foobar2'),
Service(device=devices[2], name='Service 3', protocol=ServiceProtocolChoices.PROTOCOL_UDP, ports=[1003]), Service(device=devices[2], name='Service 3', protocol=ServiceProtocolChoices.PROTOCOL_UDP, ports=[1003]),
Service(virtual_machine=virtual_machines[0], name='Service 4', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[2001]), Service(virtual_machine=virtual_machines[0], name='Service 4', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[2001]),
Service(virtual_machine=virtual_machines[1], name='Service 5', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[2002]), Service(virtual_machine=virtual_machines[1], name='Service 5', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[2002]),
@ -1354,6 +1386,10 @@ class ServiceTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'protocol': ServiceProtocolChoices.PROTOCOL_TCP} params = {'protocol': ServiceProtocolChoices.PROTOCOL_TCP}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_description(self):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_port(self): def test_port(self):
params = {'port': '1001'} params = {'port': '1001'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)

View File

@ -1038,7 +1038,6 @@ class ServiceListView(generic.ObjectListView):
filterset = filtersets.ServiceFilterSet filterset = filtersets.ServiceFilterSet
filterset_form = forms.ServiceFilterForm filterset_form = forms.ServiceFilterForm
table = tables.ServiceTable table = tables.ServiceTable
action_buttons = ('import', 'export')
class ServiceView(generic.ObjectView): class ServiceView(generic.ObjectView):

View File

@ -20,19 +20,28 @@ PARAMS = (
name='BANNER_LOGIN', name='BANNER_LOGIN',
label='Login banner', label='Login banner',
default='', default='',
description="Additional content to display on the login page" description="Additional content to display on the login page",
field_kwargs={
'widget': forms.Textarea(),
},
), ),
ConfigParam( ConfigParam(
name='BANNER_TOP', name='BANNER_TOP',
label='Top banner', label='Top banner',
default='', default='',
description="Additional content to display at the top of every page" description="Additional content to display at the top of every page",
field_kwargs={
'widget': forms.Textarea(),
},
), ),
ConfigParam( ConfigParam(
name='BANNER_BOTTOM', name='BANNER_BOTTOM',
label='Bottom banner', label='Bottom banner',
default='', default='',
description="Additional content to display at the bottom of every page" description="Additional content to display at the bottom of every page",
field_kwargs={
'widget': forms.Textarea(),
},
), ),
# IPAM # IPAM

View File

@ -18,7 +18,7 @@ from ipam.filtersets import (
from ipam.models import Aggregate, ASN, IPAddress, Prefix, VLAN, VRF from ipam.models import Aggregate, ASN, IPAddress, Prefix, VLAN, VRF
from ipam.tables import AggregateTable, ASNTable, IPAddressTable, PrefixTable, VLANTable, VRFTable from ipam.tables import AggregateTable, ASNTable, IPAddressTable, PrefixTable, VLANTable, VRFTable
from tenancy.filtersets import ContactFilterSet, TenantFilterSet from tenancy.filtersets import ContactFilterSet, TenantFilterSet
from tenancy.models import Contact, Tenant from tenancy.models import Contact, Tenant, ContactAssignment
from tenancy.tables import ContactTable, TenantTable from tenancy.tables import ContactTable, TenantTable
from utilities.utils import count_related from utilities.utils import count_related
from virtualization.filtersets import ClusterFilterSet, VirtualMachineFilterSet from virtualization.filtersets import ClusterFilterSet, VirtualMachineFilterSet
@ -186,7 +186,7 @@ SEARCH_TYPES = OrderedDict((
'url': 'tenancy:tenant_list', 'url': 'tenancy:tenant_list',
}), }),
('contact', { ('contact', {
'queryset': Contact.objects.prefetch_related('group', 'assignments'), 'queryset': Contact.objects.prefetch_related('group', 'assignments').annotate(assignment_count=count_related(ContactAssignment, 'contact')),
'filterset': ContactFilterSet, 'filterset': ContactFilterSet,
'table': ContactTable, 'table': ContactTable,
'url': 'tenancy:contact_list', 'url': 'tenancy:contact_list',

View File

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

View File

@ -133,7 +133,7 @@ class HomeView(View):
changelog = ObjectChange.objects.restrict(request.user, 'view').prefetch_related( changelog = ObjectChange.objects.restrict(request.user, 'view').prefetch_related(
'user', 'changed_object_type' 'user', 'changed_object_type'
)[:10] )[:10]
changelog_table = ObjectChangeTable(changelog) changelog_table = ObjectChangeTable(changelog, user=request.user)
# Check whether a new release is available. (Only for staff/superusers.) # Check whether a new release is available. (Only for staff/superusers.)
new_release = None new_release = None

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -6,7 +6,16 @@ type ShowHideMap = {
* *
* @example vlangroup_edit * @example vlangroup_edit
*/ */
[view: string]: { [view: string]: string;
};
type ShowHideLayout = {
/**
* Name of layout config
*
* @example vlangroup
*/
[config: string]: {
/** /**
* Default layout. * Default layout.
*/ */
@ -19,15 +28,15 @@ type ShowHideMap = {
}; };
/** /**
* Mapping of scope names to arrays of object types whose fields should be hidden or shown when * Mapping of layout names to arrays of object types whose fields should be hidden or shown when
* the scope type (key) is selected. * the scope type (key) is selected.
* *
* For example, if `region` is the scope type, the fields with IDs listed in * For example, if `region` is the scope type, the fields with IDs listed in
* showHideMap.region.hide should be hidden, and the fields with IDs listed in * showHideMap.region.hide should be hidden, and the fields with IDs listed in
* showHideMap.region.show should be shown. * showHideMap.region.show should be shown.
*/ */
const showHideMap: ShowHideMap = { const showHideLayout: ShowHideLayout = {
vlangroup_edit: { vlangroup: {
region: { region: {
hide: ['id_sitegroup', 'id_site', 'id_location', 'id_rack', 'id_clustergroup', 'id_cluster'], hide: ['id_sitegroup', 'id_site', 'id_location', 'id_rack', 'id_clustergroup', 'id_cluster'],
show: ['id_region'], show: ['id_region'],
@ -70,6 +79,17 @@ const showHideMap: ShowHideMap = {
}, },
}, },
}; };
/**
* Mapping of view names to layout configurations
*
* For example, if `vlangroup_add` is the view, use the layout configuration `vlangroup`.
*/
const showHideMap: ShowHideMap = {
vlangroup_add: 'vlangroup',
vlangroup_edit: 'vlangroup',
};
/** /**
* Toggle visibility of a given element's parent. * Toggle visibility of a given element's parent.
* @param query CSS Query. * @param query CSS Query.
@ -94,8 +114,9 @@ function toggleParentVisibility(query: string, action: 'show' | 'hide') {
function handleScopeChange<P extends keyof ShowHideMap>(view: P, element: HTMLSelectElement) { function handleScopeChange<P extends keyof ShowHideMap>(view: P, element: HTMLSelectElement) {
// Scope type's innerText looks something like `DCIM > region`. // Scope type's innerText looks something like `DCIM > region`.
const scopeType = element.options[element.selectedIndex].innerText.toLowerCase(); const scopeType = element.options[element.selectedIndex].innerText.toLowerCase();
const layoutConfig = showHideMap[view];
for (const [scope, fields] of Object.entries(showHideMap[view])) { for (const [scope, fields] of Object.entries(showHideLayout[layoutConfig])) {
// If the scope type ends with the specified scope, toggle its field visibility according to // If the scope type ends with the specified scope, toggle its field visibility according to
// the show/hide values. // the show/hide values.
if (scopeType.endsWith(scope)) { if (scopeType.endsWith(scope)) {
@ -109,7 +130,7 @@ function handleScopeChange<P extends keyof ShowHideMap>(view: P, element: HTMLSe
break; break;
} else { } else {
// Otherwise, hide all fields. // Otherwise, hide all fields.
for (const field of showHideMap[view].default.hide) { for (const field of showHideLayout[layoutConfig].default.hide) {
toggleParentVisibility(`#${field}`, 'hide'); toggleParentVisibility(`#${field}`, 'hide');
} }
} }

View File

@ -8,11 +8,12 @@ import { DynamicParamsMap } from './dynamicParams';
import { isStaticParams, isOption } from './types'; import { isStaticParams, isOption } from './types';
import { import {
hasMore, hasMore,
isTruthy,
hasError, hasError,
getElement, isTruthy,
getApiData, getApiData,
getElement,
isApiError, isApiError,
replaceAll,
createElement, createElement,
uniqueByProperty, uniqueByProperty,
findFirstAdjacent, findFirstAdjacent,
@ -461,7 +462,7 @@ export class APISelect {
// Set any primitive k/v pairs as data attributes on each option. // Set any primitive k/v pairs as data attributes on each option.
for (const [k, v] of Object.entries(result)) { for (const [k, v] of Object.entries(result)) {
if (!['id', 'slug'].includes(k) && ['string', 'number', 'boolean'].includes(typeof v)) { if (!['id', 'slug'].includes(k) && ['string', 'number', 'boolean'].includes(typeof v)) {
const key = k.replaceAll('_', '-'); const key = replaceAll(k, '_', '-');
data[key] = String(v); data[key] = String(v);
} }
// Set option to disabled if the result contains a matching key and is truthy. // Set option to disabled if the result contains a matching key and is truthy.
@ -659,7 +660,7 @@ export class APISelect {
for (const [key, value] of this.pathValues.entries()) { for (const [key, value] of this.pathValues.entries()) {
for (const result of this.url.matchAll(new RegExp(`({{${key}}})`, 'g'))) { for (const result of this.url.matchAll(new RegExp(`({{${key}}})`, 'g'))) {
if (isTruthy(value)) { if (isTruthy(value)) {
url = url.replaceAll(result[1], value.toString()); url = replaceAll(url, result[1], value.toString());
} }
} }
} }
@ -741,7 +742,7 @@ export class APISelect {
* @param id DOM ID of the other element. * @param id DOM ID of the other element.
*/ */
private updatePathValues(id: string): void { private updatePathValues(id: string): void {
const key = id.replaceAll(/^id_/gi, ''); const key = replaceAll(id, /^id_/i, '');
const element = getElement<HTMLSelectElement>(`id_${key}`); const element = getElement<HTMLSelectElement>(`id_${key}`);
if (element !== null) { if (element !== null) {
// If this element's URL contains Django template tags ({{), replace the template tag // If this element's URL contains Django template tags ({{), replace the template tag
@ -919,16 +920,18 @@ export class APISelect {
style.setAttribute('data-netbox', id); style.setAttribute('data-netbox', id);
// Scope the CSS to apply both the list item and the selected item. // Scope the CSS to apply both the list item and the selected item.
style.innerHTML = ` style.innerHTML = replaceAll(
`
div.ss-values div.ss-value[data-id="${id}"], div.ss-values div.ss-value[data-id="${id}"],
div.ss-list div.ss-option:not(.ss-disabled)[data-id="${id}"] div.ss-list div.ss-option:not(.ss-disabled)[data-id="${id}"]
{ {
background-color: ${bg} !important; background-color: ${bg} !important;
color: ${fg} !important; color: ${fg} !important;
} }
` `,
.replaceAll('\n', '') '\n',
.trim(); '',
).trim();
// Add the style element to the DOM. // Add the style element to the DOM.
document.head.appendChild(style); document.head.appendChild(style);

View File

@ -11,15 +11,6 @@ function saveTableConfig(): void {
} }
} }
/**
* Delete all selected columns, which reverts the user's preferences to the default column set.
*/
function resetTableConfig(): void {
for (const element of getElements<HTMLSelectElement>('select[name="columns"]')) {
element.value = '';
}
}
/** /**
* Add columns to the table config select element. * Add columns to the table config select element.
*/ */
@ -53,7 +44,10 @@ function removeColumns(event: Event): void {
/** /**
* Submit form configuration to the NetBox API. * Submit form configuration to the NetBox API.
*/ */
async function submitFormConfig(url: string, formConfig: Dict<Dict>): Promise<APIResponse<APIUserConfig>> { async function submitFormConfig(
url: string,
formConfig: Dict<Dict>,
): Promise<APIResponse<APIUserConfig>> {
return await apiPatch<APIUserConfig>(url, formConfig); return await apiPatch<APIUserConfig>(url, formConfig);
} }
@ -72,23 +66,44 @@ function handleSubmit(event: Event): void {
const toast = createToast( const toast = createToast(
'danger', 'danger',
'Error Updating Table Configuration', 'Error Updating Table Configuration',
'No API path defined for configuration form.' 'No API path defined for configuration form.',
); );
toast.show(); toast.show();
return; return;
} }
// Determine if the form action is to reset the table config.
const reset = document.activeElement?.getAttribute('value') === 'Reset';
// Create an array from the dot-separated config path. E.g. tables.DevicePowerOutletTable becomes
// ['tables', 'DevicePowerOutletTable']
const path = element.getAttribute('data-config-root')?.split('.') ?? [];
if (reset) {
// If we're resetting the table config, create an empty object for this table. E.g.
// tables.PlatformTable becomes {tables: PlatformTable: {}}
const data = path.reduceRight<Dict<Dict>>((value, key) => ({ [key]: value }), {});
// Submit the reset for configuration to the API.
submitFormConfig(url, data).then(res => {
if (hasError(res)) {
const toast = createToast('danger', 'Error Resetting Table Configuration', res.error);
toast.show();
} else {
location.reload();
}
});
return;
}
// Get all the selected options from any select element in the form. // Get all the selected options from any select element in the form.
const options = getSelectedOptions(element); const options = getSelectedOptions(element, 'select[name=columns]');
// Create an object mapping the select element's name to all selected options for that element. // Create an object mapping the select element's name to all selected options for that element.
const formData: Dict<Dict<string>> = Object.assign( const formData: Dict<Dict<string>> = Object.assign(
{}, {},
...options.map(opt => ({ [opt.name]: opt.options })), ...options.map(opt => ({ [opt.name]: opt.options })),
); );
// Create an array from the dot-separated config path. E.g. tables.DevicePowerOutletTable becomes
// ['tables', 'DevicePowerOutletTable']
const path = element.getAttribute('data-config-root')?.split('.') ?? [];
// Create an object mapping the configuration path to the select element names, which contain the // Create an object mapping the configuration path to the select element names, which contain the
// selection options. E.g. {tables: {DevicePowerOutletTable: {columns: ['label', 'type']}}} // selection options. E.g. {tables: {DevicePowerOutletTable: {columns: ['label', 'type']}}}
@ -112,9 +127,6 @@ export function initTableConfig(): void {
for (const element of getElements<HTMLButtonElement>('#save_tableconfig')) { for (const element of getElements<HTMLButtonElement>('#save_tableconfig')) {
element.addEventListener('click', saveTableConfig); element.addEventListener('click', saveTableConfig);
} }
for (const element of getElements<HTMLButtonElement>('#reset_tableconfig')) {
element.addEventListener('click', resetTableConfig);
}
for (const element of getElements<HTMLButtonElement>('#add_columns')) { for (const element of getElements<HTMLButtonElement>('#add_columns')) {
element.addEventListener('click', addColumns); element.addEventListener('click', addColumns);
} }

View File

@ -1,4 +1,4 @@
import { getElements, findFirstAdjacent } from '../util'; import { getElements, replaceAll, findFirstAdjacent } from '../util';
type InterfaceState = 'enabled' | 'disabled'; type InterfaceState = 'enabled' | 'disabled';
type ShowHide = 'show' | 'hide'; type ShowHide = 'show' | 'hide';
@ -105,9 +105,9 @@ class ButtonState {
*/ */
private toggleButton(): void { private toggleButton(): void {
if (this.buttonState === 'show') { if (this.buttonState === 'show') {
this.button.innerText = this.button.innerText.replaceAll('Show', 'Hide'); this.button.innerText = replaceAll(this.button.innerText, 'Show', 'Hide');
} else if (this.buttonState === 'hide') { } else if (this.buttonState === 'hide') {
this.button.innerText = this.button.innerText.replaceAll('Hide', 'Show'); this.button.innerText = replaceAll(this.button.innerHTML, 'Hide', 'Show');
} }
} }

View File

@ -231,11 +231,15 @@ export function scrollTo(element: Element, offset: number = 0): void {
* Iterate through a select element's options and return an array of options that are selected. * Iterate through a select element's options and return an array of options that are selected.
* *
* @param base Select element. * @param base Select element.
* @param selector Optionally specify a selector. 'select' by default.
* @returns Array of selected options. * @returns Array of selected options.
*/ */
export function getSelectedOptions<E extends HTMLElement>(base: E): SelectedOption[] { export function getSelectedOptions<E extends HTMLElement>(
base: E,
selector: string = 'select',
): SelectedOption[] {
let selected = [] as SelectedOption[]; let selected = [] as SelectedOption[];
for (const element of base.querySelectorAll<HTMLSelectElement>('select')) { for (const element of base.querySelectorAll<HTMLSelectElement>(selector)) {
if (element !== null) { if (element !== null) {
const select = { name: element.name, options: [] } as SelectedOption; const select = { name: element.name, options: [] } as SelectedOption;
for (const option of element.options) { for (const option of element.options) {
@ -315,7 +319,7 @@ export function* getRowValues(table: HTMLTableRowElement): Generator<string> {
for (const element of table.querySelectorAll<HTMLTableCellElement>('td')) { for (const element of table.querySelectorAll<HTMLTableCellElement>('td')) {
if (element !== null) { if (element !== null) {
if (isTruthy(element.innerText) && element.innerText !== '—') { if (isTruthy(element.innerText) && element.innerText !== '—') {
yield element.innerText.replaceAll(/[\n\r]/g, '').trim(); yield replaceAll(element.innerText, '[\n\r]', '').trim();
} }
} }
} }
@ -436,3 +440,49 @@ export function uniqueByProperty<T extends unknown, P extends keyof T>(arr: T[],
} }
return Array.from(baseMap.values()); return Array.from(baseMap.values());
} }
/**
* Replace all occurrences of a pattern with a replacement string.
*
* This is a browser-compatibility-focused drop-in replacement for `String.prototype.replaceAll()`,
* introduced in ES2021.
*
* @param input string to be processed.
* @param pattern regex pattern string or RegExp object to search for.
* @param replacement replacement substring with which `pattern` matches will be replaced.
* @returns processed version of `input`.
*/
export function replaceAll(input: string, pattern: string | RegExp, replacement: string): string {
// Ensure input is a string.
if (typeof input !== 'string') {
throw new TypeError("replaceAll 'input' argument must be a string");
}
// Ensure pattern is a string or RegExp.
if (typeof pattern !== 'string' && !(pattern instanceof RegExp)) {
throw new TypeError("replaceAll 'pattern' argument must be a string or RegExp instance");
}
// Ensure replacement is able to be stringified.
switch (typeof replacement) {
case 'boolean':
replacement = String(replacement);
break;
case 'number':
replacement = String(replacement);
break;
case 'string':
break;
default:
throw new TypeError("replaceAll 'replacement' argument must be stringifyable");
}
if (pattern instanceof RegExp) {
// Add global flag to existing RegExp object and deduplicate
const flags = Array.from(new Set([...pattern.flags.split(''), 'g'])).join('');
pattern = new RegExp(pattern.source, flags);
} else {
// Create a RegExp object with the global flag set.
pattern = new RegExp(pattern, 'g');
}
return input.replace(pattern, replacement);
}

View File

@ -23,7 +23,6 @@ $theme-colors: (
'danger': $danger, 'danger': $danger,
'light': $light, 'light': $light,
'dark': $dark, 'dark': $dark,
// General-purpose palette // General-purpose palette
'blue': $blue-300, 'blue': $blue-300,
'indigo': $indigo-300, 'indigo': $indigo-300,
@ -37,7 +36,7 @@ $theme-colors: (
'cyan': $cyan-300, 'cyan': $cyan-300,
'gray': $gray-300, 'gray': $gray-300,
'black': $black, 'black': $black,
'white': $white, 'white': $white
); );
// Gradient // Gradient
@ -146,7 +145,7 @@ $nav-tabs-link-active-border-color: $gray-800 $gray-800 $nav-tabs-link-active-bg
$nav-pills-link-active-color: $component-active-color; $nav-pills-link-active-color: $component-active-color;
$nav-pills-link-active-bg: $component-active-bg; $nav-pills-link-active-bg: $component-active-bg;
$navbar-light-color: $darkest; $navbar-light-color: $navbar-dark-color;
$navbar-light-toggler-icon-bg: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'><path stroke='#{$navbar-light-color}' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/></svg>"); $navbar-light-toggler-icon-bg: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 30 30'><path stroke='#{$navbar-light-color}' stroke-linecap='round' stroke-miterlimit='10' stroke-width='2' d='M4 7h22M4 15h22M4 23h22'/></svg>");
$navbar-light-toggler-border-color: $gray-700; $navbar-light-toggler-border-color: $gray-700;

View File

@ -139,7 +139,7 @@
<body> <body>
<script type="text/javascript"> <script type="text/javascript">
(function() { function checkSideNav() {
// Check localStorage to see if the sidebar should be pinned. // Check localStorage to see if the sidebar should be pinned.
var sideNavRaw = localStorage.getItem('netbox-sidenav'); var sideNavRaw = localStorage.getItem('netbox-sidenav');
// Determine if the device has a small screeen. This media query is equivalent to // Determine if the device has a small screeen. This media query is equivalent to
@ -154,11 +154,15 @@
// jumpy/glitchy behavior on page reloads. // jumpy/glitchy behavior on page reloads.
document.body.setAttribute('data-sidenav-pinned', ''); document.body.setAttribute('data-sidenav-pinned', '');
document.body.setAttribute('data-sidenav-show', ''); document.body.setAttribute('data-sidenav-show', '');
document.body.removeAttribute('data-sidenav-hidden');
} else { } else {
document.body.removeAttribute('data-sidenav-pinned');
document.body.setAttribute('data-sidenav-hidden', ''); document.body.setAttribute('data-sidenav-hidden', '');
} }
} }
})(); }
window.addEventListener('resize', function(){ checkSideNav() });
checkSideNav();
</script> </script>
{# Page layout #} {# Page layout #}

View File

@ -108,11 +108,12 @@
{# Page footer #} {# Page footer #}
<footer class="footer container-fluid"> <footer class="footer container-fluid">
{% block footer %}
<div class="row align-items-center justify-content-between mx-0"> <div class="row align-items-center justify-content-between mx-0">
{# Docs & Community Links #}
<div class="col-sm-12 col-md-auto fs-4 noprint"> <div class="col-sm-12 col-md-auto fs-4 noprint">
<nav class="nav justify-content-center justify-content-lg-start"> <nav class="nav justify-content-center justify-content-lg-start">
{% block footer_links %}
{# Documentation #} {# Documentation #}
<a type="button" class="nav-link" href="{% static 'docs/' %}" target="_blank"> <a type="button" class="nav-link" href="{% static 'docs/' %}" target="_blank">
<i title="Docs" class="mdi mdi-book-open-variant text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i> <i title="Docs" class="mdi mdi-book-open-variant text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
@ -144,16 +145,17 @@
<a type="button" class="nav-link" href="https://netdev.chat/" target="_blank"> <a type="button" class="nav-link" href="https://netdev.chat/" target="_blank">
<i title="Community" class="mdi mdi-slack text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i> <i title="Community" class="mdi mdi-slack text-primary" data-bs-placement="top" data-bs-toggle="tooltip"></i>
</a> </a>
{% endblock footer_links %}
</nav> </nav>
</div> </div>
{# System Info #}
<div class="col-sm-12 col-md-auto text-center text-lg-end text-muted"> <div class="col-sm-12 col-md-auto text-center text-lg-end text-muted">
<span class="d-block d-md-inline">{% annotated_now %} {% now 'T' %}</span> <span class="d-block d-md-inline">{% annotated_now %} {% now 'T' %}</span>
<span class="ms-md-3 d-block d-md-inline">{{ settings.HOSTNAME }} (v{{ settings.VERSION }})</span> <span class="ms-md-3 d-block d-md-inline">{{ settings.HOSTNAME }} (v{{ settings.VERSION }})</span>
</div> </div>
</div> </div>
{% endblock footer %}
</footer> </footer>
</div> </div>

View File

@ -33,6 +33,7 @@
<a href="{{ object.site.get_absolute_url }}">{{ object.site }}</a> <a href="{{ object.site.get_absolute_url }}">{{ object.site }}</a>
</td> </td>
</tr> </tr>
<tr>
<th scope="row">Location</th> <th scope="row">Location</th>
<td> <td>
{% if object.location %} {% if object.location %}
@ -129,7 +130,7 @@
<a href="{{ vc_member.get_absolute_url }}">{{ vc_member }}</a> <a href="{{ vc_member.get_absolute_url }}">{{ vc_member }}</a>
</td> </td>
<td> <td>
{% badge vc_member.vc_position %} {% badge vc_member.vc_position show_empty=True %}
</td> </td>
<td> <td>
{% if object.virtual_chassis.master == vc_member %}<i class="mdi mdi-check-bold"></i>{% endif %} {% if object.virtual_chassis.master == vc_member %}<i class="mdi mdi-check-bold"></i>{% endif %}

View File

@ -183,42 +183,98 @@
</div> </div>
<div class="col col-md-6"> <div class="col col-md-6">
<div class="card"> <div class="card">
<h5 class="card-header">Stats</h5> <h5 class="card-header">Related Objects</h5>
<div class="card-body"> <div class="card-body">
<div class="row"> <table class="table table-hover attr-table">
<div class="col col-md-4 text-center"> <tr>
<h2><a href="{% url 'dcim:location_list' %}?site_id={{ object.pk }}" class="btn {% if stats.location_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.location_count }}</a></h2> <th scope="row">Locations</th>
<p>Locations</p> <td class="text-end">
</div> {% if stats.location_count %}
<div class="col col-md-4 text-center"> <a href="{% url 'dcim:location_list' %}?site_id={{ object.pk }}">{{ stats.location_count }}</a>
<h2><a href="{% url 'dcim:rack_list' %}?site_id={{ object.pk }}" class="btn {% if stats.rack_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.rack_count }}</a></h2> {% else %}
<p>Racks</p> {{ ''|placeholder }}
</div> {% endif %}
<div class="col col-md-4 text-center"> </td>
<h2><a href="{% url 'dcim:device_list' %}?site_id={{ object.pk }}" class="btn {% if stats.device_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.device_count }}</a></h2> </tr>
<p>Devices</p> <tr>
</div> <th scope="row">Racks</th>
<div class="col col-md-4 text-center"> <td class="text-end">
<h2><a href="{% url 'ipam:prefix_list' %}?site_id={{ object.pk }}" class="btn {% if stats.prefix_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.prefix_count }}</a></h2> {% if stats.rack_count %}
<p>Prefixes</p> <div class="dropdown">
</div> <button class="btn btn-sm btn-light dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
<div class="col col-md-4 text-center"> {{ stats.rack_count }}
<h2><a href="{% url 'ipam:vlan_list' %}?site_id={{ object.pk }}" class="btn {% if stats.vlan_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.vlan_count }}</a></h2> </button>
<p>VLANs</p> <ul class="dropdown-menu">
</div> <li><a class="dropdown-item" href="{% url 'dcim:rack_list' %}?site_id={{ object.pk }}">View Racks</a></li>
<div class="col col-md-4 text-center"> <li><a class="dropdown-item" href="{% url 'dcim:rack_elevation_list' %}?site_id={{ object.pk }}">View Elevations</a></li>
<h2><a href="{% url 'circuits:circuit_list' %}?site_id={{ object.pk }}" class="btn {% if stats.circuit_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.circuit_count }}</a></h2> </ul>
<p>Circuits</p>
</div>
<div class="col col-md-4 text-center">
<h2><a href="{% url 'virtualization:virtualmachine_list' %}?site_id={{ object.pk }}" class="btn {% if stats.vm_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.vm_count }}</a></h2>
<p>Virtual Machines</p>
</div>
<div class="col col-md-4 text-center">
<h2><a href="{% url 'ipam:asn_list' %}?site_id={{ object.pk }}" class="btn {% if stats.asn_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.asn_count }}</a></h2>
<p>ASNs</p>
</div>
</div> </div>
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">Devices</th>
<td class="text-end">
{% if stats.device_count %}
<a href="{% url 'dcim:device_list' %}?site_id={{ object.pk }}">{{ stats.device_count }}</a>
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">Virtual Machines</th>
<td class="text-end">
{% if stats.vm_count %}
<a href="{% url 'virtualization:virtualmachine_list' %}?site_id={{ object.pk }}">{{ stats.vm_count }}</a>
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">Prefixes</th>
<td class="text-end">
{% if stats.prefix_count %}
<a href="{% url 'ipam:prefix_list' %}?site_id={{ object.pk }}">{{ stats.prefix_count }}</a>
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">VLANs</th>
<td class="text-end">
{% if stats.vlan_count %}
<a href="{% url 'ipam:vlan_list' %}?site_id={{ object.pk }}">{{ stats.vlan_count }}</a>
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">ASNs</th>
<td class="text-end">
{% if stats.asn_count %}
<a href="{% url 'ipam:asn_list' %}?site_id={{ object.pk }}">{{ stats.asn_count }}</a>
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">Circuits</th>
<td class="text-end">
{% if stats.circuit_count %}
<a href="{% url 'circuits:circuit_list' %}?site_id={{ object.pk }}">{{ stats.circuit_count }}</a>
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
</table>
</div> </div>
</div> </div>
{% include 'inc/panels/contacts.html' %} {% include 'inc/panels/contacts.html' %}

View File

@ -38,7 +38,11 @@
<tr> <tr>
<th scope="row">User</th> <th scope="row">User</th>
<td> <td>
{{ object.user|default:object.user_name }} {% if object.user.get_full_name %}
{{ object.user.get_full_name }} ({{ object.user_name }})
{% else %}
{{ object.user_name }}
{% endif %}
</td> </td>
</tr> </tr>
<tr> <tr>

View File

@ -25,12 +25,12 @@
<div class="noprint bulk-buttons"> <div class="noprint bulk-buttons">
<div class="bulk-button-group"> <div class="bulk-button-group">
{% if perms.ipam.change_prefix %} {% if perms.ipam.change_prefix %}
<button type="submit" name="_edit" formaction="{% url 'ipam:prefix_bulk_edit' %}?return_url={% url 'ipam:prefix_prefixes' pk=object.pk %}" class="btn btn-warning btn-sm"> <button type="submit" name="_edit" formaction="{% url 'ipam:prefix_bulk_edit' %}?return_url={% url 'ipam:aggregate_prefixes' pk=object.pk %}" class="btn btn-warning btn-sm">
<i class="mdi mdi-pencil" aria-hidden="true"></i> Edit <i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
</button> </button>
{% endif %} {% endif %}
{% if perms.ipam.delete_prefix %} {% if perms.ipam.delete_prefix %}
<button type="submit" name="_delete" formaction="{% url 'ipam:prefix_bulk_delete' %}?return_url={% url 'ipam:prefix_prefixes' pk=object.pk %}" class="btn btn-danger btn-sm"> <button type="submit" name="_delete" formaction="{% url 'ipam:prefix_bulk_delete' %}?return_url={% url 'ipam:aggregate_prefixes' pk=object.pk %}" class="btn btn-danger btn-sm">
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
</button> </button>
{% endif %} {% endif %}

View File

@ -5,7 +5,18 @@
{% block title %}Search{% endblock %} {% block title %}Search{% endblock %}
{% block content %} {% block tabs %}
<ul class="nav nav-tabs px-3">
<li class="nav-item" role="presentation">
<button class="nav-link active" type="button" role="tab">
Results
</button>
</li>
</ul>
{% endblock tabs %}
{% block content-wrapper %}
<div class="tab-content">
{% if request.GET.q %} {% if request.GET.q %}
{% if results %} {% if results %}
<div class="row"> <div class="row">
@ -73,4 +84,5 @@
</div> </div>
</div> </div>
{% endif %} {% endif %}
{% endblock content %} </div>
{% endblock content-wrapper %}

View File

@ -62,7 +62,7 @@ class TenantFilterSet(PrimaryModelFilterSet):
class Meta: class Meta:
model = Tenant model = Tenant
fields = ['id', 'name', 'slug'] fields = ['id', 'name', 'slug', 'description']
def search(self, queryset, name, value): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():
@ -131,7 +131,7 @@ class ContactRoleFilterSet(OrganizationalModelFilterSet):
class Meta: class Meta:
model = ContactRole model = ContactRole
fields = ['id', 'name', 'slug'] fields = ['id', 'name', 'slug', 'description']
class ContactFilterSet(PrimaryModelFilterSet): class ContactFilterSet(PrimaryModelFilterSet):

View File

@ -64,8 +64,8 @@ class TenantTestCase(TestCase, ChangeLoggedFilterSetTests):
tenantgroup.save() tenantgroup.save()
tenants = ( tenants = (
Tenant(name='Tenant 1', slug='tenant-1', group=tenant_groups[0]), Tenant(name='Tenant 1', slug='tenant-1', group=tenant_groups[0], description='foobar1'),
Tenant(name='Tenant 2', slug='tenant-2', group=tenant_groups[1]), Tenant(name='Tenant 2', slug='tenant-2', group=tenant_groups[1], description='foobar2'),
Tenant(name='Tenant 3', slug='tenant-3', group=tenant_groups[2]), Tenant(name='Tenant 3', slug='tenant-3', group=tenant_groups[2]),
) )
Tenant.objects.bulk_create(tenants) Tenant.objects.bulk_create(tenants)
@ -85,6 +85,10 @@ class TenantTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'group': [group[0].slug, group[1].slug]} params = {'group': [group[0].slug, group[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_description(self):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class ContactGroupTestCase(TestCase, ChangeLoggedFilterSetTests): class ContactGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = ContactGroup.objects.all() queryset = ContactGroup.objects.all()
@ -137,8 +141,8 @@ class ContactRoleTestCase(TestCase, ChangeLoggedFilterSetTests):
def setUpTestData(cls): def setUpTestData(cls):
contact_roles = ( contact_roles = (
ContactRole(name='Contact Role 1', slug='contact-role-1'), ContactRole(name='Contact Role 1', slug='contact-role-1', description='foobar1'),
ContactRole(name='Contact Role 2', slug='contact-role-2'), ContactRole(name='Contact Role 2', slug='contact-role-2', description='foobar2'),
ContactRole(name='Contact Role 3', slug='contact-role-3'), ContactRole(name='Contact Role 3', slug='contact-role-3'),
) )
ContactRole.objects.bulk_create(contact_roles) ContactRole.objects.bulk_create(contact_roles)
@ -151,6 +155,10 @@ class ContactRoleTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'slug': ['contact-role-1', 'contact-role-2']} params = {'slug': ['contact-role-1', 'contact-role-2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_description(self):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class ContactTestCase(TestCase, ChangeLoggedFilterSetTests): class ContactTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = Contact.objects.all() queryset = Contact.objects.all()

View File

@ -97,7 +97,7 @@ class TokenFilterSet(BaseFilterSet):
class Meta: class Meta:
model = Token model = Token
fields = ['id', 'key', 'write_enabled'] fields = ['id', 'key', 'write_enabled', 'description']
def search(self, queryset, name, value): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():
@ -138,7 +138,7 @@ class ObjectPermissionFilterSet(BaseFilterSet):
class Meta: class Meta:
model = ObjectPermission model = ObjectPermission
fields = ['id', 'name', 'enabled', 'object_types'] fields = ['id', 'name', 'enabled', 'object_types', 'description']
def search(self, queryset, name, value): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():

View File

@ -142,8 +142,8 @@ class ObjectPermissionTestCase(TestCase, BaseFilterSetTests):
) )
permissions = ( permissions = (
ObjectPermission(name='Permission 1', actions=['view', 'add', 'change', 'delete']), ObjectPermission(name='Permission 1', actions=['view', 'add', 'change', 'delete'], description='foobar1'),
ObjectPermission(name='Permission 2', actions=['view', 'add', 'change', 'delete']), ObjectPermission(name='Permission 2', actions=['view', 'add', 'change', 'delete'], description='foobar2'),
ObjectPermission(name='Permission 3', actions=['view', 'add', 'change', 'delete']), ObjectPermission(name='Permission 3', actions=['view', 'add', 'change', 'delete']),
ObjectPermission(name='Permission 4', actions=['view'], enabled=False), ObjectPermission(name='Permission 4', actions=['view'], enabled=False),
ObjectPermission(name='Permission 5', actions=['add'], enabled=False), ObjectPermission(name='Permission 5', actions=['add'], enabled=False),
@ -183,6 +183,10 @@ class ObjectPermissionTestCase(TestCase, BaseFilterSetTests):
params = {'object_types': [object_types[0].pk, object_types[1].pk]} params = {'object_types': [object_types[0].pk, object_types[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_description(self):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class TokenTestCase(TestCase, BaseFilterSetTests): class TokenTestCase(TestCase, BaseFilterSetTests):
queryset = Token.objects.all() queryset = Token.objects.all()
@ -201,8 +205,8 @@ class TokenTestCase(TestCase, BaseFilterSetTests):
future_date = make_aware(datetime.datetime(3000, 1, 1)) future_date = make_aware(datetime.datetime(3000, 1, 1))
past_date = make_aware(datetime.datetime(2000, 1, 1)) past_date = make_aware(datetime.datetime(2000, 1, 1))
tokens = ( tokens = (
Token(user=users[0], key=Token.generate_key(), expires=future_date, write_enabled=True), Token(user=users[0], key=Token.generate_key(), expires=future_date, write_enabled=True, description='foobar1'),
Token(user=users[1], key=Token.generate_key(), expires=future_date, write_enabled=True), Token(user=users[1], key=Token.generate_key(), expires=future_date, write_enabled=True, description='foobar2'),
Token(user=users[2], key=Token.generate_key(), expires=past_date, write_enabled=False), Token(user=users[2], key=Token.generate_key(), expires=past_date, write_enabled=False),
) )
Token.objects.bulk_create(tokens) Token.objects.bulk_create(tokens)
@ -232,3 +236,7 @@ class TokenTestCase(TestCase, BaseFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'write_enabled': False} params = {'write_enabled': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
def test_description(self):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

View File

@ -98,7 +98,9 @@ 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.
""" """
find = forms.CharField() find = forms.CharField()
replace = forms.CharField() replace = forms.CharField(
required=False
)
use_regex = forms.BooleanField( use_regex = forms.BooleanField(
required=False, required=False,
initial=True, initial=True,

View File

@ -39,6 +39,12 @@ class RestrictedQuerySet(QuerySet):
# Any permission with null constraints grants access to _all_ instances # Any permission with null constraints grants access to _all_ instances
attrs = Q() attrs = Q()
break break
else:
# for else, when no break
# avoid duplicates when JOIN on many-to-many fields without using DISTINCT.
# DISTINCT acts globally on the entire request, which may not be desirable.
allowed_objects = self.model.objects.filter(attrs)
attrs = Q(pk__in=allowed_objects)
qs = self.filter(attrs) qs = self.filter(attrs)
return qs return qs

View File

@ -4,10 +4,13 @@ from django.contrib.auth.models import AnonymousUser
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import FieldDoesNotExist from django.core.exceptions import FieldDoesNotExist
from django.db.models import DateField, DateTimeField
from django.db.models.fields.related import RelatedField from django.db.models.fields.related import RelatedField
from django.urls import reverse from django.urls import reverse
from django.utils.formats import date_format
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django_tables2 import RequestConfig from django_tables2 import RequestConfig
from django_tables2.columns import library
from django_tables2.data import TableQuerysetData from django_tables2.data import TableQuerysetData
from django_tables2.utils import Accessor from django_tables2.utils import Accessor
@ -205,6 +208,42 @@ class TemplateColumn(tables.TemplateColumn):
return ret return ret
@library.register
class DateColumn(tables.DateColumn):
"""
Overrides the default implementation of DateColumn to better handle null values, returning a default value for
tables and null when exporting data. It is registered in the tables library to use this class instead of the
default, making this behavior consistent in all fields of type DateField.
"""
def value(self, value):
return value
@classmethod
def from_field(cls, field, **kwargs):
if isinstance(field, DateField):
return cls(**kwargs)
@library.register
class DateTimeColumn(tables.DateTimeColumn):
"""
Overrides the default implementation of DateTimeColumn to better handle null values, returning a default value for
tables and null when exporting data. It is registered in the tables library to use this class instead of the
default, making this behavior consistent in all fields of type DateTimeField.
"""
def value(self, value):
if value:
return date_format(value, format="SHORT_DATETIME_FORMAT")
return None
@classmethod
def from_field(cls, field, **kwargs):
if isinstance(field, DateTimeField):
return cls(**kwargs)
class ButtonsColumn(tables.TemplateColumn): class ButtonsColumn(tables.TemplateColumn):
""" """
Render edit, delete, and changelog buttons for an object. Render edit, delete, and changelog buttons for an object.

View File

@ -183,7 +183,7 @@ def deepmerge(original, new):
""" """
merged = OrderedDict(original) merged = OrderedDict(original)
for key, val in new.items(): for key, val in new.items():
if key in original and isinstance(original[key], dict) and isinstance(val, dict): if key in original and isinstance(original[key], dict) and val and isinstance(val, dict):
merged[key] = deepmerge(original[key], val) merged[key] = deepmerge(original[key], val)
else: else:
merged[key] = val merged[key] = val

View File

@ -282,7 +282,7 @@ class VMInterfaceFilterSet(PrimaryModelFilterSet):
class Meta: class Meta:
model = VMInterface model = VMInterface
fields = ['id', 'name', 'enabled', 'mtu'] fields = ['id', 'name', 'enabled', 'mtu', 'description']
def search(self, queryset, name, value): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():

View File

@ -64,7 +64,7 @@ class ClusterCSVForm(CustomFieldModelCSVForm):
class VirtualMachineCSVForm(CustomFieldModelCSVForm): class VirtualMachineCSVForm(CustomFieldModelCSVForm):
status = CSVChoiceField( status = CSVChoiceField(
choices=VirtualMachineStatusChoices, choices=VirtualMachineStatusChoices,
help_text='Operational status of device' help_text='Operational status'
) )
cluster = CSVModelChoiceField( cluster = CSVModelChoiceField(
queryset=Cluster.objects.all(), queryset=Cluster.objects.all(),

View File

@ -422,8 +422,8 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
VirtualMachine.objects.bulk_create(vms) VirtualMachine.objects.bulk_create(vms)
interfaces = ( interfaces = (
VMInterface(virtual_machine=vms[0], name='Interface 1', enabled=True, mtu=100, mac_address='00-00-00-00-00-01'), VMInterface(virtual_machine=vms[0], name='Interface 1', enabled=True, mtu=100, mac_address='00-00-00-00-00-01', description='foobar1'),
VMInterface(virtual_machine=vms[1], name='Interface 2', enabled=True, mtu=200, mac_address='00-00-00-00-00-02'), VMInterface(virtual_machine=vms[1], name='Interface 2', enabled=True, mtu=200, mac_address='00-00-00-00-00-02', description='foobar2'),
VMInterface(virtual_machine=vms[2], name='Interface 3', enabled=False, mtu=300, mac_address='00-00-00-00-00-03'), VMInterface(virtual_machine=vms[2], name='Interface 3', enabled=False, mtu=300, mac_address='00-00-00-00-00-03'),
) )
VMInterface.objects.bulk_create(interfaces) VMInterface.objects.bulk_create(interfaces)
@ -478,3 +478,7 @@ class VMInterfaceTestCase(TestCase, ChangeLoggedFilterSetTests):
def test_mac_address(self): def test_mac_address(self):
params = {'mac_address': ['00-00-00-00-00-01', '00-00-00-00-00-02']} params = {'mac_address': ['00-00-00-00-00-01', '00-00-00-00-00-02']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_description(self):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

View File

@ -61,7 +61,7 @@ class WirelessLANFilterSet(PrimaryModelFilterSet):
class Meta: class Meta:
model = WirelessLAN model = WirelessLAN
fields = ['id', 'ssid', 'auth_psk'] fields = ['id', 'ssid', 'auth_psk', 'description']
def search(self, queryset, name, value): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():
@ -93,7 +93,7 @@ class WirelessLinkFilterSet(PrimaryModelFilterSet):
class Meta: class Meta:
model = WirelessLink model = WirelessLink
fields = ['id', 'ssid', 'auth_psk'] fields = ['id', 'ssid', 'auth_psk', 'description']
def search(self, queryset, name, value): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():

View File

@ -25,8 +25,8 @@ class WirelessLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
group.save() group.save()
child_groups = ( child_groups = (
WirelessLANGroup(name='Wireless LAN Group 1A', slug='wireless-lan-group-1a', parent=groups[0]), WirelessLANGroup(name='Wireless LAN Group 1A', slug='wireless-lan-group-1a', parent=groups[0], description='foobar1'),
WirelessLANGroup(name='Wireless LAN Group 1B', slug='wireless-lan-group-1b', parent=groups[0]), WirelessLANGroup(name='Wireless LAN Group 1B', slug='wireless-lan-group-1b', parent=groups[0], description='foobar2'),
WirelessLANGroup(name='Wireless LAN Group 2A', slug='wireless-lan-group-2a', parent=groups[1]), WirelessLANGroup(name='Wireless LAN Group 2A', slug='wireless-lan-group-2a', parent=groups[1]),
WirelessLANGroup(name='Wireless LAN Group 2B', slug='wireless-lan-group-2b', parent=groups[1]), WirelessLANGroup(name='Wireless LAN Group 2B', slug='wireless-lan-group-2b', parent=groups[1]),
WirelessLANGroup(name='Wireless LAN Group 3A', slug='wireless-lan-group-3a', parent=groups[2]), WirelessLANGroup(name='Wireless LAN Group 3A', slug='wireless-lan-group-3a', parent=groups[2]),
@ -54,6 +54,10 @@ class WirelessLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'parent': [parent_groups[0].slug, parent_groups[1].slug]} params = {'parent': [parent_groups[0].slug, parent_groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_description(self):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class WirelessLANTestCase(TestCase, ChangeLoggedFilterSetTests): class WirelessLANTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = WirelessLAN.objects.all() queryset = WirelessLAN.objects.all()
@ -147,7 +151,8 @@ class WirelessLinkTestCase(TestCase, ChangeLoggedFilterSetTests):
status=LinkStatusChoices.STATUS_CONNECTED, status=LinkStatusChoices.STATUS_CONNECTED,
auth_type=WirelessAuthTypeChoices.TYPE_OPEN, auth_type=WirelessAuthTypeChoices.TYPE_OPEN,
auth_cipher=WirelessAuthCipherChoices.CIPHER_AUTO, auth_cipher=WirelessAuthCipherChoices.CIPHER_AUTO,
auth_psk='PSK1' auth_psk='PSK1',
description='foobar1'
).save() ).save()
WirelessLink( WirelessLink(
interface_a=interfaces[1], interface_a=interfaces[1],
@ -156,7 +161,8 @@ class WirelessLinkTestCase(TestCase, ChangeLoggedFilterSetTests):
status=LinkStatusChoices.STATUS_PLANNED, status=LinkStatusChoices.STATUS_PLANNED,
auth_type=WirelessAuthTypeChoices.TYPE_WEP, auth_type=WirelessAuthTypeChoices.TYPE_WEP,
auth_cipher=WirelessAuthCipherChoices.CIPHER_TKIP, auth_cipher=WirelessAuthCipherChoices.CIPHER_TKIP,
auth_psk='PSK2' auth_psk='PSK2',
description='foobar2'
).save() ).save()
WirelessLink( WirelessLink(
interface_a=interfaces[4], interface_a=interfaces[4],
@ -192,3 +198,7 @@ class WirelessLinkTestCase(TestCase, ChangeLoggedFilterSetTests):
def test_auth_psk(self): def test_auth_psk(self):
params = {'auth_psk': ['PSK1', 'PSK2']} params = {'auth_psk': ['PSK1', 'PSK2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_description(self):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

View File

@ -9,8 +9,8 @@ django-prometheus==2.2.0
django-redis==5.2.0 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.1.0
django-timezone-field==4.2.3 django-timezone-field==5.0
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,15 +18,16 @@ 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.9 mkdocs-material==8.2.5
netaddr==0.8.0 netaddr==0.8.0
Pillow==8.4.0 Pillow==9.0.1
psycopg2-binary==2.9.3 psycopg2-binary==2.9.3
PyYAML==6.0 PyYAML==6.0
social-auth-app-django==5.0.0 social-auth-app-django==5.0.0
social-auth-core==4.2.0 social-auth-core==4.2.0
svgwrite==1.4.1 svgwrite==1.4.1
tablib==3.2.0 tablib==3.2.0
tzdata==2021.5
# Workaround for #7401 # Workaround for #7401
jsonschema==3.2.0 jsonschema==3.2.0

View File

@ -10,6 +10,23 @@ cd "$(dirname "$0")"
VIRTUALENV="$(pwd -P)/venv" VIRTUALENV="$(pwd -P)/venv"
PYTHON="${PYTHON:-python3}" PYTHON="${PYTHON:-python3}"
# Validate the minimum required Python version
COMMAND="${PYTHON} -c 'import sys; exit(1 if sys.version_info < (3, 7) else 0)'"
PYTHON_VERSION=$(eval "${PYTHON} -V")
eval $COMMAND || {
echo "--------------------------------------------------------------------"
echo "ERROR: Unsupported Python version: ${PYTHON_VERSION}. NetBox requires"
echo "Python 3.7 or later. To specify an alternate Python executable, set"
echo "the PYTHON environment variable. For example:"
echo ""
echo " sudo PYTHON=/usr/bin/python3.7 ./upgrade.sh"
echo ""
echo "To show your current Python version: ${PYTHON} -V"
echo "--------------------------------------------------------------------"
exit 1
}
echo "Using ${PYTHON_VERSION}"
# Remove the existing virtual environment (if any) # Remove the existing virtual environment (if any)
if [ -d "$VIRTUALENV" ]; then if [ -d "$VIRTUALENV" ]; then
COMMAND="rm -rf ${VIRTUALENV}" COMMAND="rm -rf ${VIRTUALENV}"