mirror of
https://github.com/netbox-community/netbox.git
synced 2025-12-29 08:37:46 -06:00
Compare commits
79 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d35cc56ed | ||
|
|
dffa380e5c | ||
|
|
6d2426843b | ||
|
|
e72b0606ba | ||
|
|
c933cbf11e | ||
|
|
9f1ffb54f5 | ||
|
|
29b8827128 | ||
|
|
6efc5682cd | ||
|
|
033a960cab | ||
|
|
9f69c46a99 | ||
|
|
631ff3e702 | ||
|
|
ed6ccfb723 | ||
|
|
d3a9a6827f | ||
|
|
057653d362 | ||
|
|
4ab58f2da9 | ||
|
|
d208ddde9a | ||
|
|
0fbfc4f38c | ||
|
|
e86dba8fc8 | ||
|
|
3e1d4369ba | ||
|
|
06b5ff2e4a | ||
|
|
3b1daaaad6 | ||
|
|
63a167f130 | ||
|
|
09d867adc3 | ||
|
|
7aba6500dd | ||
|
|
787a2dd7c2 | ||
|
|
c81f4da780 | ||
|
|
cffb99cec5 | ||
|
|
3b894f9ccb | ||
|
|
bf836c9bc2 | ||
|
|
4a4596d5e8 | ||
|
|
48b825c64a | ||
|
|
4fb42ac7b3 | ||
|
|
a8b4024016 | ||
|
|
a6c07e6a35 | ||
|
|
59cd5bc653 | ||
|
|
bda4f314a4 | ||
|
|
2a56c08bc8 | ||
|
|
beb0aff656 | ||
|
|
64270d6a4e | ||
|
|
fba4141ce3 | ||
|
|
a4ecb82330 | ||
|
|
5a3e213fb4 | ||
|
|
c4304d059c | ||
|
|
fee66438f3 | ||
|
|
0f52712468 | ||
|
|
fbaa82df7b | ||
|
|
9c1358e6e7 | ||
|
|
63b7145baa | ||
|
|
bcd974210d | ||
|
|
ed79e3bbf4 | ||
|
|
b5bc0bad38 | ||
|
|
2a44affd03 | ||
|
|
57ef44706a | ||
|
|
70dddb673b | ||
|
|
6c6cb321bf | ||
|
|
11514bfb21 | ||
|
|
c324d23634 | ||
|
|
f9431f1c29 | ||
|
|
b1ac20ac19 | ||
|
|
f8022040b2 | ||
|
|
8114492673 | ||
|
|
154b3a7abb | ||
|
|
015ef25ca0 | ||
|
|
3e1cc0d7f3 | ||
|
|
e1d1aab4bd | ||
|
|
299bde9653 | ||
|
|
4b98f74943 | ||
|
|
a33fb2a0a9 | ||
|
|
13dc6854c2 | ||
|
|
e475386936 | ||
|
|
0b194e363e | ||
|
|
72e93b04da | ||
|
|
7794b6718a | ||
|
|
efa939d0c2 | ||
|
|
8e91db0394 | ||
|
|
2a8728544c | ||
|
|
f83e55e1db | ||
|
|
113c8d1d85 | ||
|
|
5b2241aaaf |
@@ -15,7 +15,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v4.2.3
|
||||
placeholder: v4.2.5
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/02-bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/02-bug_report.yaml
vendored
@@ -27,7 +27,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox Version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v4.2.3
|
||||
placeholder: v4.2.5
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
[main]
|
||||
host = https://app.transifex.com
|
||||
|
||||
[o:netbox-community:p:netbox:r:9cbf4fcf95b3d92e4ebbf1a5e5d1caee]
|
||||
[o:netbox-community:p:netbox:r:034999968a7366ba27a8bdf1ab63bf42]
|
||||
file_filter = netbox/translations/<lang>/LC_MESSAGES/django.po
|
||||
source_file = netbox/translations/en/LC_MESSAGES/django.po
|
||||
type = PO
|
||||
|
||||
@@ -308,6 +308,7 @@ A particular object within NetBox. Each ObjectVar must specify a particular mode
|
||||
* `query_params` - A dictionary of query parameters to use when retrieving available options (optional)
|
||||
* `context` - A custom dictionary mapping template context variables to fields, used when rendering `<option>` elements within the dropdown menu (optional; see below)
|
||||
* `null_option` - A label representing a "null" or empty choice (optional)
|
||||
* `selector` - A boolean that, when True, includes an advanced object selection widget to assist the user in identifying the desired object (optional; False by default)
|
||||
|
||||
To limit the selections available within the list, additional query parameters can be passed as the `query_params` dictionary. For example, to show only devices with an "active" status:
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ This documentation describes the process of packaging and publishing a new NetBo
|
||||
|
||||
While major releases generally introduce some very substantial change to the application, they are typically treated the same as minor version increments for the purpose of release packaging.
|
||||
|
||||
For patch releases (e.g. upgrading from v4.2.2 to v4.2.3), begin at the [patch releases](#patch-releases) heading below. For minor or major releases, complete the entire checklist.
|
||||
|
||||
## Minor Version Releases
|
||||
|
||||
### Address Constrained Dependencies
|
||||
@@ -85,7 +87,20 @@ In cases where upgrading a dependency to its most recent release is breaking, it
|
||||
|
||||
### Update UI Dependencies
|
||||
|
||||
Check whether any UI dependencies (JavaScript packages, fonts, etc.) need to be updated by running `yarn outdated` from within the `project-static/` directory. [Upgrade these dependencies](./web-ui.md#updating-dependencies) as necessary, then run `yarn bundle` to generate the necessary files for distribution.
|
||||
Check whether any UI dependencies (JavaScript packages, fonts, etc.) need to be updated by running `yarn outdated` from within the `project-static/` directory. [Upgrade these dependencies](./web-ui.md#updating-dependencies) as necessary, then run `yarn bundle` to generate the necessary files for distribution:
|
||||
|
||||
```
|
||||
$ yarn bundle
|
||||
yarn run v1.22.19
|
||||
$ node bundle.js
|
||||
✅ Bundled source file 'styles/external.scss' to 'netbox-external.css'
|
||||
✅ Bundled source file 'styles/netbox.scss' to 'netbox.css'
|
||||
✅ Bundled source file 'styles/svg/rack_elevation.scss' to 'rack_elevation.css'
|
||||
✅ Bundled source file 'styles/svg/cable_trace.scss' to 'cable_trace.css'
|
||||
✅ Bundled source file 'index.ts' to 'netbox.js'
|
||||
✅ Copied graphiql files
|
||||
Done in 1.00s.
|
||||
```
|
||||
|
||||
### Rebuild the Device Type Definition Schema
|
||||
|
||||
@@ -116,9 +131,12 @@ Then, compile these portable (`.po`) files for use in the application:
|
||||
|
||||
### Update Version and Changelog
|
||||
|
||||
* Update the version and published date in `release.yaml` with the current version & date. Add a designation (e.g.g `beta1`) if applicable.
|
||||
* Update the version number and date in `netbox/release.yaml`. Add or remove the designation (e.g. `beta1`) if applicable.
|
||||
* Update the example version numbers in the feature request and bug report templates under `.github/ISSUE_TEMPLATES/`.
|
||||
* Replace the "FUTURE" placeholder in the release notes with the current date.
|
||||
* Add a section for this release at the top of the changelog page for the minor version (e.g. `docs/release-notes/version-4.2.md`) listing all relevant changes made in this release.
|
||||
|
||||
!!! tip
|
||||
Put yourself in the shoes of the user when recording change notes. Focus on the effect that each change has for the end user, rather than the specific bits of code that were modified in a PR. Ensure that each message conveys meaning absent context of the initial feature request or bug report. Remember to include key words or phrases (such as exception names) that can be easily searched.
|
||||
|
||||
### Submit a Pull Request
|
||||
|
||||
@@ -126,6 +144,9 @@ Commit the above changes and submit a pull request titled **"Release vX.Y.Z"** t
|
||||
|
||||
Once CI has completed and a colleague has reviewed the PR, merge it. This effects a new release in the `main` branch.
|
||||
|
||||
!!! warning
|
||||
To ensure a streamlined review process, the pull request for a release **must** be limited to the changes outlined in this document. A release PR must never include functional changes to the application: Any unrelated "cleanup" needs to be captured in a separate PR prior to the release being shipped.
|
||||
|
||||
### Create a New Release
|
||||
|
||||
Create a [new release](https://github.com/netbox-community/netbox/releases/new) on GitHub with the following parameters.
|
||||
|
||||
@@ -22,7 +22,7 @@ NetBox generally follows the [Django style guide](https://docs.djangoproject.com
|
||||
|
||||
### Linting
|
||||
|
||||
The [ruff](https://docs.astral.sh/ruff/) linter is used to enforce code style. A [pre-commit hook](./getting-started.md#3-enable-pre-commit-hooks) which runs this automatically is included with NetBox. To invoke `ruff` manually, run:
|
||||
The [ruff](https://docs.astral.sh/ruff/) linter is used to enforce code style, and is run automatically by [pre-commit](./getting-started.md#5-install-pre-commit). To invoke `ruff` manually, run:
|
||||
|
||||
```
|
||||
ruff check netbox/
|
||||
|
||||
@@ -30,7 +30,7 @@ To download translated strings automatically, you'll need to:
|
||||
1. Install the [Transifex CLI client](https://github.com/transifex/cli)
|
||||
2. Generate a [Transifex API token](https://app.transifex.com/user/settings/api/)
|
||||
|
||||
Once you have the client set up, run the following command:
|
||||
Once you have the client set up, run the following command from the project root (e.g. `/opt/netbox/`):
|
||||
|
||||
```no-highlight
|
||||
TX_TOKEN=$TOKEN tx pull
|
||||
@@ -46,6 +46,9 @@ Once retrieved, the updated strings need to be compiled into new `.mo` files so
|
||||
|
||||
Once any new `.mo` files have been generated, they need to be committed and pushed back up to GitHub. (Again, this is typically done as part of publishing a new NetBox release.)
|
||||
|
||||
!!! tip
|
||||
Run `git status` to check that both `*.mo` & `*.po` files have been updated as expected.
|
||||
|
||||
## Proposing New Languages
|
||||
|
||||
If you'd like to add support for a new language to NetBox, the first step is to [submit a GitHub issue](https://github.com/netbox-community/netbox/issues/new?assignees=&labels=type%3A+translation&projects=&template=translation.yaml) to capture the proposal. While we'd like to add as many languages as possible, we do need to limit the rate at which new languages are added. New languages will be selected according to community interest and the number of volunteers who sign up as translators.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
## What is a REST API?
|
||||
|
||||
REST stands for [representational state transfer](https://en.wikipedia.org/wiki/Representational_state_transfer). It's a particular type of API which employs HTTP requests and [JavaScript Object Notation (JSON)](https://www.json.org/) to facilitate create, retrieve, update, and delete (CRUD) operations on objects within an application. Each type of operation is associated with a particular HTTP verb:
|
||||
REST stands for [representational state transfer](https://en.wikipedia.org/wiki/REST). It's a particular type of API which employs HTTP requests and [JavaScript Object Notation (JSON)](https://www.json.org/) to facilitate create, retrieve, update, and delete (CRUD) operations on objects within an application. Each type of operation is associated with a particular HTTP verb:
|
||||
|
||||
* `GET`: Retrieve an object or list of objects
|
||||
* `POST`: Create an object
|
||||
|
||||
@@ -1,5 +1,63 @@
|
||||
# NetBox v4.2
|
||||
|
||||
## v4.2.5 (2025-03-06)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#17357](https://github.com/netbox-community/netbox/issues/17357) - Use VirtualChassis name as fallback for unnamed devices
|
||||
* [#17542](https://github.com/netbox-community/netbox/issues/17542) - Add contact assignments to VPN tunnels
|
||||
* [#17944](https://github.com/netbox-community/netbox/issues/17944) - Allow script inputs to be filtered on ObjectVar and MultiObjectVar selections
|
||||
* [#18024](https://github.com/netbox-community/netbox/issues/18024) - Add permalink URL pattern to match a custom script by module and class name
|
||||
* [#18095](https://github.com/netbox-community/netbox/issues/18095) - Ensure contacts are shown on children of objects with contacts
|
||||
* [#18141](https://github.com/netbox-community/netbox/issues/18141) - Support "Quick Add" for plugins
|
||||
* [#18403](https://github.com/netbox-community/netbox/issues/18403) - Improve performance of job list views
|
||||
* [#18693](https://github.com/netbox-community/netbox/issues/18693) - Support setting VLAN translation on bulk edit of interfaces
|
||||
* [#18772](https://github.com/netbox-community/netbox/issues/18772) - Add "type" filter for virtual circuits
|
||||
* [#18774](https://github.com/netbox-community/netbox/issues/18774) - Add tooltip preview of tag descriptions when hovering over tags
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#15016](https://github.com/netbox-community/netbox/issues/15016) - Prevent AssertionError when adding multiple devices "mid-span" in a cable trace
|
||||
* [#15924](https://github.com/netbox-community/netbox/issues/15924) - Prevent setting tagged VLANs on interfaces with mode: tagged-all
|
||||
* [#17488](https://github.com/netbox-community/netbox/issues/17488) - Ensure VLANGroup.vid_ranges shows up in API results
|
||||
* [#17709](https://github.com/netbox-community/netbox/issues/17709) - Allow primary key for nested models in OpenAPI request schemas
|
||||
* [#17796](https://github.com/netbox-community/netbox/issues/17796) - Fix IndexError on "Create & Add Another" operation on custom field choices
|
||||
* [#18605](https://github.com/netbox-community/netbox/issues/18605) - Limit VLAN selection dropdown to choices appropriate to site
|
||||
* [#18722](https://github.com/netbox-community/netbox/issues/18722) - Improve UI feedback on failed script execution
|
||||
* [#18729](https://github.com/netbox-community/netbox/issues/18729) - Fix unpredictable ordering on querysets with annotations/groupings
|
||||
* [#18753](https://github.com/netbox-community/netbox/issues/18753) - Prevent webhooks from being triggered on a script dry-run
|
||||
* [#18758](https://github.com/netbox-community/netbox/issues/18758) - Fix FieldError when sorting by account count field in providers list
|
||||
* [#18768](https://github.com/netbox-community/netbox/issues/18768) - Fix removing a secondary MAC address from an interface
|
||||
|
||||
---
|
||||
|
||||
## v4.2.4 (2025-02-21)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#17309](https://github.com/netbox-community/netbox/issues/17309) - Omit empty counts in related object tables
|
||||
* [#18277](https://github.com/netbox-community/netbox/issues/18277) - Improve multi-table inheritance in serialization of change-logged models
|
||||
* [#18286](https://github.com/netbox-community/netbox/issues/18286) - Add more job duration choices
|
||||
* [#18357](https://github.com/netbox-community/netbox/issues/18357) - Display author name in plugin list for locally installed plugins
|
||||
* [#18408](https://github.com/netbox-community/netbox/issues/18408) - Add Paused status for virtual machines
|
||||
* [#18584](https://github.com/netbox-community/netbox/issues/18584) - Add rack type column to manufacturer list
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#17436](https://github.com/netbox-community/netbox/issues/17436) - Fix {module} replacement in module bays
|
||||
* [#18013](https://github.com/netbox-community/netbox/issues/18013) - Limit object type to selected object in change log filter
|
||||
* [#18241](https://github.com/netbox-community/netbox/issues/18241) - Default logging level of custom scripts changed to INFO
|
||||
* [#18247](https://github.com/netbox-community/netbox/issues/18247) - Fix visibility of disabled cable paths in dark mode
|
||||
* [#18480](https://github.com/netbox-community/netbox/issues/18480) - Clean data passed to script in runscript command
|
||||
* [#18555](https://github.com/netbox-community/netbox/issues/18555) - Add default get_absolute_url method to plugin models
|
||||
* [#18585](https://github.com/netbox-community/netbox/issues/18585) - Fix filtering circuits by location
|
||||
* [#18593](https://github.com/netbox-community/netbox/issues/18593) - Fix "Create & Add Another" IP Address workflow
|
||||
* [#18594](https://github.com/netbox-community/netbox/issues/18594) - Enable sorting by ASN count on site and provider lists
|
||||
* [#18619](https://github.com/netbox-community/netbox/issues/18619) - Ensure shift-click selection selects only visible list items
|
||||
* [#18674](https://github.com/netbox-community/netbox/issues/18674) - Preserve form values when selecting speed on circuit termination
|
||||
|
||||
---
|
||||
|
||||
## v4.2.3 (2025-02-04)
|
||||
|
||||
### Enhancements
|
||||
|
||||
@@ -64,6 +64,8 @@ markdown_extensions:
|
||||
format: !!python/name:pymdownx.superfences.fence_code_format
|
||||
- pymdownx.tabbed:
|
||||
alternate_style: true
|
||||
not_in_nav: |
|
||||
/index.md
|
||||
nav:
|
||||
- Introduction: 'introduction.md'
|
||||
- Features:
|
||||
|
||||
@@ -234,6 +234,11 @@ class CircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilte
|
||||
to_field_name='slug',
|
||||
label=_('Site (slug)'),
|
||||
)
|
||||
location_id = django_filters.ModelMultipleChoiceFilter(
|
||||
field_name='terminations___location',
|
||||
label=_('Location (ID)'),
|
||||
queryset=Location.objects.all(),
|
||||
)
|
||||
termination_a_id = django_filters.ModelMultipleChoiceFilter(
|
||||
queryset=CircuitTermination.objects.all(),
|
||||
label=_('Termination A (ID)'),
|
||||
|
||||
@@ -126,7 +126,7 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
|
||||
'type_id', 'status', 'install_date', 'termination_date', 'commit_rate', 'distance', 'distance_unit',
|
||||
name=_('Attributes')
|
||||
),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')),
|
||||
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')),
|
||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
||||
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
|
||||
)
|
||||
@@ -181,6 +181,11 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
|
||||
},
|
||||
label=_('Site')
|
||||
)
|
||||
location_id = DynamicModelMultipleChoiceField(
|
||||
queryset=Location.objects.all(),
|
||||
required=False,
|
||||
label=_('Location')
|
||||
)
|
||||
install_date = forms.DateField(
|
||||
label=_('Install date'),
|
||||
required=False,
|
||||
@@ -322,7 +327,7 @@ class VirtualCircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBox
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('provider_id', 'provider_account_id', 'provider_network_id', name=_('Provider')),
|
||||
FieldSet('type', 'status', name=_('Attributes')),
|
||||
FieldSet('type_id', 'status', name=_('Attributes')),
|
||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
||||
)
|
||||
selector_fields = ('filter_id', 'q', 'provider_id', 'provider_network_id')
|
||||
|
||||
@@ -349,9 +349,8 @@ class CircuitTermination(
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# Must define either site *or* provider network
|
||||
if self.termination is None:
|
||||
raise ValidationError(_("A circuit termination must attach to termination."))
|
||||
raise ValidationError(_("A circuit termination must attach to a terminating object."))
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# Cache objects associated with the terminating object (for filtering)
|
||||
|
||||
@@ -23,7 +23,6 @@ class ProviderTable(ContactsColumnMixin, NetBoxTable):
|
||||
verbose_name=_('Accounts')
|
||||
)
|
||||
account_count = columns.LinkedCountColumn(
|
||||
accessor=tables.A('accounts__count'),
|
||||
viewname='circuits:provideraccount_list',
|
||||
url_params={'provider_id': 'pk'},
|
||||
verbose_name=_('Account Count')
|
||||
@@ -33,7 +32,6 @@ class ProviderTable(ContactsColumnMixin, NetBoxTable):
|
||||
verbose_name=_('ASNs')
|
||||
)
|
||||
asn_count = columns.LinkedCountColumn(
|
||||
accessor=tables.A('asns__count'),
|
||||
viewname='ipam:asn_list',
|
||||
url_params={'provider_id': 'pk'},
|
||||
verbose_name=_('ASN Count')
|
||||
|
||||
@@ -3,8 +3,10 @@ from django.test import TestCase
|
||||
from circuits.choices import *
|
||||
from circuits.filtersets import *
|
||||
from circuits.models import *
|
||||
from dcim.choices import InterfaceTypeChoices
|
||||
from dcim.models import Cable, Device, DeviceRole, DeviceType, Interface, Manufacturer, Region, Site, SiteGroup
|
||||
from dcim.choices import InterfaceTypeChoices, LocationStatusChoices
|
||||
from dcim.models import (
|
||||
Cable, Device, DeviceRole, DeviceType, Interface, Location, Manufacturer, Region, Site, SiteGroup
|
||||
)
|
||||
from ipam.models import ASN, RIR
|
||||
from netbox.choices import DistanceUnitChoices
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
@@ -225,6 +227,17 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
)
|
||||
ProviderNetwork.objects.bulk_create(provider_networks)
|
||||
|
||||
locations = (
|
||||
Location.objects.create(
|
||||
site=sites[0], name='Test Location 1', slug='test-location-1',
|
||||
status=LocationStatusChoices.STATUS_ACTIVE,
|
||||
),
|
||||
Location.objects.create(
|
||||
site=sites[1], name='Test Location 2', slug='test-location-2',
|
||||
status=LocationStatusChoices.STATUS_ACTIVE,
|
||||
),
|
||||
)
|
||||
|
||||
circuits = (
|
||||
Circuit(
|
||||
provider=providers[0],
|
||||
@@ -305,7 +318,9 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
|
||||
circuit_terminations = ((
|
||||
CircuitTermination(circuit=circuits[0], termination=sites[0], term_side='A'),
|
||||
CircuitTermination(circuit=circuits[0], termination=locations[0], term_side='Z'),
|
||||
CircuitTermination(circuit=circuits[1], termination=sites[1], term_side='A'),
|
||||
CircuitTermination(circuit=circuits[1], termination=locations[1], term_side='Z'),
|
||||
CircuitTermination(circuit=circuits[2], termination=sites[2], term_side='A'),
|
||||
CircuitTermination(circuit=circuits[3], termination=provider_networks[0], term_side='A'),
|
||||
CircuitTermination(circuit=circuits[4], termination=provider_networks[1], term_side='A'),
|
||||
@@ -395,6 +410,11 @@ class CircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
params = {'site': [sites[0].slug, sites[1].slug]}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_location(self):
|
||||
location_ids = Location.objects.values_list('id', flat=True)[:2]
|
||||
params = {'location_id': location_ids}
|
||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||
|
||||
def test_tenant(self):
|
||||
tenants = Tenant.objects.all()[:2]
|
||||
params = {'tenant_id': [tenants[0].pk, tenants[1].pk]}
|
||||
|
||||
@@ -4,6 +4,7 @@ from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from dcim.views import PathTraceView
|
||||
from ipam.models import ASN
|
||||
from netbox.views import generic
|
||||
from tenancy.views import ObjectContactsView
|
||||
from utilities.forms import ConfirmationForm
|
||||
@@ -20,7 +21,9 @@ from .models import *
|
||||
@register_model_view(Provider, 'list', path='', detail=False)
|
||||
class ProviderListView(generic.ObjectListView):
|
||||
queryset = Provider.objects.annotate(
|
||||
count_circuits=count_related(Circuit, 'provider')
|
||||
count_circuits=count_related(Circuit, 'provider'),
|
||||
asn_count=count_related(ASN, 'providers'),
|
||||
account_count=count_related(ProviderAccount, 'provider'),
|
||||
)
|
||||
filterset = filtersets.ProviderFilterSet
|
||||
filterset_form = forms.ProviderFilterForm
|
||||
|
||||
@@ -2,12 +2,13 @@ import re
|
||||
import typing
|
||||
from collections import OrderedDict
|
||||
|
||||
from drf_spectacular.extensions import OpenApiSerializerFieldExtension
|
||||
from drf_spectacular.extensions import OpenApiSerializerFieldExtension, OpenApiSerializerExtension, _SchemaType
|
||||
from drf_spectacular.openapi import AutoSchema
|
||||
from drf_spectacular.plumbing import (
|
||||
build_basic_type, build_choice_field, build_media_type_object, build_object_type, get_doc,
|
||||
)
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import Direction
|
||||
|
||||
from netbox.api.fields import ChoiceField
|
||||
from netbox.api.serializers import WritableNestedSerializer
|
||||
@@ -277,3 +278,40 @@ class FixSerializedPKRelatedField(OpenApiSerializerFieldExtension):
|
||||
return component.ref if component else None
|
||||
else:
|
||||
return build_basic_type(OpenApiTypes.INT)
|
||||
|
||||
|
||||
class FixIntegerRangeSerializerSchema(OpenApiSerializerExtension):
|
||||
target_class = 'netbox.api.fields.IntegerRangeSerializer'
|
||||
|
||||
def map_serializer(self, auto_schema: 'AutoSchema', direction: Direction) -> _SchemaType:
|
||||
return {
|
||||
'type': 'array',
|
||||
'items': {
|
||||
'type': 'array',
|
||||
'items': {
|
||||
'type': 'integer',
|
||||
},
|
||||
'minItems': 2,
|
||||
'maxItems': 2,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# Nested models can be passed by ID in requests
|
||||
# The logic for this is handled in `BaseModelSerializer.to_internal_value`
|
||||
class FixWritableNestedSerializerAllowPK(OpenApiSerializerFieldExtension):
|
||||
target_class = 'netbox.api.serializers.BaseModelSerializer'
|
||||
match_subclasses = True
|
||||
|
||||
def map_serializer_field(self, auto_schema, direction):
|
||||
schema = auto_schema._map_serializer_field(self.target, direction, bypass_extensions=True)
|
||||
if schema is None:
|
||||
return schema
|
||||
if direction == 'request' and self.target.nested:
|
||||
return {
|
||||
'oneOf': [
|
||||
build_basic_type(OpenApiTypes.INT),
|
||||
schema,
|
||||
]
|
||||
}
|
||||
return schema
|
||||
|
||||
@@ -81,8 +81,10 @@ class JobIntervalChoices(ChoiceSet):
|
||||
CHOICES = (
|
||||
(INTERVAL_MINUTELY, _('Minutely')),
|
||||
(INTERVAL_HOURLY, _('Hourly')),
|
||||
(INTERVAL_HOURLY * 12, _('12 hours')),
|
||||
(INTERVAL_DAILY, _('Daily')),
|
||||
(INTERVAL_WEEKLY, _('Weekly')),
|
||||
(INTERVAL_DAILY * 30, _('30 days')),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ class DataFileFilterForm(NetBoxModelFilterSetForm):
|
||||
|
||||
|
||||
class JobFilterForm(SavedFiltersMixin, FilterForm):
|
||||
model = Job
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id'),
|
||||
FieldSet('object_type', 'status', name=_('Attributes')),
|
||||
@@ -162,6 +163,7 @@ class ObjectChangeFilterForm(SavedFiltersMixin, FilterForm):
|
||||
|
||||
|
||||
class ConfigRevisionFilterForm(SavedFiltersMixin, FilterForm):
|
||||
model = ConfigRevision
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id'),
|
||||
)
|
||||
|
||||
@@ -80,6 +80,13 @@ def get_local_plugins(plugins=None):
|
||||
plugin = importlib.import_module(plugin_name)
|
||||
plugin_config: PluginConfig = plugin.config
|
||||
|
||||
if plugin_config.author:
|
||||
author = PluginAuthor(
|
||||
name=plugin_config.author,
|
||||
)
|
||||
else:
|
||||
author = None
|
||||
|
||||
local_plugins[plugin_config.name] = Plugin(
|
||||
config_name=plugin_config.name,
|
||||
title_short=plugin_config.verbose_name,
|
||||
@@ -88,6 +95,7 @@ def get_local_plugins(plugins=None):
|
||||
description_short=plugin_config.description,
|
||||
is_local=True,
|
||||
is_installed=True,
|
||||
author=author,
|
||||
installed_version=plugin_config.version,
|
||||
)
|
||||
|
||||
|
||||
@@ -165,7 +165,7 @@ class DataFileBulkDeleteView(generic.BulkDeleteView):
|
||||
|
||||
@register_model_view(Job, 'list', path='', detail=False)
|
||||
class JobListView(generic.ObjectListView):
|
||||
queryset = Job.objects.all()
|
||||
queryset = Job.objects.defer('data')
|
||||
filterset = filtersets.JobFilterSet
|
||||
filterset_form = forms.JobFilterForm
|
||||
table = tables.JobTable
|
||||
@@ -182,12 +182,12 @@ class JobView(generic.ObjectView):
|
||||
|
||||
@register_model_view(Job, 'delete')
|
||||
class JobDeleteView(generic.ObjectDeleteView):
|
||||
queryset = Job.objects.all()
|
||||
queryset = Job.objects.defer('data')
|
||||
|
||||
|
||||
@register_model_view(Job, 'bulk_delete', path='delete', detail=False)
|
||||
class JobBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = Job.objects.all()
|
||||
queryset = Job.objects.defer('data')
|
||||
filterset = filtersets.JobFilterSet
|
||||
table = tables.JobTable
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from django.utils.translation import gettext as _
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from drf_spectacular.utils import extend_schema_field
|
||||
from rest_framework import serializers
|
||||
@@ -232,8 +233,56 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
|
||||
|
||||
def validate(self, data):
|
||||
|
||||
# Validate many-to-many VLAN assignments
|
||||
if not self.nested:
|
||||
|
||||
# Validate 802.1q mode and vlan(s)
|
||||
mode = None
|
||||
tagged_vlans = []
|
||||
|
||||
# Gather Information
|
||||
if self.instance:
|
||||
mode = data.get('mode') if 'mode' in data.keys() else self.instance.mode
|
||||
untagged_vlan = data.get('untagged_vlan') if 'untagged_vlan' in data.keys() else \
|
||||
self.instance.untagged_vlan
|
||||
qinq_svlan = data.get('qinq_svlan') if 'qinq_svlan' in data.keys() else \
|
||||
self.instance.qinq_svlan
|
||||
tagged_vlans = data.get('tagged_vlans') if 'tagged_vlans' in data.keys() else \
|
||||
self.instance.tagged_vlans.all()
|
||||
else:
|
||||
mode = data.get('mode', None)
|
||||
untagged_vlan = data.get('untagged_vlan') if 'untagged_vlan' in data.keys() else None
|
||||
qinq_svlan = data.get('qinq_svlan') if 'qinq_svlan' in data.keys() else None
|
||||
tagged_vlans = data.get('tagged_vlans') if 'tagged_vlans' in data.keys() else None
|
||||
|
||||
errors = {}
|
||||
|
||||
# Non Q-in-Q mode with service vlan set
|
||||
if mode != InterfaceModeChoices.MODE_Q_IN_Q and qinq_svlan:
|
||||
errors.update({
|
||||
'qinq_svlan': _("Interface mode does not support q-in-q service vlan")
|
||||
})
|
||||
# Routed mode
|
||||
if not mode:
|
||||
# Untagged vlan
|
||||
if untagged_vlan:
|
||||
errors.update({
|
||||
'untagged_vlan': _("Interface mode does not support untagged vlan")
|
||||
})
|
||||
# Tagged vlan
|
||||
if tagged_vlans:
|
||||
errors.update({
|
||||
'tagged_vlans': _("Interface mode does not support tagged vlans")
|
||||
})
|
||||
# Non-tagged mode
|
||||
elif mode in (InterfaceModeChoices.MODE_TAGGED_ALL, InterfaceModeChoices.MODE_ACCESS) and tagged_vlans:
|
||||
errors.update({
|
||||
'tagged_vlans': _("Interface mode does not support tagged vlans")
|
||||
})
|
||||
|
||||
if errors:
|
||||
raise serializers.ValidationError(errors)
|
||||
|
||||
# Validate many-to-many VLAN assignments
|
||||
device = self.instance.device if self.instance else data.get('device')
|
||||
for vlan in data.get('tagged_vlans', []):
|
||||
if vlan.site not in [device.site, None]:
|
||||
|
||||
2
netbox/dcim/exceptions.py
Normal file
2
netbox/dcim/exceptions.py
Normal file
@@ -0,0 +1,2 @@
|
||||
class UnsupportedCablePath(Exception):
|
||||
pass
|
||||
@@ -1193,6 +1193,7 @@ class DeviceFilterSet(
|
||||
return queryset
|
||||
return queryset.filter(
|
||||
Q(name__icontains=value) |
|
||||
Q(virtual_chassis__name__icontains=value) |
|
||||
Q(serial__icontains=value.strip()) |
|
||||
Q(inventoryitems__serial__icontains=value.strip()) |
|
||||
Q(asset_tag__icontains=value.strip()) |
|
||||
|
||||
@@ -1411,7 +1411,7 @@ class InterfaceBulkEditForm(
|
||||
form_from_model(Interface, [
|
||||
'label', 'type', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'wwn', 'mtu', 'mgmt_only', 'mark_connected',
|
||||
'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
|
||||
'wireless_lans'
|
||||
'wireless_lans', 'vlan_translation_policy'
|
||||
])
|
||||
):
|
||||
enabled = forms.NullBooleanField(
|
||||
@@ -1564,7 +1564,9 @@ class InterfaceBulkEditForm(
|
||||
FieldSet('vdcs', 'mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected', name=_('Operation')),
|
||||
FieldSet('poe_mode', 'poe_type', name=_('PoE')),
|
||||
FieldSet('parent', 'bridge', 'lag', name=_('Related Interfaces')),
|
||||
FieldSet('mode', 'vlan_group', 'untagged_vlan', 'qinq_svlan', name=_('802.1Q Switching')),
|
||||
FieldSet(
|
||||
'mode', 'vlan_group', 'untagged_vlan', 'qinq_svlan', 'vlan_translation_policy', name=_('802.1Q Switching')
|
||||
),
|
||||
FieldSet(
|
||||
TabbedGroups(
|
||||
FieldSet('tagged_vlans', name=_('Assignment')),
|
||||
@@ -1579,7 +1581,7 @@ class InterfaceBulkEditForm(
|
||||
nullable_fields = (
|
||||
'module', 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'wwn', 'vdcs', 'mtu', 'description',
|
||||
'poe_mode', 'poe_type', 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
|
||||
'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vrf', 'wireless_lans'
|
||||
'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vrf', 'wireless_lans', 'vlan_translation_policy',
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
@@ -43,20 +43,14 @@ class InterfaceCommonForm(forms.Form):
|
||||
super().clean()
|
||||
|
||||
parent_field = 'device' if 'device' in self.cleaned_data else 'virtual_machine'
|
||||
tagged_vlans = self.cleaned_data.get('tagged_vlans')
|
||||
|
||||
# Untagged interfaces cannot be assigned tagged VLANs
|
||||
if self.cleaned_data['mode'] == InterfaceModeChoices.MODE_ACCESS and tagged_vlans:
|
||||
raise forms.ValidationError({
|
||||
'mode': _("An access interface cannot have tagged VLANs assigned.")
|
||||
})
|
||||
|
||||
# Remove all tagged VLAN assignments from "tagged all" interfaces
|
||||
elif self.cleaned_data['mode'] == InterfaceModeChoices.MODE_TAGGED_ALL:
|
||||
self.cleaned_data['tagged_vlans'] = []
|
||||
if 'tagged_vlans' in self.fields.keys():
|
||||
tagged_vlans = self.cleaned_data.get('tagged_vlans') if self.is_bound else \
|
||||
self.get_initial_for_field(self.fields['tagged_vlans'], 'tagged_vlans')
|
||||
else:
|
||||
tagged_vlans = []
|
||||
|
||||
# Validate tagged VLANs; must be a global VLAN or in the same site
|
||||
elif self.cleaned_data['mode'] == InterfaceModeChoices.MODE_TAGGED and tagged_vlans:
|
||||
if self.cleaned_data['mode'] == InterfaceModeChoices.MODE_TAGGED and tagged_vlans:
|
||||
valid_sites = [None, self.cleaned_data[parent_field].site]
|
||||
invalid_vlans = [str(v) for v in tagged_vlans if v.site not in valid_sites]
|
||||
|
||||
|
||||
@@ -303,7 +303,7 @@ class RackTypeFilterForm(RackBaseFilterForm):
|
||||
model = RackType
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('form_factor', 'width', 'u_height', name=_('Rack Type')),
|
||||
FieldSet('manufacturer_id', 'form_factor', 'width', 'u_height', name=_('Rack Type')),
|
||||
FieldSet('starting_unit', 'desc_units', name=_('Numbering')),
|
||||
FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')),
|
||||
)
|
||||
|
||||
@@ -15,6 +15,7 @@ from dcim.fields import PathField
|
||||
from dcim.utils import decompile_path_node, object_to_path_node
|
||||
from netbox.models import ChangeLoggedModel, PrimaryModel
|
||||
from utilities.conversion import to_meters
|
||||
from utilities.exceptions import AbortRequest
|
||||
from utilities.fields import ColorField
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from wireless.models import WirelessLink
|
||||
@@ -26,6 +27,7 @@ __all__ = (
|
||||
'CableTermination',
|
||||
)
|
||||
|
||||
from ..exceptions import UnsupportedCablePath
|
||||
|
||||
trace_paths = Signal()
|
||||
|
||||
@@ -236,8 +238,10 @@ class Cable(PrimaryModel):
|
||||
for termination in self.b_terminations:
|
||||
if not termination.pk or termination not in b_terminations:
|
||||
CableTermination(cable=self, cable_end='B', termination=termination).save()
|
||||
|
||||
trace_paths.send(Cable, instance=self, created=_created)
|
||||
try:
|
||||
trace_paths.send(Cable, instance=self, created=_created)
|
||||
except UnsupportedCablePath as e:
|
||||
raise AbortRequest(e)
|
||||
|
||||
def get_status_color(self):
|
||||
return LinkStatusChoices.colors.get(self.status)
|
||||
@@ -531,8 +535,8 @@ class CablePath(models.Model):
|
||||
return None
|
||||
|
||||
# Ensure all originating terminations are attached to the same link
|
||||
if len(terminations) > 1:
|
||||
assert all(t.link == terminations[0].link for t in terminations[1:])
|
||||
if len(terminations) > 1 and not all(t.link == terminations[0].link for t in terminations[1:]):
|
||||
raise UnsupportedCablePath(_("All originating terminations must be attached to the same link"))
|
||||
|
||||
path = []
|
||||
position_stack = []
|
||||
@@ -543,12 +547,13 @@ class CablePath(models.Model):
|
||||
while terminations:
|
||||
|
||||
# Terminations must all be of the same type
|
||||
assert all(isinstance(t, type(terminations[0])) for t in terminations[1:])
|
||||
if not all(isinstance(t, type(terminations[0])) for t in terminations[1:]):
|
||||
raise UnsupportedCablePath(_("All mid-span terminations must have the same termination type"))
|
||||
|
||||
# All mid-span terminations must all be attached to the same device
|
||||
if not isinstance(terminations[0], PathEndpoint):
|
||||
assert all(isinstance(t, type(terminations[0])) for t in terminations[1:])
|
||||
assert all(t.parent_object == terminations[0].parent_object for t in terminations[1:])
|
||||
if (not isinstance(terminations[0], PathEndpoint) and not
|
||||
all(t.parent_object == terminations[0].parent_object for t in terminations[1:])):
|
||||
raise UnsupportedCablePath(_("All mid-span terminations must have the same parent object"))
|
||||
|
||||
# Check for a split path (e.g. rear port fanning out to multiple front ports with
|
||||
# different cables attached)
|
||||
@@ -571,8 +576,10 @@ class CablePath(models.Model):
|
||||
return None
|
||||
# Otherwise, halt the trace if no link exists
|
||||
break
|
||||
assert all(type(link) in (Cable, WirelessLink) for link in links)
|
||||
assert all(isinstance(link, type(links[0])) for link in links)
|
||||
if not all(type(link) in (Cable, WirelessLink) for link in links):
|
||||
raise UnsupportedCablePath(_("All links must be cable or wireless"))
|
||||
if not all(isinstance(link, type(links[0])) for link in links):
|
||||
raise UnsupportedCablePath(_("All links must match first link type"))
|
||||
|
||||
# Step 3: Record asymmetric paths as split
|
||||
not_connected_terminations = [termination.link for termination in terminations if termination.link is None]
|
||||
@@ -653,14 +660,18 @@ class CablePath(models.Model):
|
||||
positions = position_stack.pop()
|
||||
|
||||
# Ensure we have a number of positions equal to the amount of remote terminations
|
||||
assert len(remote_terminations) == len(positions)
|
||||
if len(remote_terminations) != len(positions):
|
||||
raise UnsupportedCablePath(
|
||||
_("All positions counts within the path on opposite ends of links must match")
|
||||
)
|
||||
|
||||
# Get our front ports
|
||||
q_filter = Q()
|
||||
for rt in remote_terminations:
|
||||
position = positions.pop()
|
||||
q_filter |= Q(rear_port_id=rt.pk, rear_port_position=position)
|
||||
assert q_filter is not Q()
|
||||
if q_filter is Q():
|
||||
raise UnsupportedCablePath(_("Remote termination position filter is missing"))
|
||||
front_ports = FrontPort.objects.filter(q_filter)
|
||||
# Obtain the individual front ports based on the termination and position
|
||||
elif position_stack:
|
||||
|
||||
@@ -934,6 +934,8 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
|
||||
raise ValidationError({'rf_channel_width': _("Cannot specify custom width with channel selected.")})
|
||||
|
||||
# VLAN validation
|
||||
if not self.mode and self.untagged_vlan:
|
||||
raise ValidationError({'untagged_vlan': _("Interface mode does not support an untagged vlan.")})
|
||||
|
||||
# Validate untagged VLAN
|
||||
if self.untagged_vlan and self.untagged_vlan.site not in [self.device.site, None]:
|
||||
|
||||
@@ -802,14 +802,10 @@ class Device(
|
||||
verbose_name_plural = _('devices')
|
||||
|
||||
def __str__(self):
|
||||
if self.name and self.asset_tag:
|
||||
return f'{self.name} ({self.asset_tag})'
|
||||
elif self.name:
|
||||
return self.name
|
||||
elif self.virtual_chassis and self.asset_tag:
|
||||
return f'{self.virtual_chassis.name}:{self.vc_position} ({self.asset_tag})'
|
||||
elif self.virtual_chassis:
|
||||
return f'{self.virtual_chassis.name}:{self.vc_position} ({self.pk})'
|
||||
if self.label and self.asset_tag:
|
||||
return f'{self.label} ({self.asset_tag})'
|
||||
elif self.label:
|
||||
return self.label
|
||||
elif self.device_type and self.asset_tag:
|
||||
return f'{self.device_type.manufacturer} {self.device_type.model} ({self.asset_tag})'
|
||||
elif self.device_type:
|
||||
@@ -1073,14 +1069,22 @@ class Device(
|
||||
device.location = self.location
|
||||
device.save()
|
||||
|
||||
@property
|
||||
def label(self):
|
||||
"""
|
||||
Return the device name if set; otherwise return a generated name if available.
|
||||
"""
|
||||
if self.name:
|
||||
return self.name
|
||||
if self.virtual_chassis:
|
||||
return f'{self.virtual_chassis.name}:{self.vc_position}'
|
||||
|
||||
@property
|
||||
def identifier(self):
|
||||
"""
|
||||
Return the device name if set; otherwise return the Device's primary key as {pk}
|
||||
"""
|
||||
if self.name is not None:
|
||||
return self.name
|
||||
return '{{{}}}'.format(self.pk)
|
||||
return self.label or '{{{}}}'.format(self.pk)
|
||||
|
||||
@property
|
||||
def primary_ip(self):
|
||||
@@ -1298,6 +1302,7 @@ class Module(PrimaryModel, ConfigContextModel):
|
||||
else:
|
||||
# ModuleBays must be saved individually for MPTT
|
||||
for instance in create_instances:
|
||||
instance.name = instance.name.replace(MODULE_TOKEN, str(self.module_bay.position))
|
||||
instance.save()
|
||||
|
||||
update_fields = ['module']
|
||||
@@ -1545,7 +1550,10 @@ class MACAddress(PrimaryModel):
|
||||
ct = ObjectType.objects.get_for_id(self._original_assigned_object_type_id)
|
||||
original_assigned_object = ct.get_object_for_this_type(pk=self._original_assigned_object_id)
|
||||
|
||||
if original_assigned_object.primary_mac_address:
|
||||
if (
|
||||
original_assigned_object.primary_mac_address
|
||||
and original_assigned_object.primary_mac_address.pk == self.pk
|
||||
):
|
||||
if not assigned_object:
|
||||
raise ValidationError(
|
||||
_("Cannot unassign MAC Address while it is designated as the primary MAC for an object")
|
||||
|
||||
@@ -44,6 +44,7 @@ class DeviceIndex(SearchIndex):
|
||||
('asset_tag', 50),
|
||||
('serial', 60),
|
||||
('name', 100),
|
||||
('virtual_chassis', 200),
|
||||
('description', 500),
|
||||
('comments', 5000),
|
||||
)
|
||||
|
||||
@@ -30,10 +30,8 @@ STROKE_RESERVED = '#4d4dff'
|
||||
|
||||
|
||||
def get_device_name(device):
|
||||
if device.virtual_chassis:
|
||||
name = f'{device.virtual_chassis.name}:{device.vc_position}'
|
||||
elif device.name:
|
||||
name = device.name
|
||||
if device.label:
|
||||
name = device.label
|
||||
else:
|
||||
name = str(device.device_type)
|
||||
if device.devicebay_count:
|
||||
|
||||
@@ -143,6 +143,7 @@ class PlatformTable(NetBoxTable):
|
||||
class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
||||
name = tables.TemplateColumn(
|
||||
verbose_name=_('Name'),
|
||||
accessor=Accessor('label'),
|
||||
template_code=DEVICE_LINK,
|
||||
linkify=True
|
||||
)
|
||||
@@ -671,7 +672,7 @@ class InterfaceTable(BaseInterfaceTable, ModularDeviceComponentTable, PathEndpoi
|
||||
'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description',
|
||||
'mark_connected', 'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection',
|
||||
'tags', 'vdcs', 'vrf', 'l2vpn', 'tunnel', 'ip_addresses', 'fhrp_groups', 'untagged_vlan', 'tagged_vlans',
|
||||
'qinq_svlan', 'inventory_items', 'created', 'last_updated',
|
||||
'qinq_svlan', 'inventory_items', 'created', 'last_updated', 'vlan_translation_policy'
|
||||
)
|
||||
default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')
|
||||
|
||||
|
||||
@@ -31,6 +31,11 @@ class ManufacturerTable(ContactsColumnMixin, NetBoxTable):
|
||||
verbose_name=_('Name'),
|
||||
linkify=True
|
||||
)
|
||||
racktype_count = columns.LinkedCountColumn(
|
||||
viewname='dcim:racktype_list',
|
||||
url_params={'manufacturer_id': 'pk'},
|
||||
verbose_name=_('Rack Types')
|
||||
)
|
||||
devicetype_count = columns.LinkedCountColumn(
|
||||
viewname='dcim:devicetype_list',
|
||||
url_params={'manufacturer_id': 'pk'},
|
||||
@@ -58,12 +63,12 @@ class ManufacturerTable(ContactsColumnMixin, NetBoxTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = models.Manufacturer
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'devicetype_count', 'moduletype_count', 'inventoryitem_count', 'platform_count',
|
||||
'description', 'slug', 'tags', 'contacts', 'actions', 'created', 'last_updated',
|
||||
'pk', 'id', 'name', 'racktype_count', 'devicetype_count', 'moduletype_count', 'inventoryitem_count',
|
||||
'platform_count', 'description', 'slug', 'tags', 'contacts', 'actions', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = (
|
||||
'pk', 'name', 'devicetype_count', 'moduletype_count', 'inventoryitem_count', 'platform_count',
|
||||
'description', 'slug',
|
||||
'pk', 'name', 'racktype_count', 'devicetype_count', 'moduletype_count', 'inventoryitem_count',
|
||||
'platform_count', 'description', 'slug',
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -94,7 +94,6 @@ class SiteTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
||||
verbose_name=_('ASNs')
|
||||
)
|
||||
asn_count = columns.LinkedCountColumn(
|
||||
accessor=tables.A('asns__count'),
|
||||
viewname='ipam:asn_list',
|
||||
url_params={'site_id': 'pk'},
|
||||
verbose_name=_('ASN Count')
|
||||
|
||||
@@ -159,8 +159,8 @@ CONSOLEPORT_BUTTONS = """
|
||||
</span>
|
||||
{% endif %}
|
||||
{% elif perms.dcim.add_cable %}
|
||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
|
||||
<span class="dropdown">
|
||||
<button type="button" class="btn btn-success btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
|
||||
@@ -172,7 +172,7 @@ CONSOLEPORT_BUTTONS = """
|
||||
</ul>
|
||||
</span>
|
||||
{% else %}
|
||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
@@ -209,8 +209,8 @@ CONSOLESERVERPORT_BUTTONS = """
|
||||
</span>
|
||||
{% endif %}
|
||||
{% elif perms.dcim.add_cable %}
|
||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
|
||||
<span class="dropdown">
|
||||
<button type="button" class="btn btn-success btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
|
||||
@@ -222,7 +222,7 @@ CONSOLESERVERPORT_BUTTONS = """
|
||||
</ul>
|
||||
</span>
|
||||
{% else %}
|
||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
@@ -259,8 +259,8 @@ POWERPORT_BUTTONS = """
|
||||
</span>
|
||||
{% endif %}
|
||||
{% elif perms.dcim.add_cable %}
|
||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
|
||||
<span class="dropdown">
|
||||
<button type="button" class="btn btn-success btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span>
|
||||
@@ -271,7 +271,7 @@ POWERPORT_BUTTONS = """
|
||||
</ul>
|
||||
</span>
|
||||
{% else %}
|
||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
@@ -308,14 +308,14 @@ POWEROUTLET_BUTTONS = """
|
||||
</span>
|
||||
{% endif %}
|
||||
{% elif perms.dcim.add_cable %}
|
||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
|
||||
{% if not record.mark_connected %}
|
||||
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.poweroutlet&a_terminations={{ record.pk }}&b_terminations_type=dcim.powerport&termination_b_site={{ object.site.pk }}&termination_b_rack={{ object.rack.pk }}&return_url={% url 'dcim:device_poweroutlets' pk=object.pk %}" title="Connect" class="btn btn-success btn-sm">
|
||||
<i class="mdi mdi-ethernet-cable" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
"""
|
||||
@@ -402,8 +402,8 @@ INTERFACE_BUTTONS = """
|
||||
</a>
|
||||
{% endif %}
|
||||
{% elif record.is_wired and perms.dcim.add_cable %}
|
||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
|
||||
{% if not record.mark_connected %}
|
||||
<span class="dropdown">
|
||||
<button type="button" class="btn btn-success btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false" title="Connect cable">
|
||||
@@ -417,7 +417,7 @@ INTERFACE_BUTTONS = """
|
||||
</ul>
|
||||
</span>
|
||||
{% else %}
|
||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
|
||||
{% endif %}
|
||||
{% elif record.is_wireless and perms.wireless.add_wirelesslink %}
|
||||
<a href="{% url 'wireless:wirelesslink_add' %}?site_a={{ record.device.site.pk }}&location_a={{ record.device.location.pk }}&device_a={{ record.device_id }}&interface_a={{ record.pk }}&site_b={{ record.device.site.pk }}&location_b={{ record.device.location.pk }}" class="btn btn-success btn-sm">
|
||||
@@ -459,8 +459,8 @@ FRONTPORT_BUTTONS = """
|
||||
</span>
|
||||
{% endif %}
|
||||
{% elif perms.dcim.add_cable %}
|
||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
|
||||
{% if not record.mark_connected %}
|
||||
<span class="dropdown">
|
||||
<button type="button" class="btn btn-success btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
@@ -476,7 +476,7 @@ FRONTPORT_BUTTONS = """
|
||||
</ul>
|
||||
</span>
|
||||
{% else %}
|
||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
"""
|
||||
@@ -514,8 +514,8 @@ REARPORT_BUTTONS = """
|
||||
</span>
|
||||
{% endif %}
|
||||
{% elif perms.dcim.add_cable %}
|
||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-lan-connect" aria-hidden="true"></i></a>
|
||||
{% if not record.mark_connected %}
|
||||
<span class="dropdown">
|
||||
<button type="button" class="btn btn-success btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
@@ -531,7 +531,7 @@ REARPORT_BUTTONS = """
|
||||
</ul>
|
||||
</span>
|
||||
{% else %}
|
||||
<a href="#" class="btn btn-outline-dark btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
|
||||
<a href="#" class="btn btn-outline-secondary btn-sm disabled"><i class="mdi mdi-ethernet-cable" aria-hidden="true"></i></a>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
"""
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import json
|
||||
|
||||
from django.test import override_settings
|
||||
from django.urls import reverse
|
||||
from django.utils.translation import gettext as _
|
||||
@@ -1748,6 +1750,23 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
|
||||
},
|
||||
]
|
||||
|
||||
def _perform_interface_test_with_invalid_data(self, mode: str = None, invalid_data: dict = {}):
|
||||
device = Device.objects.first()
|
||||
data = {
|
||||
'device': device.pk,
|
||||
'name': 'Interface 1',
|
||||
'type': InterfaceTypeChoices.TYPE_1GE_FIXED,
|
||||
}
|
||||
data.update({'mode': mode})
|
||||
data.update(invalid_data)
|
||||
|
||||
response = self.client.post(self._get_list_url(), data, format='json', **self.header)
|
||||
self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
|
||||
content = json.loads(response.content)
|
||||
for key in invalid_data.keys():
|
||||
self.assertIn(key, content)
|
||||
self.assertIsNone(content.get('data'))
|
||||
|
||||
def test_bulk_delete_child_interfaces(self):
|
||||
interface1 = Interface.objects.get(name='Interface 1')
|
||||
device = interface1.device
|
||||
@@ -1775,6 +1794,57 @@ class InterfaceTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase
|
||||
self.client.delete(self._get_list_url(), data, format='json', **self.header)
|
||||
self.assertEqual(device.interfaces.count(), 2) # Child & parent were both deleted
|
||||
|
||||
def test_create_child_interfaces_mode_invalid_data(self):
|
||||
"""
|
||||
POST data to test interface mode check and invalid tagged/untagged VLANS.
|
||||
"""
|
||||
self.add_permissions('dcim.add_interface')
|
||||
|
||||
vlans = VLAN.objects.all()[0:3]
|
||||
|
||||
# Routed mode, untagged, tagged and qinq service vlan
|
||||
invalid_data = {
|
||||
'untagged_vlan': vlans[0].pk,
|
||||
'tagged_vlans': [vlans[1].pk, vlans[2].pk],
|
||||
'qinq_svlan': vlans[2].pk
|
||||
}
|
||||
self._perform_interface_test_with_invalid_data(None, invalid_data)
|
||||
|
||||
# Routed mode, untagged and tagged vlan
|
||||
invalid_data = {
|
||||
'untagged_vlan': vlans[0].pk,
|
||||
'tagged_vlans': [vlans[1].pk, vlans[2].pk],
|
||||
}
|
||||
self._perform_interface_test_with_invalid_data(None, invalid_data)
|
||||
|
||||
# Routed mode, untagged vlan
|
||||
invalid_data = {
|
||||
'untagged_vlan': vlans[0].pk,
|
||||
}
|
||||
self._perform_interface_test_with_invalid_data(None, invalid_data)
|
||||
|
||||
invalid_data = {
|
||||
'tagged_vlans': [vlans[1].pk, vlans[2].pk],
|
||||
}
|
||||
# Routed mode, qinq service vlan
|
||||
self._perform_interface_test_with_invalid_data(None, invalid_data)
|
||||
# Access mode, tagged vlans
|
||||
self._perform_interface_test_with_invalid_data(InterfaceModeChoices.MODE_ACCESS, invalid_data)
|
||||
# All tagged mode, tagged vlans
|
||||
self._perform_interface_test_with_invalid_data(InterfaceModeChoices.MODE_TAGGED_ALL, invalid_data)
|
||||
|
||||
invalid_data = {
|
||||
'qinq_svlan': vlans[0].pk,
|
||||
}
|
||||
# Routed mode, qinq service vlan
|
||||
self._perform_interface_test_with_invalid_data(None, invalid_data)
|
||||
# Access mode, qinq service vlan
|
||||
self._perform_interface_test_with_invalid_data(InterfaceModeChoices.MODE_ACCESS, invalid_data)
|
||||
# Tagged mode, qinq service vlan
|
||||
self._perform_interface_test_with_invalid_data(InterfaceModeChoices.MODE_TAGGED, invalid_data)
|
||||
# Tagged-all mode, qinq service vlan
|
||||
self._perform_interface_test_with_invalid_data(InterfaceModeChoices.MODE_TAGGED_ALL, invalid_data)
|
||||
|
||||
|
||||
class FrontPortTest(APIViewTestCases.APIViewTestCase):
|
||||
model = FrontPort
|
||||
|
||||
@@ -5,6 +5,7 @@ from dcim.choices import LinkStatusChoices
|
||||
from dcim.models import *
|
||||
from dcim.svg import CableTraceSVG
|
||||
from dcim.utils import object_to_path_node
|
||||
from utilities.exceptions import AbortRequest
|
||||
|
||||
|
||||
class CablePathTestCase(TestCase):
|
||||
@@ -2470,7 +2471,7 @@ class CablePathTestCase(TestCase):
|
||||
b_terminations=[frontport1, frontport3],
|
||||
label='C1'
|
||||
)
|
||||
with self.assertRaises(AssertionError):
|
||||
with self.assertRaises(AbortRequest):
|
||||
cable1.save()
|
||||
|
||||
self.assertPathDoesNotExist(
|
||||
@@ -2489,7 +2490,7 @@ class CablePathTestCase(TestCase):
|
||||
label='C3'
|
||||
)
|
||||
|
||||
with self.assertRaises(AssertionError):
|
||||
with self.assertRaises(AbortRequest):
|
||||
cable3.save()
|
||||
|
||||
self.assertPathDoesNotExist(
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
from django.test import TestCase
|
||||
|
||||
from dcim.choices import DeviceFaceChoices, DeviceStatusChoices, InterfaceTypeChoices
|
||||
from dcim.choices import DeviceFaceChoices, DeviceStatusChoices, InterfaceTypeChoices, InterfaceModeChoices
|
||||
from dcim.forms import *
|
||||
from dcim.models import *
|
||||
from ipam.models import VLAN
|
||||
from utilities.testing import create_test_device
|
||||
from virtualization.models import Cluster, ClusterGroup, ClusterType
|
||||
|
||||
@@ -117,11 +118,23 @@ class DeviceTestCase(TestCase):
|
||||
self.assertIn('position', form.errors)
|
||||
|
||||
|
||||
class LabelTestCase(TestCase):
|
||||
class InterfaceTestCase(TestCase):
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.device = create_test_device('Device 1')
|
||||
cls.vlans = (
|
||||
VLAN(name='VLAN 1', vid=1),
|
||||
VLAN(name='VLAN 2', vid=2),
|
||||
VLAN(name='VLAN 3', vid=3),
|
||||
)
|
||||
VLAN.objects.bulk_create(cls.vlans)
|
||||
cls.interface = Interface.objects.create(
|
||||
device=cls.device,
|
||||
name='Interface 1',
|
||||
type=InterfaceTypeChoices.TYPE_1GE_GBIC,
|
||||
mode=InterfaceModeChoices.MODE_TAGGED,
|
||||
)
|
||||
|
||||
def test_interface_label_count_valid(self):
|
||||
"""
|
||||
@@ -151,3 +164,152 @@ class LabelTestCase(TestCase):
|
||||
|
||||
self.assertFalse(form.is_valid())
|
||||
self.assertIn('label', form.errors)
|
||||
|
||||
def test_create_interface_mode_valid_data(self):
|
||||
"""
|
||||
Test that saving valid interface mode and tagged/untagged vlans works properly
|
||||
"""
|
||||
|
||||
# Validate access mode
|
||||
data = {
|
||||
'device': self.device.pk,
|
||||
'name': 'ethernet1/1',
|
||||
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
|
||||
'mode': InterfaceModeChoices.MODE_ACCESS,
|
||||
'untagged_vlan': self.vlans[0].pk
|
||||
}
|
||||
form = InterfaceCreateForm(data)
|
||||
|
||||
self.assertTrue(form.is_valid())
|
||||
|
||||
# Validate tagged vlans
|
||||
data = {
|
||||
'device': self.device.pk,
|
||||
'name': 'ethernet1/2',
|
||||
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
|
||||
'mode': InterfaceModeChoices.MODE_TAGGED,
|
||||
'untagged_vlan': self.vlans[0].pk,
|
||||
'tagged_vlans': [self.vlans[1].pk, self.vlans[2].pk]
|
||||
}
|
||||
form = InterfaceCreateForm(data)
|
||||
self.assertTrue(form.is_valid())
|
||||
|
||||
# Validate tagged vlans
|
||||
data = {
|
||||
'device': self.device.pk,
|
||||
'name': 'ethernet1/3',
|
||||
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
|
||||
'mode': InterfaceModeChoices.MODE_TAGGED_ALL,
|
||||
'untagged_vlan': self.vlans[0].pk,
|
||||
}
|
||||
form = InterfaceCreateForm(data)
|
||||
self.assertTrue(form.is_valid())
|
||||
|
||||
def test_create_interface_mode_access_invalid_data(self):
|
||||
"""
|
||||
Test that saving invalid interface mode and tagged/untagged vlans works properly
|
||||
"""
|
||||
data = {
|
||||
'device': self.device.pk,
|
||||
'name': 'ethernet1/4',
|
||||
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
|
||||
'mode': InterfaceModeChoices.MODE_ACCESS,
|
||||
'untagged_vlan': self.vlans[0].pk,
|
||||
'tagged_vlans': [self.vlans[1].pk, self.vlans[2].pk]
|
||||
}
|
||||
form = InterfaceCreateForm(data)
|
||||
|
||||
self.assertTrue(form.is_valid())
|
||||
self.assertIn('untagged_vlan', form.cleaned_data.keys())
|
||||
self.assertNotIn('tagged_vlans', form.cleaned_data.keys())
|
||||
self.assertNotIn('qinq_svlan', form.cleaned_data.keys())
|
||||
|
||||
def test_edit_interface_mode_access_invalid_data(self):
|
||||
"""
|
||||
Test that saving invalid interface mode and tagged/untagged vlans works properly
|
||||
"""
|
||||
data = {
|
||||
'device': self.device.pk,
|
||||
'name': 'Ethernet 1/5',
|
||||
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
|
||||
'mode': InterfaceModeChoices.MODE_ACCESS,
|
||||
'tagged_vlans': [self.vlans[0].pk, self.vlans[1].pk, self.vlans[2].pk]
|
||||
}
|
||||
form = InterfaceForm(data, instance=self.interface)
|
||||
|
||||
self.assertTrue(form.is_valid())
|
||||
self.assertIn('untagged_vlan', form.cleaned_data.keys())
|
||||
self.assertNotIn('tagged_vlans', form.cleaned_data.keys())
|
||||
self.assertNotIn('qinq_svlan', form.cleaned_data.keys())
|
||||
|
||||
def test_create_interface_mode_tagged_all_invalid_data(self):
|
||||
"""
|
||||
Test that saving invalid interface mode and tagged/untagged vlans works properly
|
||||
"""
|
||||
data = {
|
||||
'device': self.device.pk,
|
||||
'name': 'ethernet1/6',
|
||||
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
|
||||
'mode': InterfaceModeChoices.MODE_TAGGED_ALL,
|
||||
'tagged_vlans': [self.vlans[0].pk, self.vlans[1].pk, self.vlans[2].pk]
|
||||
}
|
||||
form = InterfaceCreateForm(data)
|
||||
|
||||
self.assertTrue(form.is_valid())
|
||||
self.assertIn('untagged_vlan', form.cleaned_data.keys())
|
||||
self.assertNotIn('tagged_vlans', form.cleaned_data.keys())
|
||||
self.assertNotIn('qinq_svlan', form.cleaned_data.keys())
|
||||
|
||||
def test_edit_interface_mode_tagged_all_invalid_data(self):
|
||||
"""
|
||||
Test that saving invalid interface mode and tagged/untagged vlans works properly
|
||||
"""
|
||||
data = {
|
||||
'device': self.device.pk,
|
||||
'name': 'Ethernet 1/7',
|
||||
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
|
||||
'mode': InterfaceModeChoices.MODE_TAGGED_ALL,
|
||||
'tagged_vlans': [self.vlans[0].pk, self.vlans[1].pk, self.vlans[2].pk]
|
||||
}
|
||||
form = InterfaceForm(data)
|
||||
self.assertTrue(form.is_valid())
|
||||
self.assertIn('untagged_vlan', form.cleaned_data.keys())
|
||||
self.assertNotIn('tagged_vlans', form.cleaned_data.keys())
|
||||
self.assertNotIn('qinq_svlan', form.cleaned_data.keys())
|
||||
|
||||
def test_create_interface_mode_routed_invalid_data(self):
|
||||
"""
|
||||
Test that saving invalid interface mode (routed) and tagged/untagged vlans works properly
|
||||
"""
|
||||
data = {
|
||||
'device': self.device.pk,
|
||||
'name': 'ethernet1/6',
|
||||
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
|
||||
'mode': None,
|
||||
'untagged_vlan': self.vlans[0].pk,
|
||||
'tagged_vlans': [self.vlans[0].pk, self.vlans[1].pk, self.vlans[2].pk]
|
||||
}
|
||||
form = InterfaceCreateForm(data)
|
||||
|
||||
self.assertTrue(form.is_valid())
|
||||
self.assertNotIn('untagged_vlan', form.cleaned_data.keys())
|
||||
self.assertNotIn('tagged_vlans', form.cleaned_data.keys())
|
||||
self.assertNotIn('qinq_svlan', form.cleaned_data.keys())
|
||||
|
||||
def test_edit_interface_mode_routed_invalid_data(self):
|
||||
"""
|
||||
Test that saving invalid interface mode (routed) and tagged/untagged vlans works properly
|
||||
"""
|
||||
data = {
|
||||
'device': self.device.pk,
|
||||
'name': 'Ethernet 1/7',
|
||||
'type': InterfaceTypeChoices.TYPE_1GE_GBIC,
|
||||
'mode': None,
|
||||
'untagged_vlan': self.vlans[0].pk,
|
||||
'tagged_vlans': [self.vlans[0].pk, self.vlans[1].pk, self.vlans[2].pk]
|
||||
}
|
||||
form = InterfaceForm(data)
|
||||
self.assertTrue(form.is_valid())
|
||||
self.assertNotIn('untagged_vlan', form.cleaned_data.keys())
|
||||
self.assertNotIn('tagged_vlans', form.cleaned_data.keys())
|
||||
self.assertNotIn('qinq_svlan', form.cleaned_data.keys())
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.test import TestCase
|
||||
from django.test import tag, TestCase
|
||||
|
||||
from circuits.models import *
|
||||
from core.models import ObjectType
|
||||
@@ -12,6 +12,43 @@ from utilities.data import drange
|
||||
from virtualization.models import Cluster, ClusterType
|
||||
|
||||
|
||||
class MACAddressTestCase(TestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
site = Site.objects.create(name='Test Site 1', slug='test-site-1')
|
||||
manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
|
||||
device_type = DeviceType.objects.create(
|
||||
manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1'
|
||||
)
|
||||
device_role = DeviceRole.objects.create(name='Test Role 1', slug='test-role-1')
|
||||
device = Device.objects.create(
|
||||
name='Device 1', device_type=device_type, role=device_role, site=site,
|
||||
)
|
||||
cls.interface = Interface.objects.create(
|
||||
device=device,
|
||||
name='Interface 1',
|
||||
type=InterfaceTypeChoices.TYPE_1GE_FIXED,
|
||||
mgmt_only=True
|
||||
)
|
||||
|
||||
cls.mac_a = MACAddress.objects.create(mac_address='1234567890ab', assigned_object=cls.interface)
|
||||
cls.mac_b = MACAddress.objects.create(mac_address='1234567890ba', assigned_object=cls.interface)
|
||||
|
||||
cls.interface.primary_mac_address = cls.mac_a
|
||||
cls.interface.save()
|
||||
|
||||
@tag('regression')
|
||||
def test_clean_will_not_allow_removal_of_assigned_object_if_primary(self):
|
||||
self.mac_a.assigned_object = None
|
||||
with self.assertRaisesMessage(ValidationError, 'Cannot unassign MAC Address while'):
|
||||
self.mac_a.clean()
|
||||
|
||||
@tag('regression')
|
||||
def test_clean_will_allow_removal_of_assigned_object_if_not_primary(self):
|
||||
self.mac_b.assigned_object = None
|
||||
self.mac_b.clean()
|
||||
|
||||
|
||||
class LocationTestCase(TestCase):
|
||||
|
||||
def test_change_location_site(self):
|
||||
@@ -590,6 +627,32 @@ class DeviceTestCase(TestCase):
|
||||
device2.full_clean()
|
||||
device2.save()
|
||||
|
||||
def test_device_label(self):
|
||||
device1 = Device(
|
||||
site=Site.objects.first(),
|
||||
device_type=DeviceType.objects.first(),
|
||||
role=DeviceRole.objects.first(),
|
||||
name=None,
|
||||
)
|
||||
self.assertEqual(device1.label, None)
|
||||
|
||||
device1.name = 'Test Device 1'
|
||||
self.assertEqual(device1.label, 'Test Device 1')
|
||||
|
||||
virtual_chassis = VirtualChassis.objects.create(name='VC 1')
|
||||
device2 = Device(
|
||||
site=Site.objects.first(),
|
||||
device_type=DeviceType.objects.first(),
|
||||
role=DeviceRole.objects.first(),
|
||||
name=None,
|
||||
virtual_chassis=virtual_chassis,
|
||||
vc_position=2,
|
||||
)
|
||||
self.assertEqual(device2.label, 'VC 1:2')
|
||||
|
||||
device2.name = 'Test Device 2'
|
||||
self.assertEqual(device2.label, 'Test Device 2')
|
||||
|
||||
def test_device_mismatched_site_cluster(self):
|
||||
cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
|
||||
Cluster.objects.create(name='Cluster 1', type=cluster_type)
|
||||
|
||||
@@ -4,17 +4,15 @@ from django.core.paginator import EmptyPage, PageNotAnInteger
|
||||
from django.db import transaction
|
||||
from django.db.models import Prefetch
|
||||
from django.forms import ModelMultipleChoiceField, MultipleHiddenInput, modelformset_factory
|
||||
from django.http import HttpResponse
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.urls import reverse
|
||||
from django.utils.html import escape
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import View
|
||||
from jinja2.exceptions import TemplateError
|
||||
|
||||
from circuits.models import Circuit, CircuitTermination
|
||||
from extras.views import ObjectConfigContextView
|
||||
from extras.views import ObjectConfigContextView, ObjectRenderConfigView
|
||||
from ipam.models import ASN, IPAddress, Prefix, VLANGroup
|
||||
from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable
|
||||
from netbox.constants import DEFAULT_ACTION_PERMISSIONS
|
||||
@@ -424,7 +422,8 @@ class SiteGroupContactsView(ObjectContactsView):
|
||||
@register_model_view(Site, 'list', path='', detail=False)
|
||||
class SiteListView(generic.ObjectListView):
|
||||
queryset = Site.objects.annotate(
|
||||
device_count=count_related(Device, 'site')
|
||||
device_count=count_related(Device, 'site'),
|
||||
asn_count=count_related(ASN, 'sites')
|
||||
)
|
||||
filterset = filtersets.SiteFilterSet
|
||||
filterset_form = forms.SiteFilterForm
|
||||
@@ -966,6 +965,7 @@ class RackReservationBulkDeleteView(generic.BulkDeleteView):
|
||||
@register_model_view(Manufacturer, 'list', path='', detail=False)
|
||||
class ManufacturerListView(generic.ObjectListView):
|
||||
queryset = Manufacturer.objects.annotate(
|
||||
racktype_count=count_related(RackType, 'manufacturer'),
|
||||
devicetype_count=count_related(DeviceType, 'manufacturer'),
|
||||
moduletype_count=count_related(ModuleType, 'manufacturer'),
|
||||
inventoryitem_count=count_related(InventoryItem, 'manufacturer'),
|
||||
@@ -2253,54 +2253,14 @@ class DeviceConfigContextView(ObjectConfigContextView):
|
||||
|
||||
|
||||
@register_model_view(Device, 'render-config')
|
||||
class DeviceRenderConfigView(generic.ObjectView):
|
||||
class DeviceRenderConfigView(ObjectRenderConfigView):
|
||||
queryset = Device.objects.all()
|
||||
template_name = 'dcim/device/render_config.html'
|
||||
base_template = 'dcim/device/base.html'
|
||||
tab = ViewTab(
|
||||
label=_('Render Config'),
|
||||
weight=2100
|
||||
weight=2100,
|
||||
)
|
||||
|
||||
def get(self, request, **kwargs):
|
||||
instance = self.get_object(**kwargs)
|
||||
context = self.get_extra_context(request, instance)
|
||||
|
||||
# If a direct export has been requested, return the rendered template content as a
|
||||
# downloadable file.
|
||||
if request.GET.get('export'):
|
||||
content = context['rendered_config'] or context['error_message']
|
||||
response = HttpResponse(content, content_type='text')
|
||||
filename = f"{instance.name or 'config'}.txt"
|
||||
response['Content-Disposition'] = f'attachment; filename="{filename}"'
|
||||
return response
|
||||
|
||||
return render(request, self.get_template_name(), {
|
||||
'object': instance,
|
||||
'tab': self.tab,
|
||||
**context,
|
||||
})
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
# Compile context data
|
||||
context_data = instance.get_config_context()
|
||||
context_data.update({'device': instance})
|
||||
|
||||
# Render the config template
|
||||
rendered_config = None
|
||||
error_message = None
|
||||
if config_template := instance.get_config_template():
|
||||
try:
|
||||
rendered_config = config_template.render(context=context_data)
|
||||
except TemplateError as e:
|
||||
error_message = _("An error occurred while rendering the template: {error}").format(error=e)
|
||||
|
||||
return {
|
||||
'config_template': config_template,
|
||||
'context_data': context_data,
|
||||
'rendered_config': rendered_config,
|
||||
'error_message': error_message,
|
||||
}
|
||||
|
||||
|
||||
@register_model_view(Device, 'virtual-machines')
|
||||
class DeviceVirtualMachinesView(generic.ObjectChildrenView):
|
||||
|
||||
@@ -155,7 +155,6 @@ class JournalEntryKindChoices(ChoiceSet):
|
||||
class LogLevelChoices(ChoiceSet):
|
||||
|
||||
LOG_DEBUG = 'debug'
|
||||
LOG_DEFAULT = 'default'
|
||||
LOG_INFO = 'info'
|
||||
LOG_SUCCESS = 'success'
|
||||
LOG_WARNING = 'warning'
|
||||
@@ -163,16 +162,15 @@ class LogLevelChoices(ChoiceSet):
|
||||
|
||||
CHOICES = (
|
||||
(LOG_DEBUG, _('Debug'), 'teal'),
|
||||
(LOG_DEFAULT, _('Default'), 'gray'),
|
||||
(LOG_INFO, _('Info'), 'cyan'),
|
||||
(LOG_SUCCESS, _('Success'), 'green'),
|
||||
(LOG_WARNING, _('Warning'), 'yellow'),
|
||||
(LOG_FAILURE, _('Failure'), 'red'),
|
||||
|
||||
)
|
||||
|
||||
SYSTEM_LEVELS = {
|
||||
LOG_DEBUG: logging.DEBUG,
|
||||
LOG_DEFAULT: logging.INFO,
|
||||
LOG_INFO: logging.INFO,
|
||||
LOG_SUCCESS: logging.INFO,
|
||||
LOG_WARNING: logging.WARNING,
|
||||
@@ -180,17 +178,6 @@ class LogLevelChoices(ChoiceSet):
|
||||
}
|
||||
|
||||
|
||||
class DurationChoices(ChoiceSet):
|
||||
|
||||
CHOICES = (
|
||||
(60, _('Hourly')),
|
||||
(720, _('12 hours')),
|
||||
(1440, _('Daily')),
|
||||
(10080, _('Weekly')),
|
||||
(43200, _('30 days')),
|
||||
)
|
||||
|
||||
|
||||
#
|
||||
# Webhooks
|
||||
#
|
||||
|
||||
@@ -138,9 +138,8 @@ DEFAULT_DASHBOARD = [
|
||||
|
||||
LOG_LEVEL_RANK = {
|
||||
LogLevelChoices.LOG_DEBUG: 0,
|
||||
LogLevelChoices.LOG_DEFAULT: 1,
|
||||
LogLevelChoices.LOG_INFO: 2,
|
||||
LogLevelChoices.LOG_SUCCESS: 3,
|
||||
LogLevelChoices.LOG_WARNING: 4,
|
||||
LogLevelChoices.LOG_FAILURE: 5,
|
||||
LogLevelChoices.LOG_INFO: 1,
|
||||
LogLevelChoices.LOG_SUCCESS: 2,
|
||||
LogLevelChoices.LOG_WARNING: 3,
|
||||
LogLevelChoices.LOG_FAILURE: 4,
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ __all__ = (
|
||||
|
||||
|
||||
class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
|
||||
model = CustomField
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id'),
|
||||
FieldSet(
|
||||
@@ -115,6 +116,7 @@ class CustomFieldFilterForm(SavedFiltersMixin, FilterForm):
|
||||
|
||||
|
||||
class CustomFieldChoiceSetFilterForm(SavedFiltersMixin, FilterForm):
|
||||
model = CustomFieldChoiceSet
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id'),
|
||||
FieldSet('base_choices', 'choice', name=_('Choices')),
|
||||
@@ -129,6 +131,7 @@ class CustomFieldChoiceSetFilterForm(SavedFiltersMixin, FilterForm):
|
||||
|
||||
|
||||
class CustomLinkFilterForm(SavedFiltersMixin, FilterForm):
|
||||
model = CustomLink
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id'),
|
||||
FieldSet('object_type', 'enabled', 'new_window', 'weight', name=_('Attributes')),
|
||||
@@ -159,6 +162,7 @@ class CustomLinkFilterForm(SavedFiltersMixin, FilterForm):
|
||||
|
||||
|
||||
class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
|
||||
model = ExportTemplate
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id'),
|
||||
FieldSet('data_source_id', 'data_file_id', name=_('Data')),
|
||||
@@ -200,6 +204,7 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
|
||||
|
||||
|
||||
class ImageAttachmentFilterForm(SavedFiltersMixin, FilterForm):
|
||||
model = ImageAttachment
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id'),
|
||||
FieldSet('object_type_id', 'name', name=_('Attributes')),
|
||||
@@ -216,6 +221,7 @@ class ImageAttachmentFilterForm(SavedFiltersMixin, FilterForm):
|
||||
|
||||
|
||||
class SavedFilterFilterForm(SavedFiltersMixin, FilterForm):
|
||||
model = SavedFilter
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id'),
|
||||
FieldSet('object_type', 'enabled', 'shared', 'weight', name=_('Attributes')),
|
||||
@@ -314,6 +320,7 @@ class TagFilterForm(SavedFiltersMixin, FilterForm):
|
||||
|
||||
|
||||
class ConfigContextFilterForm(SavedFiltersMixin, FilterForm):
|
||||
model = ConfigContext
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag_id'),
|
||||
FieldSet('data_source_id', 'data_file_id', name=_('Data')),
|
||||
@@ -403,6 +410,7 @@ class ConfigContextFilterForm(SavedFiltersMixin, FilterForm):
|
||||
|
||||
|
||||
class ConfigTemplateFilterForm(SavedFiltersMixin, FilterForm):
|
||||
model = ConfigTemplate
|
||||
fieldsets = (
|
||||
FieldSet('q', 'filter_id', 'tag'),
|
||||
FieldSet('data_source_id', 'data_file_id', name=_('Data')),
|
||||
@@ -469,6 +477,7 @@ class JournalEntryFilterForm(NetBoxModelFilterSetForm):
|
||||
|
||||
|
||||
class NotificationGroupFilterForm(SavedFiltersMixin, FilterForm):
|
||||
model = NotificationGroup
|
||||
user_id = DynamicModelMultipleChoiceField(
|
||||
queryset=User.objects.all(),
|
||||
required=False,
|
||||
|
||||
@@ -162,6 +162,7 @@ class CustomFieldForm(forms.ModelForm):
|
||||
|
||||
|
||||
class CustomFieldChoiceSetForm(forms.ModelForm):
|
||||
# TODO: The extra_choices field definition diverge from the CustomFieldChoiceSet model
|
||||
extra_choices = forms.CharField(
|
||||
widget=ChoicesWidget(),
|
||||
required=False,
|
||||
@@ -178,12 +179,25 @@ class CustomFieldChoiceSetForm(forms.ModelForm):
|
||||
def __init__(self, *args, initial=None, **kwargs):
|
||||
super().__init__(*args, initial=initial, **kwargs)
|
||||
|
||||
# Escape colons in extra_choices
|
||||
# TODO: The check for str / list below is to handle difference in extra_choices field definition
|
||||
# In CustomFieldChoiceSetForm, extra_choices is a CharField but in CustomFieldChoiceSet, it is an ArrayField
|
||||
# if standardize these, we can simplify this code
|
||||
|
||||
# Convert extra_choices Array Field from model to CharField for form
|
||||
if 'extra_choices' in self.initial and self.initial['extra_choices']:
|
||||
choices = []
|
||||
for choice in self.initial['extra_choices']:
|
||||
choice = (choice[0].replace(':', '\\:'), choice[1].replace(':', '\\:'))
|
||||
choices.append(choice)
|
||||
extra_choices = self.initial['extra_choices']
|
||||
if isinstance(extra_choices, str):
|
||||
extra_choices = [extra_choices]
|
||||
choices = ""
|
||||
for choice in extra_choices:
|
||||
# Setup choices in Add Another use case
|
||||
if isinstance(choice, str):
|
||||
choice_str = ":".join(choice.replace("'", "").replace(" ", "")[1:-1].split(","))
|
||||
choices += choice_str + "\n"
|
||||
# Setup choices in Edit use case
|
||||
elif isinstance(choice, list):
|
||||
choice_str = ":".join(choice)
|
||||
choices += choice_str + "\n"
|
||||
|
||||
self.initial['extra_choices'] = choices
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from django import forms
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from extras.choices import DurationChoices
|
||||
from core.choices import JobIntervalChoices
|
||||
from utilities.forms.widgets import DateTimePicker, NumberWithOptions
|
||||
from utilities.datetime import local_now
|
||||
|
||||
@@ -22,7 +22,7 @@ class ReportForm(forms.Form):
|
||||
min_value=1,
|
||||
label=_("Recurs every"),
|
||||
widget=NumberWithOptions(
|
||||
options=DurationChoices
|
||||
options=JobIntervalChoices
|
||||
),
|
||||
help_text=_("Interval at which this report is re-run (in minutes)")
|
||||
)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from django import forms
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from extras.choices import DurationChoices
|
||||
from core.choices import JobIntervalChoices
|
||||
from utilities.forms.widgets import DateTimePicker, NumberWithOptions
|
||||
from utilities.datetime import local_now
|
||||
|
||||
@@ -28,7 +28,7 @@ class ScriptForm(forms.Form):
|
||||
min_value=1,
|
||||
label=_("Recurs every"),
|
||||
widget=NumberWithOptions(
|
||||
options=DurationChoices
|
||||
options=JobIntervalChoices
|
||||
),
|
||||
help_text=_("Interval at which this script is re-run (in minutes)")
|
||||
)
|
||||
|
||||
@@ -100,7 +100,10 @@ class ScriptJob(JobRunner):
|
||||
|
||||
# Execute the script. If commit is True, wrap it with the event_tracking context manager to ensure we process
|
||||
# change logging, event rules, etc.
|
||||
with ExitStack() as stack:
|
||||
for request_processor in registry['request_processors']:
|
||||
stack.enter_context(request_processor(request))
|
||||
if commit:
|
||||
with ExitStack() as stack:
|
||||
for request_processor in registry['request_processors']:
|
||||
stack.enter_context(request_processor(request))
|
||||
self.run_script(script, request, data, commit)
|
||||
else:
|
||||
self.run_script(script, request, data, commit)
|
||||
|
||||
@@ -81,12 +81,17 @@ class Command(BaseCommand):
|
||||
logger.error(f'\t{field}: {error.get("message")}')
|
||||
raise CommandError()
|
||||
|
||||
# Remove extra fields from ScriptForm before passng data to script
|
||||
form.cleaned_data.pop('_schedule_at')
|
||||
form.cleaned_data.pop('_interval')
|
||||
form.cleaned_data.pop('_commit')
|
||||
|
||||
# Execute the script.
|
||||
job = ScriptJob.enqueue(
|
||||
instance=script_obj,
|
||||
user=user,
|
||||
immediate=True,
|
||||
data=data,
|
||||
data=form.cleaned_data,
|
||||
request=NetBoxFakeRequest({
|
||||
'META': {},
|
||||
'POST': data,
|
||||
|
||||
@@ -15,7 +15,7 @@ class Report(BaseScript):
|
||||
|
||||
# There is no generic log() equivalent on BaseScript
|
||||
def log(self, message):
|
||||
self._log(message, None, level=LogLevelChoices.LOG_DEFAULT)
|
||||
self._log(message, None, level=LogLevelChoices.LOG_INFO)
|
||||
|
||||
def log_success(self, obj=None, message=None):
|
||||
super().log_success(message, obj)
|
||||
|
||||
@@ -211,10 +211,12 @@ class ObjectVar(ScriptVariable):
|
||||
:param context: A custom dictionary mapping template context variables to fields, used when rendering <option>
|
||||
elements within the dropdown menu (optional)
|
||||
:param null_option: The label to use as a "null" selection option (optional)
|
||||
:param selector: Include an advanced object selection widget to assist the user in identifying the desired
|
||||
object (optional)
|
||||
"""
|
||||
form_field = DynamicModelChoiceField
|
||||
|
||||
def __init__(self, model, query_params=None, context=None, null_option=None, *args, **kwargs):
|
||||
def __init__(self, model, query_params=None, context=None, null_option=None, selector=False, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
self.field_attrs.update({
|
||||
@@ -222,6 +224,7 @@ class ObjectVar(ScriptVariable):
|
||||
'query_params': query_params,
|
||||
'context': context,
|
||||
'null_option': null_option,
|
||||
'selector': selector,
|
||||
})
|
||||
|
||||
|
||||
@@ -460,7 +463,7 @@ class BaseScript:
|
||||
# Logging
|
||||
#
|
||||
|
||||
def _log(self, message, obj=None, level=LogLevelChoices.LOG_DEFAULT):
|
||||
def _log(self, message, obj=None, level=LogLevelChoices.LOG_INFO):
|
||||
"""
|
||||
Log a message. Do not call this method directly; use one of the log_* wrappers below.
|
||||
"""
|
||||
|
||||
@@ -1118,6 +1118,7 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||
'devicerole',
|
||||
'devicetype',
|
||||
'dummymodel', # From dummy_plugin
|
||||
'dummynetboxmodel', # From dummy_plugin
|
||||
'eventrule',
|
||||
'fhrpgroup',
|
||||
'frontport',
|
||||
|
||||
@@ -75,8 +75,11 @@ urlpatterns = [
|
||||
path('scripts/add/', views.ScriptModuleCreateView.as_view(), name='scriptmodule_add'),
|
||||
path('scripts/results/<int:job_pk>/', views.ScriptResultView.as_view(), name='script_result'),
|
||||
path('scripts/<int:pk>/', views.ScriptView.as_view(), name='script'),
|
||||
path('scripts/<str:module>.<str:name>/', views.ScriptView.as_view(), name='script'),
|
||||
path('scripts/<int:pk>/source/', views.ScriptSourceView.as_view(), name='script_source'),
|
||||
path('scripts/<str:module>.<str:name>/source/', views.ScriptSourceView.as_view(), name='script_source'),
|
||||
path('scripts/<int:pk>/jobs/', views.ScriptJobsView.as_view(), name='script_jobs'),
|
||||
path('scripts/<str:module>.<str:name>/jobs/', views.ScriptJobsView.as_view(), name='script_jobs'),
|
||||
path('script-modules/<int:pk>/', include(get_model_urls('extras', 'scriptmodule'))),
|
||||
|
||||
# Markdown
|
||||
|
||||
@@ -10,6 +10,7 @@ from django.utils import timezone
|
||||
from django.utils.module_loading import import_string
|
||||
from django.utils.translation import gettext as _
|
||||
from django.views.generic import View
|
||||
from jinja2.exceptions import TemplateError
|
||||
|
||||
from core.choices import ManagedFileRootPathChoices
|
||||
from core.forms import ManagedFileForm
|
||||
@@ -885,6 +886,61 @@ class ConfigTemplateBulkSyncDataView(generic.BulkSyncDataView):
|
||||
queryset = ConfigTemplate.objects.all()
|
||||
|
||||
|
||||
class ObjectRenderConfigView(generic.ObjectView):
|
||||
base_template = None
|
||||
template_name = 'extras/object_render_config.html'
|
||||
|
||||
def get(self, request, **kwargs):
|
||||
instance = self.get_object(**kwargs)
|
||||
context = self.get_extra_context(request, instance)
|
||||
|
||||
# If a direct export has been requested, return the rendered template content as a
|
||||
# downloadable file.
|
||||
if request.GET.get('export'):
|
||||
content = context['rendered_config'] or context['error_message']
|
||||
response = HttpResponse(content, content_type='text')
|
||||
filename = f"{instance.name or 'config'}.txt"
|
||||
response['Content-Disposition'] = f'attachment; filename="{filename}"'
|
||||
return response
|
||||
|
||||
return render(
|
||||
request,
|
||||
self.get_template_name(),
|
||||
{
|
||||
'object': instance,
|
||||
'tab': self.tab,
|
||||
**context,
|
||||
},
|
||||
)
|
||||
|
||||
def get_extra_context_data(self, request, instance):
|
||||
return {
|
||||
f'{instance._meta.model_name}': instance,
|
||||
}
|
||||
|
||||
def get_extra_context(self, request, instance):
|
||||
# Compile context data
|
||||
context_data = instance.get_config_context()
|
||||
context_data.update(self.get_extra_context_data(request, instance))
|
||||
|
||||
# Render the config template
|
||||
rendered_config = None
|
||||
error_message = None
|
||||
if config_template := instance.get_config_template():
|
||||
try:
|
||||
rendered_config = config_template.render(context=context_data)
|
||||
except TemplateError as e:
|
||||
error_message = _("An error occurred while rendering the template: {error}").format(error=e)
|
||||
|
||||
return {
|
||||
'base_template': self.base_template,
|
||||
'config_template': config_template,
|
||||
'context_data': context_data,
|
||||
'rendered_config': rendered_config,
|
||||
'error_message': error_message,
|
||||
}
|
||||
|
||||
|
||||
#
|
||||
# Image attachments
|
||||
#
|
||||
@@ -1195,6 +1251,14 @@ class ScriptListView(ContentTypePermissionRequiredMixin, View):
|
||||
class BaseScriptView(generic.ObjectView):
|
||||
queryset = Script.objects.all()
|
||||
|
||||
def get_object(self, **kwargs):
|
||||
if pk := kwargs.get('pk', False):
|
||||
return get_object_or_404(self.queryset, pk=pk)
|
||||
elif (module := kwargs.get('module')) and (name := kwargs.get('name', False)):
|
||||
return get_object_or_404(self.queryset, module__file_path=f'{module}.py', name=name)
|
||||
else:
|
||||
raise Http404
|
||||
|
||||
def _get_script_class(self, script):
|
||||
"""
|
||||
Return an instance of the Script's Python class
|
||||
@@ -1315,9 +1379,9 @@ class ScriptResultView(TableMixin, generic.ObjectView):
|
||||
index = 0
|
||||
|
||||
try:
|
||||
log_threshold = LOG_LEVEL_RANK[request.GET.get('log_threshold', LogLevelChoices.LOG_DEBUG)]
|
||||
log_threshold = LOG_LEVEL_RANK[request.GET.get('log_threshold', LogLevelChoices.LOG_INFO)]
|
||||
except KeyError:
|
||||
log_threshold = LOG_LEVEL_RANK[LogLevelChoices.LOG_DEBUG]
|
||||
log_threshold = LOG_LEVEL_RANK[LogLevelChoices.LOG_INFO]
|
||||
if job.data:
|
||||
|
||||
if 'log' in job.data:
|
||||
@@ -1325,7 +1389,7 @@ class ScriptResultView(TableMixin, generic.ObjectView):
|
||||
tests = job.data['tests']
|
||||
|
||||
for log in job.data['log']:
|
||||
log_level = LOG_LEVEL_RANK.get(log.get('status'), LogLevelChoices.LOG_DEFAULT)
|
||||
log_level = LOG_LEVEL_RANK.get(log.get('status'), LogLevelChoices.LOG_INFO)
|
||||
if log_level >= log_threshold:
|
||||
index += 1
|
||||
result = {
|
||||
@@ -1348,7 +1412,7 @@ class ScriptResultView(TableMixin, generic.ObjectView):
|
||||
for method, test_data in tests.items():
|
||||
if 'log' in test_data:
|
||||
for time, status, obj, url, message in test_data['log']:
|
||||
log_level = LOG_LEVEL_RANK.get(status, LogLevelChoices.LOG_DEFAULT)
|
||||
log_level = LOG_LEVEL_RANK.get(status, LogLevelChoices.LOG_INFO)
|
||||
if log_level >= log_threshold:
|
||||
index += 1
|
||||
result = {
|
||||
@@ -1374,9 +1438,9 @@ class ScriptResultView(TableMixin, generic.ObjectView):
|
||||
if job.completed:
|
||||
table = self.get_table(job, request, bulk_actions=False)
|
||||
|
||||
log_threshold = request.GET.get('log_threshold', LogLevelChoices.LOG_DEBUG)
|
||||
log_threshold = request.GET.get('log_threshold', LogLevelChoices.LOG_INFO)
|
||||
if log_threshold not in LOG_LEVEL_RANK:
|
||||
log_threshold = LogLevelChoices.LOG_DEBUG
|
||||
log_threshold = LogLevelChoices.LOG_INFO
|
||||
|
||||
context = {
|
||||
'script': job.object,
|
||||
|
||||
@@ -212,7 +212,7 @@ class PrefixForm(TenancyForm, ScopedForm, NetBoxModelForm):
|
||||
required=False,
|
||||
selector=True,
|
||||
query_params={
|
||||
'available_at_site': '$site',
|
||||
'available_at_site': '$scope',
|
||||
},
|
||||
label=_('VLAN'),
|
||||
)
|
||||
@@ -240,6 +240,14 @@ class PrefixForm(TenancyForm, ScopedForm, NetBoxModelForm):
|
||||
'tenant', 'description', 'comments', 'tags',
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# #18605: only filter VLAN select list if scope field is a Site
|
||||
if scope_field := self.fields.get('scope', None):
|
||||
if scope_field.queryset.model is not Site:
|
||||
self.fields['vlan'].widget.attrs.pop('data-dynamic-params', None)
|
||||
|
||||
|
||||
class IPRangeForm(TenancyForm, NetBoxModelForm):
|
||||
vrf = DynamicModelChoiceField(
|
||||
|
||||
43
netbox/ipam/tests/test_forms.py
Normal file
43
netbox/ipam/tests/test_forms.py
Normal file
@@ -0,0 +1,43 @@
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.test import TestCase
|
||||
|
||||
from dcim.models import Location, Region, Site, SiteGroup
|
||||
from ipam.forms import PrefixForm
|
||||
|
||||
|
||||
class PrefixFormTestCase(TestCase):
|
||||
default_dynamic_params = '[{"fieldName":"scope","queryParam":"available_at_site"}]'
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.site = Site.objects.create(name='Site 1', slug='site-1')
|
||||
|
||||
def test_vlan_field_sets_dynamic_params_by_default(self):
|
||||
"""data-dynamic-params present when no scope_type selected"""
|
||||
form = PrefixForm(data={})
|
||||
|
||||
assert form.fields['vlan'].widget.attrs['data-dynamic-params'] == self.default_dynamic_params
|
||||
|
||||
def test_vlan_field_sets_dynamic_params_for_scope_site(self):
|
||||
"""data-dynamic-params present when scope type is Site and when scope is specifc site"""
|
||||
form = PrefixForm(data={
|
||||
'scope_type': ContentType.objects.get_for_model(Site).id,
|
||||
'scope': self.site,
|
||||
})
|
||||
|
||||
assert form.fields['vlan'].widget.attrs['data-dynamic-params'] == self.default_dynamic_params
|
||||
|
||||
def test_vlan_field_does_not_set_dynamic_params_for_other_scopes(self):
|
||||
"""data-dynamic-params not present when scope type is populated by is not Site"""
|
||||
cases = [
|
||||
Region(name='Region 1', slug='region-1'),
|
||||
Location(site=self.site, name='Location 1', slug='location-1'),
|
||||
SiteGroup(name='Site Group 1', slug='site-group-1'),
|
||||
]
|
||||
for case in cases:
|
||||
form = PrefixForm(data={
|
||||
'scope_type': ContentType.objects.get_for_model(case._meta.model).id,
|
||||
'scope': case,
|
||||
})
|
||||
|
||||
assert 'data-dynamic-params' not in form.fields['vlan'].widget.attrs
|
||||
@@ -868,6 +868,7 @@ class IPAddressEditView(generic.ObjectEditView):
|
||||
return {'interface': request.GET['interface']}
|
||||
elif 'vminterface' in request.GET:
|
||||
return {'vminterface': request.GET['vminterface']}
|
||||
return {}
|
||||
|
||||
|
||||
# TODO: Standardize or remove this view
|
||||
|
||||
@@ -121,6 +121,11 @@ class NetBoxModelViewSet(
|
||||
obj.snapshot()
|
||||
return obj
|
||||
|
||||
def get_queryset(self):
|
||||
qs = super().get_queryset()
|
||||
ordering = qs.model._meta.ordering
|
||||
return qs.order_by(*ordering)
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
# If a list of objects has been provided, initialize the serializer with many=True
|
||||
if isinstance(kwargs.get('data', {}), list):
|
||||
|
||||
@@ -169,15 +169,6 @@ class NetBoxModelFilterSetForm(CustomFieldsMixin, SavedFiltersMixin, forms.Form)
|
||||
|
||||
selector_fields = ('filter_id', 'q')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Limit saved filters to those applicable to the form's model
|
||||
object_type = ObjectType.objects.get_for_model(self.model)
|
||||
self.fields['filter_id'].widget.add_query_params({
|
||||
'object_type_id': object_type.pk,
|
||||
})
|
||||
|
||||
def _get_custom_fields(self, content_type):
|
||||
return super()._get_custom_fields(content_type).exclude(
|
||||
Q(filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED) |
|
||||
|
||||
@@ -73,6 +73,16 @@ class SavedFiltersMixin(forms.Form):
|
||||
}
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Limit saved filters to those applicable to the form's model
|
||||
if hasattr(self, 'model'):
|
||||
object_type = ObjectType.objects.get_for_model(self.model)
|
||||
self.fields['filter_id'].widget.add_query_params({
|
||||
'object_type_id': object_type.pk,
|
||||
})
|
||||
|
||||
|
||||
class TagsMixin(forms.Form):
|
||||
tags = DynamicModelMultipleChoiceField(
|
||||
|
||||
@@ -10,6 +10,7 @@ from mptt.models import MPTTModel, TreeForeignKey
|
||||
from netbox.models.features import *
|
||||
from utilities.mptt import TreeManager
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from utilities.views import get_viewname
|
||||
|
||||
|
||||
__all__ = (
|
||||
@@ -42,7 +43,7 @@ class NetBoxFeatureSet(
|
||||
return f'{settings.STATIC_URL}docs/models/{self._meta.app_label}/{self._meta.model_name}/'
|
||||
|
||||
def get_absolute_url(self):
|
||||
return reverse(f'{self._meta.app_label}:{self._meta.model_name}', args=[self.pk])
|
||||
return reverse(get_viewname(self), args=[self.pk])
|
||||
|
||||
|
||||
#
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import taggit.managers
|
||||
import utilities.json
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dummy_plugin', '0001_initial'),
|
||||
('extras', '0122_charfield_null_choices'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='DummyNetBoxModel',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
|
||||
('created', models.DateTimeField(auto_now_add=True, null=True)),
|
||||
('last_updated', models.DateTimeField(auto_now=True, null=True)),
|
||||
(
|
||||
'custom_field_data',
|
||||
models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder),
|
||||
),
|
||||
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
|
||||
],
|
||||
options={
|
||||
'abstract': False,
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -1,5 +1,7 @@
|
||||
from django.db import models
|
||||
|
||||
from netbox.models import NetBoxModel
|
||||
|
||||
|
||||
class DummyModel(models.Model):
|
||||
name = models.CharField(
|
||||
@@ -11,3 +13,7 @@ class DummyModel(models.Model):
|
||||
|
||||
class Meta:
|
||||
ordering = ['name']
|
||||
|
||||
|
||||
class DummyNetBoxModel(NetBoxModel):
|
||||
pass
|
||||
|
||||
@@ -6,4 +6,6 @@ from . import views
|
||||
urlpatterns = (
|
||||
path('models/', views.DummyModelsView.as_view(), name='dummy_model_list'),
|
||||
path('models/add/', views.DummyModelAddView.as_view(), name='dummy_model_add'),
|
||||
|
||||
path('netboxmodel/<int:pk>/', views.DummyNetBoxModelView.as_view(), name='dummynetboxmodel'),
|
||||
)
|
||||
|
||||
@@ -5,12 +5,17 @@ from django.http import HttpResponse
|
||||
from django.views.generic import View
|
||||
|
||||
from dcim.models import Site
|
||||
from netbox.views import generic
|
||||
from utilities.views import register_model_view
|
||||
from .models import DummyModel
|
||||
from .models import DummyModel, DummyNetBoxModel
|
||||
# Trigger registration of custom column
|
||||
from .tables import mycol # noqa: F401
|
||||
|
||||
|
||||
#
|
||||
# DummyModel
|
||||
#
|
||||
|
||||
class DummyModelsView(View):
|
||||
|
||||
def get(self, request):
|
||||
@@ -32,6 +37,18 @@ class DummyModelAddView(View):
|
||||
return HttpResponse("Instance created")
|
||||
|
||||
|
||||
#
|
||||
# DummyNetBoxModel
|
||||
#
|
||||
|
||||
class DummyNetBoxModelView(generic.ObjectView):
|
||||
queryset = DummyNetBoxModel.objects.all()
|
||||
|
||||
|
||||
#
|
||||
# API
|
||||
#
|
||||
|
||||
@register_model_view(Site, 'extra', path='other-stuff')
|
||||
class ExtraCoreModelView(View):
|
||||
|
||||
|
||||
23
netbox/netbox/tests/test_models.py
Normal file
23
netbox/netbox/tests/test_models.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from unittest import skipIf
|
||||
|
||||
from django.conf import settings
|
||||
from django.test import TestCase
|
||||
|
||||
from core.models import ObjectChange
|
||||
from netbox.tests.dummy_plugin.models import DummyNetBoxModel
|
||||
|
||||
|
||||
class ModelTest(TestCase):
|
||||
|
||||
def test_get_absolute_url(self):
|
||||
m = ObjectChange()
|
||||
m.pk = 123
|
||||
|
||||
self.assertEqual(m.get_absolute_url(), f'/core/changelog/{m.pk}/')
|
||||
|
||||
@skipIf('netbox.tests.dummy_plugin' not in settings.PLUGINS, "dummy_plugin not in settings.PLUGINS")
|
||||
def test_get_absolute_url_plugin(self):
|
||||
m = DummyNetBoxModel()
|
||||
m.pk = 123
|
||||
|
||||
self.assertEqual(m.get_absolute_url(), f'/plugins/dummy-plugin/netboxmodel/{m.pk}/')
|
||||
@@ -125,6 +125,11 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
|
||||
# Request handlers
|
||||
#
|
||||
|
||||
def get_queryset(self, request):
|
||||
qs = super().get_queryset(request)
|
||||
ordering = qs.model._meta.ordering
|
||||
return qs.order_by(*ordering)
|
||||
|
||||
def get(self, request):
|
||||
"""
|
||||
GET request handler.
|
||||
|
||||
@@ -166,7 +166,7 @@ class ObjectJobsView(ConditionalLoginRequiredMixin, View):
|
||||
|
||||
def get_jobs(self, instance):
|
||||
object_type = ContentType.objects.get_for_model(instance)
|
||||
return Job.objects.filter(
|
||||
return Job.objects.defer('data').filter(
|
||||
object_type=object_type,
|
||||
object_id=instance.id
|
||||
)
|
||||
|
||||
10
netbox/project-static/dist/netbox.js
vendored
10
netbox/project-static/dist/netbox.js
vendored
File diff suppressed because one or more lines are too long
4
netbox/project-static/dist/netbox.js.map
vendored
4
netbox/project-static/dist/netbox.js.map
vendored
File diff suppressed because one or more lines are too long
@@ -30,8 +30,8 @@
|
||||
"gridstack": "11.3.0",
|
||||
"htmx.org": "1.9.12",
|
||||
"query-string": "9.1.1",
|
||||
"sass": "1.83.4",
|
||||
"tom-select": "2.4.2",
|
||||
"sass": "1.85.0",
|
||||
"tom-select": "2.4.3",
|
||||
"typeface-inter": "3.18.1",
|
||||
"typeface-roboto-mono": "1.1.13"
|
||||
},
|
||||
@@ -53,5 +53,6 @@
|
||||
},
|
||||
"resolutions": {
|
||||
"@types/bootstrap/**/@popperjs/core": "^2.11.6"
|
||||
}
|
||||
},
|
||||
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
|
||||
}
|
||||
|
||||
@@ -43,7 +43,9 @@ function toggleCheckboxRange(
|
||||
const typedElement = element as HTMLInputElement;
|
||||
//Change loop's current checkbox state to eventTargetElement checkbox state
|
||||
if (changePkCheckboxState === true) {
|
||||
typedElement.checked = eventTargetElement.checked;
|
||||
if (!typedElement.closest('tr')?.classList.contains('d-none')) {
|
||||
typedElement.checked = eventTargetElement.checked;
|
||||
}
|
||||
}
|
||||
//The previously clicked checkbox was above the shift clicked checkbox
|
||||
if (element === previousStateElement) {
|
||||
@@ -52,7 +54,9 @@ function toggleCheckboxRange(
|
||||
return;
|
||||
}
|
||||
changePkCheckboxState = true;
|
||||
typedElement.checked = eventTargetElement.checked;
|
||||
if (!typedElement.closest('tr')?.classList.contains('d-none')) {
|
||||
typedElement.checked = eventTargetElement.checked;
|
||||
}
|
||||
}
|
||||
//The previously clicked checkbox was below the shift clicked checkbox
|
||||
if (element === eventTargetElement) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { initForms } from './forms';
|
||||
import { initButtons } from './buttons';
|
||||
import { initClipboard } from './clipboard'
|
||||
import { initClipboard } from './clipboard';
|
||||
import { initSelects } from './select';
|
||||
import { initObjectSelector } from './objectSelector';
|
||||
import { initBootstrap } from './bs';
|
||||
@@ -9,6 +10,7 @@ import { initQuickAdd } from './quickAdd';
|
||||
function initDepedencies(): void {
|
||||
initButtons();
|
||||
initClipboard();
|
||||
initForms();
|
||||
initSelects();
|
||||
initObjectSelector();
|
||||
initQuickAdd();
|
||||
|
||||
@@ -2673,10 +2673,10 @@ safe-regex-test@^1.0.3:
|
||||
es-errors "^1.3.0"
|
||||
is-regex "^1.1.4"
|
||||
|
||||
sass@1.83.4:
|
||||
version "1.83.4"
|
||||
resolved "https://registry.yarnpkg.com/sass/-/sass-1.83.4.tgz#5ccf60f43eb61eeec300b780b8dcb85f16eec6d1"
|
||||
integrity sha512-B1bozCeNQiOgDcLd33e2Cs2U60wZwjUUXzh900ZyQF5qUasvMdDZYbQ566LJu7cqR+sAHlAfO6RMkaID5s6qpA==
|
||||
sass@1.85.0:
|
||||
version "1.85.0"
|
||||
resolved "https://registry.yarnpkg.com/sass/-/sass-1.85.0.tgz#0127ef697d83144496401553f0a0e87be83df45d"
|
||||
integrity sha512-3ToiC1xZ1Y8aU7+CkgCI/tqyuPXEmYGJXO7H4uqp0xkLXUqp88rQQ4j1HmP37xSJLbCJPaIiv+cT1y+grssrww==
|
||||
dependencies:
|
||||
chokidar "^4.0.0"
|
||||
immutable "^5.0.2"
|
||||
@@ -2882,10 +2882,10 @@ toggle-selection@^1.0.6:
|
||||
resolved "https://registry.yarnpkg.com/toggle-selection/-/toggle-selection-1.0.6.tgz#6e45b1263f2017fa0acc7d89d78b15b8bf77da32"
|
||||
integrity sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==
|
||||
|
||||
tom-select@2.4.2:
|
||||
version "2.4.2"
|
||||
resolved "https://registry.yarnpkg.com/tom-select/-/tom-select-2.4.2.tgz#9764faf6cba51f6571d03a79bb7c1cac1cac7a5a"
|
||||
integrity sha512-2RWjkL3gMDz9E+u8w+tQy9JWsYq8gaSytEVeugKYDeMus6ZtxT1HttLPnXsfHCnBPlsNubVyj5gtUeN+S+bcpA==
|
||||
tom-select@2.4.3:
|
||||
version "2.4.3"
|
||||
resolved "https://registry.yarnpkg.com/tom-select/-/tom-select-2.4.3.tgz#1daa4131cd317de691f39eb5bf41148265986c1f"
|
||||
integrity sha512-MFFrMxP1bpnAMPbdvPCZk0KwYxLqhYZso39torcdoefeV/NThNyDu8dV96/INJ5XQVTL3O55+GqQ78Pkj5oCfw==
|
||||
dependencies:
|
||||
"@orchidjs/sifter" "^1.1.0"
|
||||
"@orchidjs/unicode-variants" "^1.1.2"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version: "4.2.3"
|
||||
version: "4.2.5"
|
||||
edition: "Community"
|
||||
published: "2025-02-04"
|
||||
published: "2025-03-06"
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<div class="btn-group btn-group-sm" role="group">
|
||||
<a href="?format=json" type="button" class="btn btn-outline-dark{% if format == 'json' %} active{% endif %}">JSON</a>
|
||||
<a href="?format=yaml" type="button" class="btn btn-outline-dark{% if format == 'yaml' %} active{% endif %}">YAML</a>
|
||||
<a href="?format=json" type="button" class="btn {% if format == 'json' %}btn-primary{% else %}btn-outline-secondary{% endif %}">JSON</a>
|
||||
<a href="?format=yaml" type="button" class="btn {% if format == 'yaml' %}btn-primary{% else %}btn-outline-secondary{% endif %}">YAML</a>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{% extends 'dcim/device/base.html' %}
|
||||
{% extends base_template %}
|
||||
{% load helpers %}
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
|
||||
@@ -67,7 +68,7 @@
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="alert alert-info">
|
||||
{% trans "No configuration template has been assigned for this device." %}
|
||||
{% trans "No configuration template has been assigned." %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -54,3 +54,7 @@
|
||||
</div>
|
||||
</div>
|
||||
{% endblock content %}
|
||||
|
||||
{% block modals %}
|
||||
{% include 'inc/htmx_modal.html' with size='lg' %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
<div class="dropdown-menu">
|
||||
{% for level, name in log_levels.items %}
|
||||
<a class="dropdown-item d-flex justify-content-between" href="{% url 'extras:script_result' job_pk=job.pk %}?log_threshold={{ level }}">
|
||||
{{ name }}{% if forloop.first %} ({% trans "All" %}){% endif %}
|
||||
{{ name }}{% if forloop.counter == 1 %} ({% trans "All" %}){% elif forloop.counter == 2 %} ({% trans "Default" %}){% endif %}
|
||||
{% if level == log_threshold %}<span class="badge bg-green ms-auto"></span>{% endif %}
|
||||
</a>
|
||||
{% endfor %}
|
||||
|
||||
@@ -55,22 +55,23 @@
|
||||
</div>
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Tagged Item Types" %}</h2>
|
||||
<table class="table table-hover attr-table">
|
||||
<ul class="list-group list-group-flush" role="presentation">
|
||||
{% for object_type in object_types %}
|
||||
<tr>
|
||||
<td>{{ object_type.content_type.name|bettertitle }}</td>
|
||||
<td>
|
||||
{% with viewname=object_type.content_type.model_class|validated_viewname:"list" %}
|
||||
{% if viewname %}
|
||||
<a href="{% url viewname %}?tag={{ object.slug }}">{{ object_type.item_count }}</a>
|
||||
{% else %}
|
||||
{{ object_type.item_count }}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</td>
|
||||
</tr>
|
||||
{% with viewname=object_type.content_type.model_class|validated_viewname:"list" %}
|
||||
{% if viewname %}
|
||||
<a href="{% url viewname %}?tag={{ object.slug }}" class="list-group-item list-group-item-action d-flex justify-content-between">
|
||||
{{ object_type.content_type.name|bettertitle }}
|
||||
<span class="badge text-bg-primary rounded-pill">{{ object_type.item_count }}</span>
|
||||
</a>
|
||||
{% else %}
|
||||
<li class="list-group-item list-group-item-action d-flex justify-content-between">
|
||||
{{ object_type.content_type.name|bettertitle }}
|
||||
<span class="badge text-bg-primary rounded-pill">{{ object_type.item_count }}</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% endfor %}
|
||||
</table>
|
||||
</ul>
|
||||
</div>
|
||||
{% plugin_right_page object %}
|
||||
</div>
|
||||
@@ -79,7 +80,7 @@
|
||||
<div class="col col-md-12">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Tagged Objects" %}</h2>
|
||||
<div class="card-body table-responsive">
|
||||
<div class="table-responsive">
|
||||
{% render_table taggeditem_table 'inc/table.html' %}
|
||||
{% include 'inc/paginator.html' with paginator=taggeditem_table.paginator page=taggeditem_table.page %}
|
||||
</div>
|
||||
|
||||
@@ -19,6 +19,8 @@
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
{% empty %}
|
||||
<span class="list-group-item text-muted">{% trans "None" %}</span>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
{% extends 'virtualization/virtualmachine/base.html' %}
|
||||
{% load static %}
|
||||
{% load i18n %}
|
||||
|
||||
{% block title %}{{ object }} - {% trans "Config" %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col-5">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Config Template" %}</h2>
|
||||
<table class="table table-hover attr-table">
|
||||
<tr>
|
||||
<th scope="row">{% trans "Config Template" %}</th>
|
||||
<td>{{ config_template|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Data Source" %}</th>
|
||||
<td>{{ config_template.data_file.source|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">{% trans "Data File" %}</th>
|
||||
<td>{{ config_template.data_file|linkify|placeholder }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-7">
|
||||
<div class="card">
|
||||
<div class="accordion accordion-flush" id="renderConfig">
|
||||
<div class="card-body">
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header" id="renderConfigHeading">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#collapsedRenderConfig" aria-expanded="false" aria-controls="collapsedRenderConfig">
|
||||
{% trans "Context Data" %}
|
||||
</button>
|
||||
</h2>
|
||||
<div id="collapsedRenderConfig" class="accordion-collapse collapse" aria-labelledby="renderConfigHeading" data-bs-parent="#renderConfig">
|
||||
<div class="accordion-body">
|
||||
<pre class="card-body">{{ context_data|pprint }}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
{% if config_template %}
|
||||
{% if rendered_config %}
|
||||
<div class="card">
|
||||
<h2 class="card-header d-flex justify-content-between">
|
||||
{% trans "Rendered Config" %}
|
||||
<a href="?export=True" class="btn btn-primary lh-1" role="button">
|
||||
<i class="mdi mdi-download" aria-hidden="true"></i> {% trans "Download" %}
|
||||
</a>
|
||||
</h2>
|
||||
<pre class="card-body">{{ rendered_config }}</pre>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-warning">
|
||||
<h4 class="alert-title mb-1">{% trans "Error rendering template" %}</h4>
|
||||
{% trans error_message %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<div class="alert alert-info">
|
||||
{% trans "No configuration template has been assigned for this virtual machine." %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user