mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-28 11:26:26 -06:00
Merge branch 'netbox-community:develop' into 2024-10-30
This commit is contained in:
commit
7bacd5727b
@ -14,7 +14,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v4.1.5
|
||||
placeholder: v4.1.10
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
2
.github/ISSUE_TEMPLATE/02-bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/02-bug_report.yaml
vendored
@ -39,7 +39,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox Version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v4.1.5
|
||||
placeholder: v4.1.10
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
3
.github/ISSUE_TEMPLATE/config.yml
vendored
3
.github/ISSUE_TEMPLATE/config.yml
vendored
@ -7,6 +7,9 @@ contact_links:
|
||||
- name: ❓ Discussion
|
||||
url: https://github.com/netbox-community/netbox/discussions
|
||||
about: "If you're just looking for help, try starting a discussion instead."
|
||||
- name: 👔 Professional Support
|
||||
url: https://netboxlabs.com/netbox-enterprise/
|
||||
about: "Professional support is available for NetBox Enterprise or Cloud."
|
||||
- name: 🌎 Correct a Translation
|
||||
url: https://explore.transifex.com/netbox-community/netbox/
|
||||
about: "Spot an incorrect translation? You can propose a fix on Transifex."
|
||||
|
5
.github/workflows/ci.yml
vendored
5
.github/workflows/ci.yml
vendored
@ -15,6 +15,11 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Add concurrency group to control job running
|
||||
concurrency:
|
||||
group: ${{ github.event_name }}-${{ github.ref }}-${{ github.actor }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
|
@ -18,8 +18,17 @@ jobs:
|
||||
NETBOX_CONFIGURATION: netbox.configuration_testing
|
||||
|
||||
steps:
|
||||
- name: Create app token
|
||||
uses: actions/create-github-app-token@v1
|
||||
id: app-token
|
||||
with:
|
||||
app-id: 1076524
|
||||
private-key: ${{ secrets.HOUSEKEEPING_SECRET_KEY }}
|
||||
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
|
12
.tx/config
Executable file
12
.tx/config
Executable file
@ -0,0 +1,12 @@
|
||||
[main]
|
||||
host = https://app.transifex.com
|
||||
|
||||
[o:netbox-community:p:netbox:r:9cbf4fcf95b3d92e4ebbf1a5e5d1caee]
|
||||
file_filter = netbox/translations/<lang>/LC_MESSAGES/django.po
|
||||
source_file = netbox/translations/en/LC_MESSAGES/django.po
|
||||
type = PO
|
||||
minimum_perc = 0
|
||||
resource_name = django.po
|
||||
replace_edited_strings = false
|
||||
keep_translations = false
|
||||
|
@ -42,7 +42,7 @@ django-rich
|
||||
|
||||
# Django integration for RQ (Reqis queuing)
|
||||
# https://github.com/rq/django-rq/blob/master/CHANGELOG.md
|
||||
django-rq<3.0
|
||||
django-rq
|
||||
|
||||
# Abstraction models for rendering and paginating HTML tables
|
||||
# https://github.com/jieter/django-tables2/blob/master/CHANGELOG.md
|
||||
@ -118,7 +118,7 @@ requests
|
||||
|
||||
# rq
|
||||
# https://github.com/rq/rq/blob/master/CHANGES.md
|
||||
rq<2.0
|
||||
rq
|
||||
|
||||
# Social authentication framework
|
||||
# https://github.com/python-social-auth/social-core/blob/master/CHANGELOG.md
|
||||
|
@ -329,6 +329,7 @@
|
||||
"100base-tx",
|
||||
"100base-t1",
|
||||
"1000base-t",
|
||||
"1000base-lx",
|
||||
"1000base-tx",
|
||||
"2.5gbase-t",
|
||||
"5gbase-t",
|
||||
|
@ -49,6 +49,10 @@ This key lists all models which have been registered in NetBox which are not des
|
||||
|
||||
This store maintains all registered items for plugins, such as navigation menus, template extensions, etc.
|
||||
|
||||
### `request_processors`
|
||||
|
||||
A list of context managers to invoke when processing a request e.g. in middleware or when executing a background job. Request processors can be registered with the `@register_request_processor` decorator.
|
||||
|
||||
### `search`
|
||||
|
||||
A dictionary mapping each model (identified by its app and label) to its search index class, if one has been registered for it.
|
||||
|
@ -90,7 +90,20 @@ This will automatically update the schema file at `contrib/generated_schema.json
|
||||
|
||||
### Update & Compile Translations
|
||||
|
||||
Updated language translations should be pulled from [Transifex](https://app.transifex.com/netbox-community/netbox/dashboard/) and re-compiled for each new release. Follow the documented process for [updating translated strings](./translations.md#updating-translated-strings) to do this.
|
||||
Updated language translations should be pulled from [Transifex](https://app.transifex.com/netbox-community/netbox/dashboard/) and re-compiled for each new release. First, retrieve any updated translation files using the Transifex CLI client:
|
||||
|
||||
```no-highlight
|
||||
tx pull
|
||||
```
|
||||
|
||||
Then, compile these portable (`.po`) files for use in the application:
|
||||
|
||||
```no-highlight
|
||||
./manage.py compilemessages
|
||||
```
|
||||
|
||||
!!! tip
|
||||
Consult the translation documentation for more detail on [updating translated strings](./translations.md#updating-translated-strings) if you've not set up the Transifex client already.
|
||||
|
||||
### Update Version and Changelog
|
||||
|
||||
|
@ -16,26 +16,31 @@ To update the English `.po` file from which all translations are derived, use th
|
||||
|
||||
Then, commit the change and push to the `develop` branch on GitHub. Any new strings will appear for translation on Transifex automatically.
|
||||
|
||||
!!! note
|
||||
It is typically not necessary to update source strings manually, as this is done nightly by a [GitHub action](https://github.com/netbox-community/netbox/blob/develop/.github/workflows/update-translation-strings.yml).
|
||||
|
||||
## Updating Translated Strings
|
||||
|
||||
Typically, translated strings need to be updated only as part of the NetBox [release process](./release-checklist.md).
|
||||
|
||||
Check the Transifex dashboard for languages that are not marked _ready for use_, being sure to click _Show all languages_ if it appears at the bottom of the list. Use machine translation to round out any not-ready languages. It's not necessary to review the machine translation immediately as the translation teams will handle that aspect; the goal at this stage is to get translations included in the Transifex pull request.
|
||||
|
||||
To update translated strings, start by initiating a sync from Transifex. From the Transifex dashboard, navigate to Settings > Integrations > GitHub > Manage, and click the **Manual Sync** button at top right.
|
||||
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/)
|
||||
|
||||
Enter a threshold percentage of 1 (to ensure all translations are captured) and select the `develop` branch, then click **Sync**. This will initiate a pull request to GitHub to update any newly modified translation (`.po`) files.
|
||||
Once you have the client set up, run the following command:
|
||||
|
||||
!!! tip
|
||||
The new PR should appear within a few minutes. If it does not, check that there are in fact new translations to be added.
|
||||
```no-highlight
|
||||
TX_TOKEN=$TOKEN tx pull
|
||||
```
|
||||
|
||||

|
||||
This will download all portable (`.po`) translation files from Transifex, updating them locally as needed.
|
||||
|
||||
Once the PR has been merged, the updated strings need to be compiled into new `.mo` files so they can be used by the application. Update the `develop` branch locally to pull in the changes from the Transifex PR, then run Django's [`compilemessages`](https://docs.djangoproject.com/en/stable/ref/django-admin/#django-admin-compilemessages) management command:
|
||||
Once retrieved, the updated strings need to be compiled into new `.mo` files so they can be used by the application. Run Django's [`compilemessages`](https://docs.djangoproject.com/en/stable/ref/django-admin/#django-admin-compilemessages) management command to compile them:
|
||||
|
||||
```nohighlight
|
||||
```no-highlight
|
||||
./manage.py compilemessages
|
||||
```
|
||||
|
||||
|
Binary file not shown.
Before Width: | Height: | Size: 108 KiB |
Binary file not shown.
Before Width: | Height: | Size: 42 KiB |
@ -1,6 +1,6 @@
|
||||
# IKE Policies
|
||||
|
||||
An [Internet Key Exhcnage (IKE)](https://en.wikipedia.org/wiki/Internet_Key_Exchange) policy defines an IKE version, mode, and set of [proposals](./ikeproposal.md) to be used in IKE negotiation. These policies are referenced by [IPSec profiles](./ipsecprofile.md).
|
||||
An [Internet Key Exchange (IKE)](https://en.wikipedia.org/wiki/Internet_Key_Exchange) policy defines an IKE version, mode, and set of [proposals](./ikeproposal.md) to be used in IKE negotiation. These policies are referenced by [IPSec profiles](./ipsecprofile.md).
|
||||
|
||||
## Fields
|
||||
|
||||
|
@ -10,6 +10,15 @@ Minor releases are published in April, August, and December of each calendar yea
|
||||
|
||||
This page contains a history of all major and minor releases since NetBox v2.0. For more detail on a specific patch release, please see the release notes page for that specific minor release.
|
||||
|
||||
#### [Version 4.1](./version-4.1.md) (September 2024)
|
||||
|
||||
* Circuit Groups ([#7025](https://github.com/netbox-community/netbox/issues/7025))
|
||||
* VLAN Group ID Ranges ([#9627](https://github.com/netbox-community/netbox/issues/9627))
|
||||
* Nested Device Modules ([#10500](https://github.com/netbox-community/netbox/issues/10500))
|
||||
* Rack Types ([#12826](https://github.com/netbox-community/netbox/issues/12826))
|
||||
* Plugins Catalog Integration ([#14731](https://github.com/netbox-community/netbox/issues/14731))
|
||||
* User Notifications ([#15621](https://github.com/netbox-community/netbox/issues/15621))
|
||||
|
||||
#### [Version 4.0](./version-4.0.md) (April 2024)
|
||||
|
||||
* Complete UI Refresh ([#12128](https://github.com/netbox-community/netbox/issues/12128))
|
||||
|
@ -1,5 +1,90 @@
|
||||
# NetBox v4.1
|
||||
|
||||
## v4.1.10 (2024-12-23)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#18260](https://github.com/netbox-community/netbox/issues/18260) - Fix object change logging
|
||||
|
||||
---
|
||||
|
||||
## v4.1.9 (2024-12-17)
|
||||
|
||||
!!! danger "Do Not Use"
|
||||
This release contains a regression which breaks change logging. Please use release v4.1.10 instead.
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#17215](https://github.com/netbox-community/netbox/issues/17215) - Change the highlighted color of disabled interfaces in interface lists
|
||||
* [#18224](https://github.com/netbox-community/netbox/issues/18224) - Apply all registered request processors when running custom scripts
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#16757](https://github.com/netbox-community/netbox/issues/16757) - Fix rendering of IP addresses table when assigning an existing IP address to an interface with global HTMX navigation enabled
|
||||
* [#17868](https://github.com/netbox-community/netbox/issues/17868) - Fix `ZeroDivisionError` exception under specific circumstances when generating a cable trace
|
||||
* [#18124](https://github.com/netbox-community/netbox/issues/18124) - Enable referencing cable attributes when querying a `cabletermination_set` via the GraphQL API
|
||||
* [#18230](https://github.com/netbox-community/netbox/issues/18230) - Fix `AttributeError` exception when attempting to edit an IP address assigned to a virtual machine interface
|
||||
|
||||
---
|
||||
|
||||
## v4.1.8 (2024-12-12)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#17071](https://github.com/netbox-community/netbox/issues/17071) - Enable OOB IP address designation during bulk import
|
||||
* [#17465](https://github.com/netbox-community/netbox/issues/17465) - Enable designation of rack type during bulk import & bulk edit
|
||||
* [#17889](https://github.com/netbox-community/netbox/issues/17889) - Enable designating an IP address as out-of-band for a device upon creation
|
||||
* [#17960](https://github.com/netbox-community/netbox/issues/17960) - Add L2TP, PPTP, Wireguard, and OpenVPN tunnel types
|
||||
* [#18021](https://github.com/netbox-community/netbox/issues/18021) - Automatically clear cache on restart when `DEBUG` is enabled
|
||||
* [#18061](https://github.com/netbox-community/netbox/issues/18061) - Omit stack trace from rendered device/VM configuration when an exception is raised
|
||||
* [#18065](https://github.com/netbox-community/netbox/issues/18065) - Include status in device details when hovering on rack elevation
|
||||
* [#18211](https://github.com/netbox-community/netbox/issues/18211) - Enable the dynamic registration of context managers for request processing
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#14044](https://github.com/netbox-community/netbox/issues/14044) - Fix unhandled AttributeError exception when bulk renaming objects
|
||||
* [#17490](https://github.com/netbox-community/netbox/issues/17490) - Fix dynamic inclusion support for config templates
|
||||
* [#17810](https://github.com/netbox-community/netbox/issues/17810) - Fix validation of racked device fields when modifying via REST API
|
||||
* [#17820](https://github.com/netbox-community/netbox/issues/17820) - Ensure default custom field values are populated when creating new modules
|
||||
* [#18044](https://github.com/netbox-community/netbox/issues/18044) - Show plugin-generated alerts within UI views for custom scripts
|
||||
* [#18150](https://github.com/netbox-community/netbox/issues/18150) - Fix REST API pagination for low `MAX_PAGE_SIZE` values
|
||||
* [#18183](https://github.com/netbox-community/netbox/issues/18183) - Omit UI navigation bar when printing
|
||||
* [#18213](https://github.com/netbox-community/netbox/issues/18213) - Fix searching for ASN ranges by name
|
||||
|
||||
---
|
||||
|
||||
## v4.1.7 (2024-11-21)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#15239](https://github.com/netbox-community/netbox/issues/15239) - Enable adding/removing individual VLANs while bulk editing device interfaces
|
||||
* [#17871](https://github.com/netbox-community/netbox/issues/17871) - Enable the assignment/removal of virtualization cluster via device bulk edit
|
||||
* [#17934](https://github.com/netbox-community/netbox/issues/17934) - Add 1000Base-LX interface type
|
||||
* [#18007](https://github.com/netbox-community/netbox/issues/18007) - Hide sensitive parameters under data source view (even for privileged users)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#17459](https://github.com/netbox-community/netbox/issues/17459) - Correct help text on `name` field of module type component templates
|
||||
* [#17901](https://github.com/netbox-community/netbox/issues/17901) - Ensure GraphiQL UI resources are served locally
|
||||
* [#17921](https://github.com/netbox-community/netbox/issues/17921) - Fix scheduling of recurring custom scripts
|
||||
* [#17923](https://github.com/netbox-community/netbox/issues/17923) - Fix the execution of custom scripts via REST API & management command
|
||||
* [#17963](https://github.com/netbox-community/netbox/issues/17963) - Fix selection of all listed objects during bulk edit
|
||||
* [#17969](https://github.com/netbox-community/netbox/issues/17969) - Fix system info export when a config revision exists
|
||||
* [#17972](https://github.com/netbox-community/netbox/issues/17972) - Force evaluation of `LOGIN_REQUIRED` when requesting static media
|
||||
* [#17986](https://github.com/netbox-community/netbox/issues/17986) - Correct labels for virtual machine & virtual disk size properties
|
||||
* [#18037](https://github.com/netbox-community/netbox/issues/18037) - Fix validation of maximum VLAN ID value when defining VLAN groups
|
||||
* [#18038](https://github.com/netbox-community/netbox/issues/18038) - The `to_grams()` utility function should always return an integer value
|
||||
|
||||
---
|
||||
|
||||
## v4.1.6 (2024-10-31)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#17700](https://github.com/netbox-community/netbox/issues/17700) - Fix warning when no scripts are found within a script module
|
||||
* [#17884](https://github.com/netbox-community/netbox/issues/17884) - Fix translation support for certain tab headings
|
||||
* [#17885](https://github.com/netbox-community/netbox/issues/17885) - Fix regression preventing custom scripts from executing
|
||||
|
||||
## v4.1.5 (2024-10-28)
|
||||
|
||||
### Enhancements
|
||||
|
@ -1,4 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
from django.db import models
|
||||
from django.db.migrations.operations import AlterModelOptions
|
||||
|
||||
@ -19,6 +21,11 @@ class CoreConfig(AppConfig):
|
||||
from core.api import schema # noqa: F401
|
||||
from netbox.models.features import register_models
|
||||
from . import data_backends, events, search # noqa: F401
|
||||
from netbox import context_managers # noqa: F401
|
||||
|
||||
# Register models
|
||||
register_models(*self.get_models())
|
||||
|
||||
# Clear Redis cache on startup in development mode
|
||||
if settings.DEBUG:
|
||||
cache.clear()
|
||||
|
@ -9,6 +9,7 @@ from django.db import models
|
||||
from django.urls import reverse
|
||||
from django.utils import timezone
|
||||
from django.utils.translation import gettext as _
|
||||
from rq.exceptions import InvalidJobOperation
|
||||
|
||||
from core.choices import JobStatusChoices
|
||||
from core.models import ObjectType
|
||||
@ -158,7 +159,11 @@ class Job(models.Model):
|
||||
job = queue.fetch_job(str(self.job_id))
|
||||
|
||||
if job:
|
||||
job.cancel()
|
||||
try:
|
||||
job.cancel()
|
||||
except InvalidJobOperation:
|
||||
# Job may raise this exception from get_status() if missing from Redis
|
||||
pass
|
||||
|
||||
def start(self):
|
||||
"""
|
||||
|
@ -308,6 +308,7 @@ class BackgroundTaskTestCase(TestCase):
|
||||
worker = get_worker('default')
|
||||
job = queue.enqueue(self.dummy_job_default)
|
||||
worker.prepare_job_execution(job)
|
||||
worker.prepare_execution(job)
|
||||
|
||||
self.assertEqual(job.get_status(), JobStatus.STARTED)
|
||||
|
||||
@ -345,3 +346,32 @@ class BackgroundTaskTestCase(TestCase):
|
||||
self.assertIn(str(worker1.name), str(response.content))
|
||||
self.assertIn('Birth', str(response.content))
|
||||
self.assertIn('Total working time', str(response.content))
|
||||
|
||||
|
||||
class SystemTestCase(TestCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
|
||||
self.user.is_staff = True
|
||||
self.user.save()
|
||||
|
||||
def test_system_view_default(self):
|
||||
# Test UI render
|
||||
response = self.client.get(reverse('core:system'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Test export
|
||||
response = self.client.get(f"{reverse('core:system')}?export=true")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
def test_system_view_with_config_revision(self):
|
||||
ConfigRevision.objects.create()
|
||||
|
||||
# Test UI render
|
||||
response = self.client.get(reverse('core:system'))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
# Test export
|
||||
response = self.client.get(f"{reverse('core:system')}?export=true")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
@ -626,11 +626,7 @@ class SystemView(UserPassesTestMixin, View):
|
||||
}
|
||||
|
||||
# Configuration
|
||||
try:
|
||||
config = ConfigRevision.objects.get(pk=cache.get('config_version'))
|
||||
except ConfigRevision.DoesNotExist:
|
||||
# Fall back to using the active config data if no record is found
|
||||
config = get_config()
|
||||
config = get_config()
|
||||
|
||||
# Raw data export
|
||||
if 'export' in request.GET:
|
||||
|
@ -871,6 +871,7 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
TYPE_100ME_T1 = '100base-t1'
|
||||
TYPE_100ME_SFP = '100base-x-sfp'
|
||||
TYPE_1GE_FIXED = '1000base-t'
|
||||
TYPE_1GE_LX_FIXED = '1000base-lx'
|
||||
TYPE_1GE_TX_FIXED = '1000base-tx'
|
||||
TYPE_1GE_GBIC = '1000base-x-gbic'
|
||||
TYPE_1GE_SFP = '1000base-x-sfp'
|
||||
@ -1033,6 +1034,7 @@ class InterfaceTypeChoices(ChoiceSet):
|
||||
(TYPE_100ME_FIXED, '100BASE-TX (10/100ME)'),
|
||||
(TYPE_100ME_T1, '100BASE-T1 (10/100ME Single Pair)'),
|
||||
(TYPE_1GE_FIXED, '1000BASE-T (1GE)'),
|
||||
(TYPE_1GE_LX_FIXED, '1000BASE-LX (1GE)'),
|
||||
(TYPE_1GE_TX_FIXED, '1000BASE-TX (1GE)'),
|
||||
(TYPE_2GE_FIXED, '2.5GBASE-T (2.5GE)'),
|
||||
(TYPE_5GE_FIXED, '5GBASE-T (5GE)'),
|
||||
|
@ -13,10 +13,11 @@ from tenancy.models import Tenant
|
||||
from users.models import User
|
||||
from utilities.forms import BulkEditForm, add_blank_choice, form_from_model
|
||||
from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField
|
||||
from utilities.forms.rendering import FieldSet, InlineFields
|
||||
from utilities.forms.rendering import FieldSet, InlineFields, TabbedGroups
|
||||
from utilities.forms.widgets import BulkEditNullBooleanSelect, NumberWithOptions
|
||||
from wireless.models import WirelessLAN, WirelessLANGroup
|
||||
from virtualization.models import Cluster
|
||||
from wireless.choices import WirelessRoleChoices
|
||||
from wireless.models import WirelessLAN, WirelessLANGroup
|
||||
|
||||
__all__ = (
|
||||
'CableBulkEditForm',
|
||||
@ -358,6 +359,11 @@ class RackBulkEditForm(NetBoxModelBulkEditForm):
|
||||
queryset=RackRole.objects.all(),
|
||||
required=False
|
||||
)
|
||||
rack_type = DynamicModelChoiceField(
|
||||
label=_('Rack type'),
|
||||
queryset=RackType.objects.all(),
|
||||
required=False,
|
||||
)
|
||||
serial = forms.CharField(
|
||||
max_length=50,
|
||||
required=False,
|
||||
@ -437,7 +443,7 @@ class RackBulkEditForm(NetBoxModelBulkEditForm):
|
||||
|
||||
model = Rack
|
||||
fieldsets = (
|
||||
FieldSet('status', 'role', 'tenant', 'serial', 'asset_tag', 'description', name=_('Rack')),
|
||||
FieldSet('status', 'role', 'tenant', 'serial', 'asset_tag', 'rack_type', 'description', name=_('Rack')),
|
||||
FieldSet('region', 'site_group', 'site', 'location', name=_('Location')),
|
||||
FieldSet(
|
||||
'form_factor', 'width', 'u_height', 'desc_units', 'airflow', 'outer_width', 'outer_depth', 'outer_unit',
|
||||
@ -721,6 +727,14 @@ class DeviceBulkEditForm(NetBoxModelBulkEditForm):
|
||||
queryset=ConfigTemplate.objects.all(),
|
||||
required=False
|
||||
)
|
||||
cluster = DynamicModelChoiceField(
|
||||
label=_('Cluster'),
|
||||
queryset=Cluster.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'site_id': ['$site', 'null']
|
||||
},
|
||||
)
|
||||
comments = CommentField()
|
||||
|
||||
model = Device
|
||||
@ -729,9 +743,10 @@ class DeviceBulkEditForm(NetBoxModelBulkEditForm):
|
||||
FieldSet('site', 'location', name=_('Location')),
|
||||
FieldSet('manufacturer', 'device_type', 'airflow', 'serial', name=_('Hardware')),
|
||||
FieldSet('config_template', name=_('Configuration')),
|
||||
FieldSet('cluster', name=_('Virtualization')),
|
||||
)
|
||||
nullable_fields = (
|
||||
'location', 'tenant', 'platform', 'serial', 'airflow', 'description', 'comments',
|
||||
'location', 'tenant', 'platform', 'serial', 'airflow', 'description', 'cluster', 'comments',
|
||||
)
|
||||
|
||||
|
||||
@ -1404,18 +1419,25 @@ class InterfaceBulkEditForm(
|
||||
parent = DynamicModelChoiceField(
|
||||
label=_('Parent'),
|
||||
queryset=Interface.objects.all(),
|
||||
required=False
|
||||
required=False,
|
||||
query_params={
|
||||
'virtual_chassis_member_id': '$device',
|
||||
}
|
||||
)
|
||||
bridge = DynamicModelChoiceField(
|
||||
label=_('Bridge'),
|
||||
queryset=Interface.objects.all(),
|
||||
required=False
|
||||
required=False,
|
||||
query_params={
|
||||
'virtual_chassis_member_id': '$device',
|
||||
}
|
||||
)
|
||||
lag = DynamicModelChoiceField(
|
||||
queryset=Interface.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'type': 'lag',
|
||||
'virtual_chassis_member_id': '$device',
|
||||
},
|
||||
label=_('LAG')
|
||||
)
|
||||
@ -1472,6 +1494,7 @@ class InterfaceBulkEditForm(
|
||||
required=False,
|
||||
query_params={
|
||||
'group_id': '$vlan_group',
|
||||
'available_on_device': '$device',
|
||||
},
|
||||
label=_('Untagged VLAN')
|
||||
)
|
||||
@ -1480,9 +1503,28 @@ class InterfaceBulkEditForm(
|
||||
required=False,
|
||||
query_params={
|
||||
'group_id': '$vlan_group',
|
||||
'available_on_device': '$device',
|
||||
},
|
||||
label=_('Tagged VLANs')
|
||||
)
|
||||
add_tagged_vlans = DynamicModelMultipleChoiceField(
|
||||
label=_('Add tagged VLANs'),
|
||||
queryset=VLAN.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'group_id': '$vlan_group',
|
||||
'available_on_device': '$device',
|
||||
},
|
||||
)
|
||||
remove_tagged_vlans = DynamicModelMultipleChoiceField(
|
||||
label=_('Remove tagged VLANs'),
|
||||
queryset=VLAN.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'group_id': '$vlan_group',
|
||||
'available_on_device': '$device',
|
||||
}
|
||||
)
|
||||
vrf = DynamicModelChoiceField(
|
||||
queryset=VRF.objects.all(),
|
||||
required=False,
|
||||
@ -1509,7 +1551,13 @@ 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', 'tagged_vlans', name=_('802.1Q Switching')),
|
||||
FieldSet('mode', 'vlan_group', 'untagged_vlan', name=_('802.1Q Switching')),
|
||||
FieldSet(
|
||||
TabbedGroups(
|
||||
FieldSet('tagged_vlans', name=_('Assignment')),
|
||||
FieldSet('add_tagged_vlans', 'remove_tagged_vlans', name=_('Add/Remove')),
|
||||
),
|
||||
),
|
||||
FieldSet(
|
||||
'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'wireless_lan_group', 'wireless_lans',
|
||||
name=_('Wireless')
|
||||
@ -1523,19 +1571,7 @@ class InterfaceBulkEditForm(
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
if self.device_id:
|
||||
device = Device.objects.filter(pk=self.device_id).first()
|
||||
|
||||
# Restrict parent/bridge/LAG interface assignment by device
|
||||
self.fields['parent'].widget.add_query_param('virtual_chassis_member_id', device.pk)
|
||||
self.fields['bridge'].widget.add_query_param('virtual_chassis_member_id', device.pk)
|
||||
self.fields['lag'].widget.add_query_param('virtual_chassis_member_id', device.pk)
|
||||
|
||||
# Limit VLAN choices by device
|
||||
self.fields['untagged_vlan'].widget.add_query_param('available_on_device', device.pk)
|
||||
self.fields['tagged_vlans'].widget.add_query_param('available_on_device', device.pk)
|
||||
|
||||
else:
|
||||
if not self.device_id:
|
||||
# See #4523
|
||||
if 'pk' in self.initial:
|
||||
site = None
|
||||
@ -1559,6 +1595,13 @@ class InterfaceBulkEditForm(
|
||||
'site_id', [site.pk, settings.FILTERS_NULL_CHOICE_VALUE]
|
||||
)
|
||||
|
||||
self.fields['add_tagged_vlans'].widget.add_query_param(
|
||||
'site_id', [site.pk, settings.FILTERS_NULL_CHOICE_VALUE]
|
||||
)
|
||||
self.fields['remove_tagged_vlans'].widget.add_query_param(
|
||||
'site_id', [site.pk, settings.FILTERS_NULL_CHOICE_VALUE]
|
||||
)
|
||||
|
||||
self.fields['parent'].choices = ()
|
||||
self.fields['parent'].widget.attrs['disabled'] = True
|
||||
self.fields['bridge'].choices = ()
|
||||
|
@ -256,6 +256,13 @@ class RackImportForm(NetBoxModelImportForm):
|
||||
to_field_name='name',
|
||||
help_text=_('Name of assigned role')
|
||||
)
|
||||
rack_type = CSVModelChoiceField(
|
||||
label=_('Rack type'),
|
||||
queryset=RackType.objects.all(),
|
||||
to_field_name='model',
|
||||
required=False,
|
||||
help_text=_('Rack type model')
|
||||
)
|
||||
form_factor = CSVChoiceField(
|
||||
label=_('Type'),
|
||||
choices=RackFormFactorChoices,
|
||||
@ -265,8 +272,13 @@ class RackImportForm(NetBoxModelImportForm):
|
||||
width = forms.ChoiceField(
|
||||
label=_('Width'),
|
||||
choices=RackWidthChoices,
|
||||
required=False,
|
||||
help_text=_('Rail-to-rail width (in inches)')
|
||||
)
|
||||
u_height = forms.IntegerField(
|
||||
required=False,
|
||||
label=_('Height (U)')
|
||||
)
|
||||
outer_unit = CSVChoiceField(
|
||||
label=_('Outer unit'),
|
||||
choices=RackDimensionUnitChoices,
|
||||
@ -289,9 +301,9 @@ class RackImportForm(NetBoxModelImportForm):
|
||||
class Meta:
|
||||
model = Rack
|
||||
fields = (
|
||||
'site', 'location', 'name', 'facility_id', 'tenant', 'status', 'role', 'form_factor', 'serial', 'asset_tag',
|
||||
'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'airflow',
|
||||
'weight', 'max_weight', 'weight_unit', 'description', 'comments', 'tags',
|
||||
'site', 'location', 'name', 'facility_id', 'tenant', 'status', 'role', 'rack_type', 'form_factor', 'serial',
|
||||
'asset_tag', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit',
|
||||
'mounting_depth', 'airflow', 'weight', 'max_weight', 'weight_unit', 'description', 'comments', 'tags',
|
||||
)
|
||||
|
||||
def __init__(self, data=None, *args, **kwargs):
|
||||
@ -303,6 +315,16 @@ class RackImportForm(NetBoxModelImportForm):
|
||||
params = {f"site__{self.fields['site'].to_field_name}": data.get('site')}
|
||||
self.fields['location'].queryset = self.fields['location'].queryset.filter(**params)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
|
||||
# width & u_height must be set if not specifying a rack type on import
|
||||
if not self.instance.pk:
|
||||
if not self.cleaned_data.get('rack_type') and not self.cleaned_data.get('width'):
|
||||
raise forms.ValidationError(_("Width must be set if not specifying a rack type."))
|
||||
if not self.cleaned_data.get('rack_type') and not self.cleaned_data.get('u_height'):
|
||||
raise forms.ValidationError(_("U height must be set if not specifying a rack type."))
|
||||
|
||||
|
||||
class RackReservationImportForm(NetBoxModelImportForm):
|
||||
site = CSVModelChoiceField(
|
||||
|
@ -909,6 +909,13 @@ class ModularComponentTemplateForm(ComponentTemplateForm):
|
||||
if self.instance.pk:
|
||||
self.fields['module_type'].disabled = True
|
||||
|
||||
# Components attached to a module need to present this standardized substitution help text.
|
||||
self.fields['name'].help_text = _(
|
||||
"Alphanumeric ranges are supported for bulk creation. Mixed cases and types within a single range are not "
|
||||
"supported (example: <code>[ge,xe]-0/0/[0-9]</code>). The token <code>{module}</code>, if present, will be "
|
||||
"automatically replaced with the position value when creating a new module."
|
||||
)
|
||||
|
||||
|
||||
class ConsolePortTemplateForm(ModularComponentTemplateForm):
|
||||
fieldsets = (
|
||||
|
@ -243,14 +243,6 @@ class InterfaceCreateForm(ComponentCreateForm, model_forms.InterfaceForm):
|
||||
class Meta(model_forms.InterfaceForm.Meta):
|
||||
exclude = ('name', 'label')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if 'module' in self.fields:
|
||||
self.fields['name'].help_text += _(
|
||||
"The string <code>{module}</code> will be replaced with the position of the assigned module, if any."
|
||||
)
|
||||
|
||||
|
||||
class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm):
|
||||
device = DynamicModelChoiceField(
|
||||
|
@ -116,7 +116,7 @@ class ModularComponentTemplateType(ComponentTemplateType):
|
||||
filters=CableTerminationFilter
|
||||
)
|
||||
class CableTerminationType(NetBoxObjectType):
|
||||
|
||||
cable: Annotated["CableType", strawberry.lazy('dcim.graphql.types')] | None
|
||||
termination: Annotated[Union[
|
||||
Annotated["CircuitTerminationType", strawberry.lazy('circuits.graphql.types')],
|
||||
Annotated["ConsolePortType", strawberry.lazy('dcim.graphql.types')],
|
||||
|
@ -1277,6 +1277,11 @@ class Module(PrimaryModel, ConfigContextModel):
|
||||
if not disable_replication:
|
||||
create_instances.append(template_instance)
|
||||
|
||||
# Set default values for any applicable custom fields
|
||||
if cf_defaults := CustomField.objects.get_defaults_for_model(component_model):
|
||||
for component in create_instances:
|
||||
component.custom_field_data = cf_defaults
|
||||
|
||||
if component_model is not ModuleBay:
|
||||
component_model.objects.bulk_create(create_instances)
|
||||
# Emit the post_save signal for each newly created object
|
||||
|
@ -362,7 +362,7 @@ class CableTraceSVG:
|
||||
self.cursor += CABLE_HEIGHT
|
||||
|
||||
# Connector (a Cable or WirelessLink)
|
||||
if links:
|
||||
if links and far_ends:
|
||||
|
||||
obj_list = {end.parent_object for end in far_ends}
|
||||
parent_object_nodes, far_terminations = self.draw_far_objects(obj_list, far_ends)
|
||||
|
@ -48,6 +48,7 @@ def get_device_description(device):
|
||||
|
||||
Name: <name>
|
||||
Role: <role>
|
||||
Status: <status>
|
||||
Device Type: <manufacturer> <model> (<u_height>)
|
||||
Asset tag: <asset_tag> (if defined)
|
||||
Serial: <serial> (if defined)
|
||||
@ -55,6 +56,7 @@ def get_device_description(device):
|
||||
"""
|
||||
description = f'Name: {device.name}'
|
||||
description += f'\nRole: {device.role}'
|
||||
description += f'\nStatus: {device.get_status_display()}'
|
||||
u_height = f'{floatformat(device.device_type.u_height)}U'
|
||||
description += f'\nDevice Type: {device.device_type.manufacturer.name} {device.device_type.model} ({u_height})'
|
||||
if device.asset_tag:
|
||||
|
@ -1,5 +1,3 @@
|
||||
import traceback
|
||||
|
||||
from django.contrib import messages
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.paginator import EmptyPage, PageNotAnInteger
|
||||
@ -35,7 +33,7 @@ from virtualization.forms import VirtualMachineFilterForm
|
||||
from virtualization.models import VirtualMachine
|
||||
from virtualization.tables import VirtualMachineTable
|
||||
from . import filtersets, forms, tables
|
||||
from .choices import DeviceFaceChoices
|
||||
from .choices import DeviceFaceChoices, InterfaceModeChoices
|
||||
from .models import *
|
||||
|
||||
CABLE_TERMINATION_TYPES = {
|
||||
@ -2106,7 +2104,8 @@ class DeviceRenderConfigView(generic.ObjectView):
|
||||
# If a direct export has been requested, return the rendered template content as a
|
||||
# downloadable file.
|
||||
if request.GET.get('export'):
|
||||
response = HttpResponse(context['rendered_config'], content_type='text')
|
||||
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
|
||||
@ -2124,17 +2123,18 @@ class DeviceRenderConfigView(generic.ObjectView):
|
||||
|
||||
# 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:
|
||||
messages.error(request, _("An error occurred while rendering the template: {error}").format(error=e))
|
||||
rendered_config = traceback.format_exc()
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
@ -2616,6 +2616,16 @@ class InterfaceBulkEditView(generic.BulkEditView):
|
||||
table = tables.InterfaceTable
|
||||
form = forms.InterfaceBulkEditForm
|
||||
|
||||
def post_save_operations(self, form, obj):
|
||||
super().post_save_operations(form, obj)
|
||||
|
||||
# Add/remove tagged VLANs
|
||||
if obj.mode == InterfaceModeChoices.MODE_TAGGED:
|
||||
if form.cleaned_data.get('add_tagged_vlans', None):
|
||||
obj.tagged_vlans.add(*form.cleaned_data['add_tagged_vlans'])
|
||||
if form.cleaned_data.get('remove_tagged_vlans', None):
|
||||
obj.tagged_vlans.remove(*form.cleaned_data['remove_tagged_vlans'])
|
||||
|
||||
|
||||
class InterfaceBulkRenameView(generic.BulkRenameView):
|
||||
queryset = Interface.objects.all()
|
||||
|
@ -1,14 +1,14 @@
|
||||
import logging
|
||||
import traceback
|
||||
from contextlib import nullcontext
|
||||
from contextlib import ExitStack
|
||||
|
||||
from django.db import transaction
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from core.signals import clear_events
|
||||
from extras.models import Script as ScriptModel
|
||||
from netbox.context_managers import event_tracking
|
||||
from netbox.jobs import JobRunner
|
||||
from netbox.registry import registry
|
||||
from utilities.exceptions import AbortScript, AbortTransaction
|
||||
from .utils import is_report
|
||||
|
||||
@ -22,9 +22,7 @@ class ScriptJob(JobRunner):
|
||||
"""
|
||||
|
||||
class Meta:
|
||||
# An explicit job name is not set because it doesn't make sense in this context. Currently, there's no scenario
|
||||
# where jobs other than this one are used. Therefore, it is hidden, resulting in a cleaner job table overview.
|
||||
name = ''
|
||||
name = 'Run Script'
|
||||
|
||||
def run_script(self, script, request, data, commit):
|
||||
"""
|
||||
@ -102,5 +100,7 @@ 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 event_tracking(request) if commit else nullcontext():
|
||||
with ExitStack() as stack:
|
||||
for request_processor in registry['request_processors']:
|
||||
stack.enter_context(request_processor(request))
|
||||
self.run_script(script, request, data, commit)
|
||||
|
@ -1141,12 +1141,14 @@ class ScriptView(BaseScriptView):
|
||||
script_class = self._get_script_class(script)
|
||||
if not script_class:
|
||||
return render(request, 'extras/script.html', {
|
||||
'object': script,
|
||||
'script': script,
|
||||
})
|
||||
|
||||
form = script_class.as_form(initial=normalize_querydict(request.GET))
|
||||
|
||||
return render(request, 'extras/script.html', {
|
||||
'object': script,
|
||||
'script': script,
|
||||
'script_class': script_class,
|
||||
'form': form,
|
||||
@ -1162,6 +1164,7 @@ class ScriptView(BaseScriptView):
|
||||
script_class = self._get_script_class(script)
|
||||
if not script_class:
|
||||
return render(request, 'extras/script.html', {
|
||||
'object': script,
|
||||
'script': script,
|
||||
})
|
||||
|
||||
@ -1181,12 +1184,12 @@ class ScriptView(BaseScriptView):
|
||||
request=copy_safe_request(request),
|
||||
job_timeout=script.python_class.job_timeout,
|
||||
commit=form.cleaned_data.pop('_commit'),
|
||||
name=script.name
|
||||
)
|
||||
|
||||
return redirect('extras:script_result', job_pk=job.pk)
|
||||
|
||||
return render(request, 'extras/script.html', {
|
||||
'object': script,
|
||||
'script': script,
|
||||
'script_class': script.python_class(),
|
||||
'form': form,
|
||||
|
@ -211,8 +211,10 @@ class ASNRangeFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
|
||||
def search(self, queryset, name, value):
|
||||
if not value.strip():
|
||||
return queryset
|
||||
qs_filter = Q(description__icontains=value)
|
||||
return queryset.filter(qs_filter)
|
||||
return queryset.filter(
|
||||
Q(name__icontains=value) |
|
||||
Q(description__icontains=value)
|
||||
)
|
||||
|
||||
|
||||
class ASNFilterSet(OrganizationalModelFilterSet, TenancyFilterSet):
|
||||
|
@ -326,12 +326,17 @@ class IPAddressImportForm(NetBoxModelImportForm):
|
||||
help_text=_('Make this the primary IP for the assigned device'),
|
||||
required=False
|
||||
)
|
||||
is_oob = forms.BooleanField(
|
||||
label=_('Is out-of-band'),
|
||||
help_text=_('Designate this as the out-of-band IP address for the assigned device'),
|
||||
required=False
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = IPAddress
|
||||
fields = [
|
||||
'address', 'vrf', 'tenant', 'status', 'role', 'device', 'virtual_machine', 'interface', 'is_primary',
|
||||
'dns_name', 'description', 'comments', 'tags',
|
||||
'is_oob', 'dns_name', 'description', 'comments', 'tags',
|
||||
]
|
||||
|
||||
def __init__(self, data=None, *args, **kwargs):
|
||||
@ -345,7 +350,7 @@ class IPAddressImportForm(NetBoxModelImportForm):
|
||||
**{f"device__{self.fields['device'].to_field_name}": data['device']}
|
||||
)
|
||||
|
||||
# Limit interface queryset by assigned device
|
||||
# Limit interface queryset by assigned VM
|
||||
elif data.get('virtual_machine'):
|
||||
self.fields['interface'].queryset = VMInterface.objects.filter(
|
||||
**{f"virtual_machine__{self.fields['virtual_machine'].to_field_name}": data['virtual_machine']}
|
||||
@ -358,16 +363,29 @@ class IPAddressImportForm(NetBoxModelImportForm):
|
||||
virtual_machine = self.cleaned_data.get('virtual_machine')
|
||||
interface = self.cleaned_data.get('interface')
|
||||
is_primary = self.cleaned_data.get('is_primary')
|
||||
is_oob = self.cleaned_data.get('is_oob')
|
||||
|
||||
# Validate is_primary
|
||||
# Validate is_primary and is_oob
|
||||
if is_primary and not device and not virtual_machine:
|
||||
raise forms.ValidationError({
|
||||
"is_primary": _("No device or virtual machine specified; cannot set as primary IP")
|
||||
})
|
||||
if is_oob and not device:
|
||||
raise forms.ValidationError({
|
||||
"is_oob": _("No device specified; cannot set as out-of-band IP")
|
||||
})
|
||||
if is_oob and virtual_machine:
|
||||
raise forms.ValidationError({
|
||||
"is_oob": _("Cannot set out-of-band IP for virtual machines")
|
||||
})
|
||||
if is_primary and not interface:
|
||||
raise forms.ValidationError({
|
||||
"is_primary": _("No interface specified; cannot set as primary IP")
|
||||
})
|
||||
if is_oob and not interface:
|
||||
raise forms.ValidationError({
|
||||
"is_oob": _("No interface specified; cannot set as out-of-band IP")
|
||||
})
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
|
||||
@ -386,6 +404,12 @@ class IPAddressImportForm(NetBoxModelImportForm):
|
||||
parent.primary_ip6 = ipaddress
|
||||
parent.save()
|
||||
|
||||
# Set as OOB for device
|
||||
if self.cleaned_data.get('is_oob'):
|
||||
parent = self.cleaned_data.get('device')
|
||||
parent.oob_ip = ipaddress
|
||||
parent.save()
|
||||
|
||||
return ipaddress
|
||||
|
||||
|
||||
|
@ -309,6 +309,10 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
|
||||
required=False,
|
||||
label=_('Make this the primary IP for the device/VM')
|
||||
)
|
||||
oob_for_parent = forms.BooleanField(
|
||||
required=False,
|
||||
label=_('Make this the out-of-band IP for the device')
|
||||
)
|
||||
comments = CommentField()
|
||||
|
||||
fieldsets = (
|
||||
@ -320,7 +324,7 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
|
||||
FieldSet('vminterface', name=_('Virtual Machine')),
|
||||
FieldSet('fhrpgroup', name=_('FHRP Group')),
|
||||
),
|
||||
'primary_for_parent', name=_('Assignment')
|
||||
'primary_for_parent', 'oob_for_parent', name=_('Assignment')
|
||||
),
|
||||
FieldSet('nat_inside', name=_('NAT IP (Inside)')),
|
||||
)
|
||||
@ -328,8 +332,8 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
|
||||
class Meta:
|
||||
model = IPAddress
|
||||
fields = [
|
||||
'address', 'vrf', 'status', 'role', 'dns_name', 'primary_for_parent', 'nat_inside', 'tenant_group',
|
||||
'tenant', 'description', 'comments', 'tags',
|
||||
'address', 'vrf', 'status', 'role', 'dns_name', 'primary_for_parent', 'oob_for_parent', 'nat_inside',
|
||||
'tenant_group', 'tenant', 'description', 'comments', 'tags',
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@ -348,7 +352,7 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# Initialize primary_for_parent if IP address is already assigned
|
||||
# Initialize parent object & fields if IP address is already assigned
|
||||
if self.instance.pk and self.instance.assigned_object:
|
||||
parent = getattr(self.instance.assigned_object, 'parent_object', None)
|
||||
if parent and (
|
||||
@ -357,6 +361,9 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
|
||||
):
|
||||
self.initial['primary_for_parent'] = True
|
||||
|
||||
if parent and getattr(parent, 'oob_ip_id', None) == self.instance.pk:
|
||||
self.initial['oob_for_parent'] = True
|
||||
|
||||
if type(instance.assigned_object) is Interface:
|
||||
self.fields['interface'].widget.add_query_params({
|
||||
'device_id': instance.assigned_object.device.pk,
|
||||
@ -385,10 +392,15 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
|
||||
})
|
||||
elif selected_objects:
|
||||
assigned_object = self.cleaned_data[selected_objects[0]]
|
||||
if self.instance.pk and self.instance.assigned_object and self.cleaned_data['primary_for_parent'] and assigned_object != self.instance.assigned_object:
|
||||
raise ValidationError(
|
||||
_("Cannot reassign IP address while it is designated as the primary IP for the parent object")
|
||||
)
|
||||
if self.instance.pk and self.instance.assigned_object and assigned_object != self.instance.assigned_object:
|
||||
if self.cleaned_data['primary_for_parent']:
|
||||
raise ValidationError(
|
||||
_("Cannot reassign primary IP address for the parent device/VM")
|
||||
)
|
||||
if self.cleaned_data['oob_for_parent']:
|
||||
raise ValidationError(
|
||||
_("Cannot reassign out-of-Band IP address for the parent device")
|
||||
)
|
||||
self.instance.assigned_object = assigned_object
|
||||
else:
|
||||
self.instance.assigned_object = None
|
||||
@ -400,6 +412,16 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
|
||||
'primary_for_parent', _("Only IP addresses assigned to an interface can be designated as primary IPs.")
|
||||
)
|
||||
|
||||
# OOB IP assignment is only available if device interface has been assigned.
|
||||
interface = self.cleaned_data.get('interface')
|
||||
if self.cleaned_data.get('oob_for_parent') and not interface:
|
||||
self.add_error(
|
||||
'oob_for_parent', _(
|
||||
"Only IP addresses assigned to a device interface can be designated as the out-of-band IP for a "
|
||||
"device."
|
||||
)
|
||||
)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
ipaddress = super().save(*args, **kwargs)
|
||||
|
||||
@ -421,6 +443,17 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
|
||||
parent.primary_ip6 = None
|
||||
parent.save()
|
||||
|
||||
# Assign/clear this IPAddress as the OOB for the associated Device
|
||||
if type(interface) is Interface:
|
||||
parent = interface.parent_object
|
||||
parent.snapshot()
|
||||
if self.cleaned_data['oob_for_parent']:
|
||||
parent.oob_ip = ipaddress
|
||||
parent.save()
|
||||
elif parent.oob_ip == ipaddress:
|
||||
parent.oob_ip = None
|
||||
parent.save()
|
||||
|
||||
return ipaddress
|
||||
|
||||
|
||||
|
@ -97,16 +97,32 @@ class VLANGroup(OrganizationalModel):
|
||||
raise ValidationError(_("Cannot set scope_id without scope_type."))
|
||||
|
||||
# Validate VID ranges
|
||||
if self.vid_ranges and check_ranges_overlap(self.vid_ranges):
|
||||
raise ValidationError({'vid_ranges': _("Ranges cannot overlap.")})
|
||||
for vid_range in self.vid_ranges:
|
||||
if vid_range.lower > vid_range.upper:
|
||||
lower_vid = vid_range.lower if vid_range.lower_inc else vid_range.lower + 1
|
||||
upper_vid = vid_range.upper if vid_range.upper_inc else vid_range.upper - 1
|
||||
if lower_vid < VLAN_VID_MIN:
|
||||
raise ValidationError({
|
||||
'vid_ranges': _("Starting VLAN ID in range ({value}) cannot be less than {minimum}").format(
|
||||
value=lower_vid, minimum=VLAN_VID_MIN
|
||||
)
|
||||
})
|
||||
if upper_vid > VLAN_VID_MAX:
|
||||
raise ValidationError({
|
||||
'vid_ranges': _("Ending VLAN ID in range ({value}) cannot exceed {maximum}").format(
|
||||
value=upper_vid, maximum=VLAN_VID_MAX
|
||||
)
|
||||
})
|
||||
if lower_vid > upper_vid:
|
||||
raise ValidationError({
|
||||
'vid_ranges': _(
|
||||
"Maximum child VID must be greater than or equal to minimum child VID ({value})"
|
||||
).format(value=vid_range)
|
||||
"Ending VLAN ID in range must be greater than or equal to the starting VLAN ID ({range})"
|
||||
).format(range=f'{lower_vid}-{upper_vid}')
|
||||
})
|
||||
|
||||
# Check for overlapping VID ranges
|
||||
if self.vid_ranges and check_ranges_overlap(self.vid_ranges):
|
||||
raise ValidationError({'vid_ranges': _("Ranges cannot overlap.")})
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
self._total_vlan_ids = 0
|
||||
for vid_range in self.vid_ranges:
|
||||
|
@ -38,12 +38,14 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination):
|
||||
|
||||
def get_limit(self, request):
|
||||
if self.limit_query_param:
|
||||
MAX_PAGE_SIZE = get_config().MAX_PAGE_SIZE
|
||||
if MAX_PAGE_SIZE:
|
||||
MAX_PAGE_SIZE = max(MAX_PAGE_SIZE, self.default_limit)
|
||||
try:
|
||||
limit = int(request.query_params[self.limit_query_param])
|
||||
if limit < 0:
|
||||
raise ValueError()
|
||||
# Enforce maximum page size, if defined
|
||||
MAX_PAGE_SIZE = get_config().MAX_PAGE_SIZE
|
||||
if MAX_PAGE_SIZE:
|
||||
return MAX_PAGE_SIZE if limit == 0 else min(limit, MAX_PAGE_SIZE)
|
||||
return limit
|
||||
|
@ -76,6 +76,12 @@ class ValidatedModelSerializer(BaseModelSerializer):
|
||||
Extends the built-in ModelSerializer to enforce calling full_clean() on a copy of the associated instance during
|
||||
validation. (DRF does not do this by default; see https://github.com/encode/django-rest-framework/issues/3144)
|
||||
"""
|
||||
|
||||
# Bypass DRF's built-in validation of unique constraints due to DRF bug #9410. Rely instead
|
||||
# on our own custom model validation (below).
|
||||
def get_unique_together_constraints(self, model):
|
||||
return []
|
||||
|
||||
def validate(self, data):
|
||||
|
||||
# Skip validation if we're being used to represent a nested object
|
||||
|
@ -1,9 +1,11 @@
|
||||
from contextlib import contextmanager
|
||||
|
||||
from netbox.context import current_request, events_queue
|
||||
from netbox.utils import register_request_processor
|
||||
from extras.events import flush_events
|
||||
|
||||
|
||||
@register_request_processor
|
||||
@contextmanager
|
||||
def event_tracking(request):
|
||||
"""
|
||||
|
@ -14,7 +14,6 @@ class NetBoxGraphQLView(GraphQLView):
|
||||
"""
|
||||
Extends strawberry's GraphQLView to support DRF's token-based authentication.
|
||||
"""
|
||||
graphiql_template = 'graphiql.html'
|
||||
|
||||
@csrf_exempt
|
||||
def dispatch(self, request, *args, **kwargs):
|
||||
|
@ -72,6 +72,7 @@ class JobRunner(ABC):
|
||||
kwargs["job_timeout"] = job.object.python_class.job_timeout
|
||||
cls.enqueue(
|
||||
instance=job.object,
|
||||
name=job.name,
|
||||
user=job.user,
|
||||
schedule_at=new_scheduled_time,
|
||||
interval=job.interval,
|
||||
|
@ -1,3 +1,5 @@
|
||||
from contextlib import ExitStack
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
|
||||
@ -10,7 +12,7 @@ from django.db.utils import InternalError
|
||||
from django.http import Http404, HttpResponseRedirect
|
||||
|
||||
from netbox.config import clear_config, get_config
|
||||
from netbox.context_managers import event_tracking
|
||||
from netbox.registry import registry
|
||||
from netbox.views import handler_500
|
||||
from utilities.api import is_api_request
|
||||
from utilities.error_handlers import handle_rest_api_exception
|
||||
@ -32,8 +34,10 @@ class CoreMiddleware:
|
||||
# Assign a random unique ID to the request. This will be used for change logging.
|
||||
request.id = uuid.uuid4()
|
||||
|
||||
# Enable the event_tracking context manager and process the request.
|
||||
with event_tracking(request):
|
||||
# Apply all registered request processors
|
||||
with ExitStack() as stack:
|
||||
for request_processor in registry['request_processors']:
|
||||
stack.enter_context(request_processor(request))
|
||||
response = self.get_response(request)
|
||||
|
||||
# Check if language cookie should be renewed
|
||||
|
@ -29,6 +29,7 @@ registry = Registry({
|
||||
'model_features': dict(),
|
||||
'models': collections.defaultdict(set),
|
||||
'plugins': dict(),
|
||||
'request_processors': list(),
|
||||
'search': dict(),
|
||||
'tables': collections.defaultdict(dict),
|
||||
'views': collections.defaultdict(dict),
|
||||
|
@ -1,7 +1,7 @@
|
||||
import urllib.parse
|
||||
|
||||
from django.urls import reverse
|
||||
from django.test import override_settings
|
||||
from django.test import Client, override_settings
|
||||
|
||||
from dcim.models import Site
|
||||
from netbox.constants import EMPTY_TABLE_TEXT
|
||||
@ -74,3 +74,21 @@ class SearchViewTestCase(TestCase):
|
||||
self.assertHttpStatus(response, 200)
|
||||
content = str(response.content)
|
||||
self.assertIn(EMPTY_TABLE_TEXT, content)
|
||||
|
||||
|
||||
class MediaViewTestCase(TestCase):
|
||||
|
||||
def test_media_login_required(self):
|
||||
url = reverse('media', kwargs={'path': 'foo.txt'})
|
||||
response = Client().get(url)
|
||||
|
||||
# Unauthenticated request should redirect to login page
|
||||
self.assertHttpStatus(response, 302)
|
||||
|
||||
@override_settings(LOGIN_REQUIRED=False)
|
||||
def test_media_login_not_required(self):
|
||||
url = reverse('media', kwargs={'path': 'foo.txt'})
|
||||
response = Client().get(url)
|
||||
|
||||
# Unauthenticated request should return a 404 (not found)
|
||||
self.assertHttpStatus(response, 404)
|
||||
|
@ -2,7 +2,6 @@ from django.conf import settings
|
||||
from django.conf.urls import include
|
||||
from django.urls import path
|
||||
from django.views.decorators.cache import cache_page
|
||||
from django.views.static import serve
|
||||
from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView
|
||||
|
||||
from account.views import LoginView, LogoutView
|
||||
@ -10,7 +9,7 @@ from netbox.api.views import APIRootView, StatusView
|
||||
from netbox.graphql.schema import schema
|
||||
from netbox.graphql.views import NetBoxGraphQLView
|
||||
from netbox.plugins.urls import plugin_patterns, plugin_api_patterns
|
||||
from netbox.views import HomeView, StaticMediaFailureView, SearchView, htmx
|
||||
from netbox.views import HomeView, MediaView, StaticMediaFailureView, SearchView, htmx
|
||||
|
||||
_patterns = [
|
||||
|
||||
@ -69,7 +68,7 @@ _patterns = [
|
||||
path('graphql/', NetBoxGraphQLView.as_view(schema=schema), name='graphql'),
|
||||
|
||||
# Serving static media in Django to pipe it through LoginRequiredMiddleware
|
||||
path('media/<path:path>', serve, {'document_root': settings.MEDIA_ROOT}),
|
||||
path('media/<path:path>', MediaView.as_view(), name='media'),
|
||||
path('media-failure/', StaticMediaFailureView.as_view(), name='media_failure'),
|
||||
|
||||
# Plugins
|
||||
|
@ -3,6 +3,7 @@ from netbox.registry import registry
|
||||
__all__ = (
|
||||
'get_data_backend_choices',
|
||||
'register_data_backend',
|
||||
'register_request_processor',
|
||||
)
|
||||
|
||||
|
||||
@ -24,3 +25,12 @@ def register_data_backend():
|
||||
return cls
|
||||
|
||||
return _wrapper
|
||||
|
||||
|
||||
def register_request_processor(func):
|
||||
"""
|
||||
Decorator for registering a request processor.
|
||||
"""
|
||||
registry['request_processors'].append(func)
|
||||
|
||||
return func
|
||||
|
@ -541,6 +541,17 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
def get_required_permission(self):
|
||||
return get_permission_for_model(self.queryset.model, 'change')
|
||||
|
||||
def post_save_operations(self, form, obj):
|
||||
"""
|
||||
This method is called for each object in _update_objects. Override to perform additional object-level
|
||||
operations that are specific to a particular ModelForm.
|
||||
"""
|
||||
# Add/remove tags
|
||||
if form.cleaned_data.get('add_tags', None):
|
||||
obj.tags.add(*form.cleaned_data['add_tags'])
|
||||
if form.cleaned_data.get('remove_tags', None):
|
||||
obj.tags.remove(*form.cleaned_data['remove_tags'])
|
||||
|
||||
def _update_objects(self, form, request):
|
||||
custom_fields = getattr(form, 'custom_fields', {})
|
||||
standard_fields = [
|
||||
@ -612,11 +623,7 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
elif form.cleaned_data[name]:
|
||||
getattr(obj, name).set(form.cleaned_data[name])
|
||||
|
||||
# Add/remove tags
|
||||
if form.cleaned_data.get('add_tags', None):
|
||||
obj.tags.add(*form.cleaned_data['add_tags'])
|
||||
if form.cleaned_data.get('remove_tags', None):
|
||||
obj.tags.remove(*form.cleaned_data['remove_tags'])
|
||||
self.post_save_operations(form, obj)
|
||||
|
||||
# Rebuild the tree for MPTT models
|
||||
if issubclass(self.queryset.model, MPTTModel):
|
||||
@ -691,7 +698,7 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
logger.debug("Form validation failed")
|
||||
|
||||
else:
|
||||
form = self.form(request.POST, initial=initial_data)
|
||||
form = self.form(initial=initial_data)
|
||||
restrict_form_fields(form, request.user)
|
||||
|
||||
# Retrieve objects being edited
|
||||
@ -737,7 +744,6 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
renamed_pks = []
|
||||
|
||||
for obj in selected_objects:
|
||||
|
||||
# Take a snapshot of change-logged models
|
||||
if hasattr(obj, 'snapshot'):
|
||||
obj.snapshot()
|
||||
@ -751,7 +757,7 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
except re.error:
|
||||
obj.new_name = obj.name
|
||||
else:
|
||||
obj.new_name = obj.name.replace(find, replace)
|
||||
obj.new_name = (obj.name or '').replace(find, replace)
|
||||
renamed_pks.append(obj.pk)
|
||||
|
||||
return renamed_pks
|
||||
@ -786,6 +792,10 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
)
|
||||
return redirect(self.get_return_url(request))
|
||||
|
||||
except IntegrityError as e:
|
||||
messages.error(self.request, ", ".join(e.args))
|
||||
clear_events.send(sender=self)
|
||||
|
||||
except (AbortRequest, PermissionsViolation) as e:
|
||||
logger.debug(e.message)
|
||||
form.add_error(None, e.message)
|
||||
|
@ -8,6 +8,7 @@ from django.core.cache import cache
|
||||
from django.shortcuts import redirect, render
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.views.generic import View
|
||||
from django.views.static import serve
|
||||
from django_tables2 import RequestConfig
|
||||
from packaging import version
|
||||
|
||||
@ -23,6 +24,7 @@ from utilities.views import ConditionalLoginRequiredMixin
|
||||
|
||||
__all__ = (
|
||||
'HomeView',
|
||||
'MediaView',
|
||||
'SearchView',
|
||||
)
|
||||
|
||||
@ -115,3 +117,11 @@ class SearchView(ConditionalLoginRequiredMixin, View):
|
||||
'form': form,
|
||||
'table': table,
|
||||
})
|
||||
|
||||
|
||||
class MediaView(ConditionalLoginRequiredMixin, View):
|
||||
"""
|
||||
Wrap Django's serve() view to enforce LOGIN_REQUIRED for static media.
|
||||
"""
|
||||
def get(self, request, path):
|
||||
return serve(request, path, document_root=settings.MEDIA_ROOT)
|
||||
|
5568
netbox/project-static/dist/graphiql/graphiql.min.js
vendored
5568
netbox/project-static/dist/graphiql/graphiql.min.js
vendored
File diff suppressed because it is too large
Load Diff
BIN
netbox/project-static/dist/netbox-external.css
vendored
BIN
netbox/project-static/dist/netbox-external.css
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox.css
vendored
BIN
netbox/project-static/dist/netbox.css
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox.js
vendored
BIN
netbox/project-static/dist/netbox.js
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox.js.map
vendored
BIN
netbox/project-static/dist/netbox.js.map
vendored
Binary file not shown.
@ -6,8 +6,8 @@
|
||||
"license": "Apache-2.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@graphiql/plugin-explorer": "3.2.2",
|
||||
"graphiql": "3.7.1",
|
||||
"@graphiql/plugin-explorer": "3.2.3",
|
||||
"graphiql": "3.7.2",
|
||||
"graphql": "16.9.0",
|
||||
"js-cookie": "3.0.5",
|
||||
"react": "18.3.1",
|
||||
|
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "netbox",
|
||||
"version": "4.0.0",
|
||||
"version": "4.1.0",
|
||||
"main": "dist/netbox.js",
|
||||
"license": "Apache-2.0",
|
||||
"private": true,
|
||||
@ -27,11 +27,11 @@
|
||||
"bootstrap": "5.3.3",
|
||||
"clipboard": "2.0.11",
|
||||
"flatpickr": "4.6.13",
|
||||
"gridstack": "10.3.1",
|
||||
"gridstack": "11.1.2",
|
||||
"htmx.org": "1.9.12",
|
||||
"query-string": "9.1.1",
|
||||
"sass": "1.80.4",
|
||||
"tom-select": "2.3.1",
|
||||
"sass": "1.82.0",
|
||||
"tom-select": "2.4.1",
|
||||
"typeface-inter": "3.18.1",
|
||||
"typeface-roboto-mono": "1.1.13"
|
||||
},
|
||||
|
@ -1,18 +1,17 @@
|
||||
import { RecursivePartial, TomInput, TomOption, TomSettings } from 'tom-select/dist/types/types';
|
||||
import { addClasses } from 'tom-select/src/vanilla'
|
||||
import { RecursivePartial, TomOption, TomSettings } from 'tom-select/dist/types/types';
|
||||
import { TomInput } from 'tom-select/dist/cjs/types/core';
|
||||
import { addClasses } from 'tom-select/src/vanilla.ts';
|
||||
import queryString from 'query-string';
|
||||
import TomSelect from 'tom-select';
|
||||
import type { Stringifiable } from 'query-string';
|
||||
import { DynamicParamsMap } from './dynamicParamsMap';
|
||||
|
||||
// Transitional
|
||||
import { QueryFilter, PathFilter } from '../types'
|
||||
import { QueryFilter, PathFilter } from '../types';
|
||||
import { getElement, replaceAll } from '../../util';
|
||||
|
||||
|
||||
// Extends TomSelect to provide enhanced fetching of options via the REST API
|
||||
export class DynamicTomSelect extends TomSelect {
|
||||
|
||||
public readonly nullOption: Nullable<TomOption> = null;
|
||||
|
||||
// Transitional code from APISelect
|
||||
@ -25,7 +24,7 @@ export class DynamicTomSelect extends TomSelect {
|
||||
* Overrides
|
||||
*/
|
||||
|
||||
constructor( input_arg: string|TomInput, user_settings: RecursivePartial<TomSettings> ) {
|
||||
constructor(input_arg: string | TomInput, user_settings: RecursivePartial<TomSettings>) {
|
||||
super(input_arg, user_settings);
|
||||
|
||||
// Glean the REST API endpoint URL from the <select> element
|
||||
@ -34,7 +33,8 @@ export class DynamicTomSelect extends TomSelect {
|
||||
// Override any field names set as widget attributes
|
||||
this.valueField = this.input.getAttribute('ts-value-field') || this.settings.valueField;
|
||||
this.labelField = this.input.getAttribute('ts-label-field') || this.settings.labelField;
|
||||
this.disabledField = this.input.getAttribute('ts-disabled-field') || this.settings.disabledField;
|
||||
this.disabledField =
|
||||
this.input.getAttribute('ts-disabled-field') || this.settings.disabledField;
|
||||
this.descriptionField = this.input.getAttribute('ts-description-field') || 'description';
|
||||
this.depthField = this.input.getAttribute('ts-depth-field') || '_depth';
|
||||
this.parentField = this.input.getAttribute('ts-parent-field') || null;
|
||||
@ -43,9 +43,9 @@ export class DynamicTomSelect extends TomSelect {
|
||||
// Set the null option (if any)
|
||||
const nullOption = this.input.getAttribute('data-null-option');
|
||||
if (nullOption) {
|
||||
let valueField = this.settings.valueField;
|
||||
let labelField = this.settings.labelField;
|
||||
this.nullOption = {}
|
||||
const valueField = this.settings.valueField;
|
||||
const labelField = this.settings.labelField;
|
||||
this.nullOption = {};
|
||||
this.nullOption[valueField] = 'null';
|
||||
this.nullOption[labelField] = nullOption;
|
||||
}
|
||||
@ -98,8 +98,8 @@ export class DynamicTomSelect extends TomSelect {
|
||||
.then(response => response.json())
|
||||
.then(apiData => {
|
||||
const results: Dict[] = apiData.results;
|
||||
let options: Dict[] = []
|
||||
for (let result of results) {
|
||||
const options: Dict[] = [];
|
||||
for (const result of results) {
|
||||
const option = self.getOptionFromData(result);
|
||||
options.push(option);
|
||||
}
|
||||
@ -108,10 +108,10 @@ export class DynamicTomSelect extends TomSelect {
|
||||
// Pass the options to the callback function
|
||||
.then(options => {
|
||||
self.loadCallback(options, []);
|
||||
}).catch(()=>{
|
||||
})
|
||||
.catch(() => {
|
||||
self.loadCallback([], []);
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
@ -155,14 +155,14 @@ export class DynamicTomSelect extends TomSelect {
|
||||
|
||||
// Compile TomOption data from an API result
|
||||
getOptionFromData(data: Dict) {
|
||||
let option: Dict = {
|
||||
const option: Dict = {
|
||||
id: data[this.valueField],
|
||||
display: data[this.labelField],
|
||||
depth: data[this.depthField] || null,
|
||||
description: data[this.descriptionField] || null,
|
||||
};
|
||||
if (data[this.parentField]) {
|
||||
let parent: Dict = data[this.parentField] as Dict;
|
||||
const parent: Dict = data[this.parentField] as Dict;
|
||||
option['parent'] = parent[this.labelField];
|
||||
}
|
||||
if (data[this.countField]) {
|
||||
@ -171,7 +171,7 @@ export class DynamicTomSelect extends TomSelect {
|
||||
if (data[this.disabledField]) {
|
||||
option['disabled'] = data[this.disabledField];
|
||||
}
|
||||
return option
|
||||
return option;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -218,7 +218,6 @@ export class DynamicTomSelect extends TomSelect {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// Parse the `data-url` attribute to add any variables to `pathValues` as keys with empty
|
||||
// values. As those keys' corresponding form fields' values change, `pathValues` will be
|
||||
// updated to reflect the new value.
|
||||
@ -297,7 +296,8 @@ export class DynamicTomSelect extends TomSelect {
|
||||
// value. For example, if the dependency is the `rack` field, and the `rack` field's value
|
||||
// is `1`, this element's URL would change from `/dcim/racks/{{rack}}/` to `/dcim/racks/1/`.
|
||||
const hasReplacement =
|
||||
this.api_url.includes(`{{`) && Boolean(this.api_url.match(new RegExp(`({{(${id})}})`, 'g')));
|
||||
this.api_url.includes(`{{`) &&
|
||||
Boolean(this.api_url.match(new RegExp(`({{(${id})}})`, 'g')));
|
||||
|
||||
if (hasReplacement) {
|
||||
if (element.value) {
|
||||
@ -349,5 +349,4 @@ export class DynamicTomSelect extends TomSelect {
|
||||
// Load new data.
|
||||
this.load(this.lastValue);
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -17,7 +17,7 @@ tr[data-virtual=true] {
|
||||
background-color: rgba(map.get($theme-colors, "primary"), 0.15);
|
||||
}
|
||||
tr[data-enabled=disabled] {
|
||||
background-color: rgba(map.get($theme-colors, "danger"), 0.15);
|
||||
background-color: rgba($gray-400, 0.15);
|
||||
}
|
||||
|
||||
// Only show the correct button depending on the cable status
|
||||
|
@ -1,10 +1,10 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
// Needed for tom-select/src/vanilla.ts
|
||||
"allowImportingTsExtensions": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"moduleResolution": "node",
|
||||
// tom-select v2.3.1 raises several TS6133 errors with noUnusedParameters
|
||||
"noUnusedParameters": false,
|
||||
"esModuleInterop": true,
|
||||
"isolatedModules": true,
|
||||
"noUnusedLocals": true,
|
||||
|
@ -200,17 +200,17 @@
|
||||
resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.2.tgz#d8bae93ac8b815b2bd7a98078cf91e2724ef11e5"
|
||||
integrity sha512-J4yDIIthosAsRZ5CPYP/jQvUAQtlZTTD/4suA08/FEnlxqW3sKS9iAhgsa9VYLZ6vDHn/ixJgIqRQPotoBjxIw==
|
||||
|
||||
"@graphiql/plugin-explorer@3.2.2":
|
||||
version "3.2.2"
|
||||
resolved "https://registry.yarnpkg.com/@graphiql/plugin-explorer/-/plugin-explorer-3.2.2.tgz#973d6015b6db15041902e95c3e4b746473313eb6"
|
||||
integrity sha512-zeBZJUAX9h+3nXw3GLHZoxi6wwYqDBU2L/xeSXSTagJhcLNW1Hwb/t/wb296hQ1x/9nyGySsTA0DQiiWV3rCBQ==
|
||||
"@graphiql/plugin-explorer@3.2.3":
|
||||
version "3.2.3"
|
||||
resolved "https://registry.yarnpkg.com/@graphiql/plugin-explorer/-/plugin-explorer-3.2.3.tgz#03854d7e62d6e24c6552ae6706e3945b9324fa23"
|
||||
integrity sha512-yh5WXRqDPuKjVyNxUwXYjx8tImvVOx+2FGanLyjoAJP2LKQu6eDtButyJ8sExk1qW4+HCSrXxJNSPs4W7cYT3g==
|
||||
dependencies:
|
||||
graphiql-explorer "^0.9.0"
|
||||
|
||||
"@graphiql/react@^0.26.2":
|
||||
version "0.26.2"
|
||||
resolved "https://registry.yarnpkg.com/@graphiql/react/-/react-0.26.2.tgz#3a1a01a569b624de8141c53eed24a7db9a523668"
|
||||
integrity sha512-aO4GWf/kJmqrjO+PORT/NPxwGvPGlg+mwye1v8xAlf8Q9j7P0hVtVBawYaSLUCCfJ/QnH7JAP+0VRamyooZZCw==
|
||||
"@graphiql/react@^0.27.0":
|
||||
version "0.27.0"
|
||||
resolved "https://registry.yarnpkg.com/@graphiql/react/-/react-0.27.0.tgz#4475a0f4ddf25d8ebc1bfc538fb21f5f1d435916"
|
||||
integrity sha512-K9ZKWd+ewodbS/1kewedmITeeKLUQswMOXwIv8XFLPt3Ondodji0vr1XXXsttlyl+V2QG/9tYVV2RJ9Ch5LdrA==
|
||||
dependencies:
|
||||
"@graphiql/toolkit" "^0.11.0"
|
||||
"@headlessui/react" "^1.7.15"
|
||||
@ -353,17 +353,17 @@
|
||||
resolved "https://registry.yarnpkg.com/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz#3dc35ba0f1e66b403c00b39344f870298ebb1c8e"
|
||||
integrity sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==
|
||||
|
||||
"@orchidjs/sifter@^1.0.3":
|
||||
version "1.0.3"
|
||||
resolved "https://registry.yarnpkg.com/@orchidjs/sifter/-/sifter-1.0.3.tgz#43f42519472282eb632d0a1589184f044d64129b"
|
||||
integrity sha512-zCZbwKegHytfsPm8Amcfh7v/4vHqTAaOu6xFswBYcn8nznBOuseu6COB2ON7ez0tFV0mKL0nRNnCiZZA+lU9/g==
|
||||
"@orchidjs/sifter@^1.1.0":
|
||||
version "1.1.0"
|
||||
resolved "https://registry.yarnpkg.com/@orchidjs/sifter/-/sifter-1.1.0.tgz#b36154ad0cda4898305d1ac44f318b41048a0438"
|
||||
integrity sha512-mYwHCfr736cIWWdhhSZvDbf90AKt2xyrJspKFC3qyIJG1LtrJeJunYEqCGG4Aq2ijENbc4WkOjszcvNaIAS/pQ==
|
||||
dependencies:
|
||||
"@orchidjs/unicode-variants" "^1.0.4"
|
||||
"@orchidjs/unicode-variants" "^1.1.2"
|
||||
|
||||
"@orchidjs/unicode-variants@^1.0.4":
|
||||
version "1.0.4"
|
||||
resolved "https://registry.yarnpkg.com/@orchidjs/unicode-variants/-/unicode-variants-1.0.4.tgz#6d2f812e3b19545bba2d81caffff1204de9a6a58"
|
||||
integrity sha512-NvVBRnZNE+dugiXERFsET1JlKZfM5lJDEpSMilKW4bToYJ7pxf0Zne78xyXB2ny2c2aHfJ6WLnz1AaTNHAmQeQ==
|
||||
"@orchidjs/unicode-variants@^1.1.2":
|
||||
version "1.1.2"
|
||||
resolved "https://registry.yarnpkg.com/@orchidjs/unicode-variants/-/unicode-variants-1.1.2.tgz#1fd71791a67fdd1591ebe0dcaadd3964537a824e"
|
||||
integrity sha512-5DobW1CHgnBROOEpFlEXytED5OosEWESFvg/VYmH0143oXcijYTprRYJTs+55HzGM4IqxiLFSuqEzu9mPNwVsA==
|
||||
|
||||
"@parcel/watcher-android-arm64@2.4.1":
|
||||
version "2.4.1"
|
||||
@ -1883,12 +1883,12 @@ graphiql-explorer@^0.9.0:
|
||||
resolved "https://registry.yarnpkg.com/graphiql-explorer/-/graphiql-explorer-0.9.0.tgz#25f6b990bfc3e04e88c0cf419e28d12abe2c4fbe"
|
||||
integrity sha512-fZC/wsuatqiQDO2otchxriFO0LaWIo/ovF/CQJ1yOudmY0P7pzDiP+l9CEHUiWbizk3e99x6DQG4XG1VxA+d6A==
|
||||
|
||||
graphiql@3.7.1:
|
||||
version "3.7.1"
|
||||
resolved "https://registry.yarnpkg.com/graphiql/-/graphiql-3.7.1.tgz#9fb727e15db443b22823389d13dc5d98c3ce0ff9"
|
||||
integrity sha512-kmummedOrFYs0BI5evrVY0AerOYlaMt/Sc/e+Sta1x8X6vEMYWNeUUz/kKF2NQT5BcsR3FnNdFt1Gk2QMgueGQ==
|
||||
graphiql@3.7.2:
|
||||
version "3.7.2"
|
||||
resolved "https://registry.yarnpkg.com/graphiql/-/graphiql-3.7.2.tgz#6a754256f4f2e6268a64e585b0fe35bf38f1b87d"
|
||||
integrity sha512-DL+KrX+aQdyzl+KwcqjlmdYdjyKegm7FcZJKkIQ1e56xn6Eoe8lw5F4t65gFex/45fHzv8e8CpaIcljxfJhO7A==
|
||||
dependencies:
|
||||
"@graphiql/react" "^0.26.2"
|
||||
"@graphiql/react" "^0.27.0"
|
||||
|
||||
graphql-language-service@5.3.0, graphql-language-service@^5.3.0:
|
||||
version "5.3.0"
|
||||
@ -1904,10 +1904,10 @@ graphql@16.9.0:
|
||||
resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.9.0.tgz#1c310e63f16a49ce1fbb230bd0a000e99f6f115f"
|
||||
integrity sha512-GGTKBX4SD7Wdb8mqeDLni2oaRGYQWjWHGKPQ24ZMnUtKfcsVoiv4uX8+LJr1K6U5VW2Lu1BwJnj7uiori0YtRw==
|
||||
|
||||
gridstack@10.3.1:
|
||||
version "10.3.1"
|
||||
resolved "https://registry.yarnpkg.com/gridstack/-/gridstack-10.3.1.tgz#4ed704279c40094fc1b9e3318f20b573f2fe9f40"
|
||||
integrity sha512-Ra82k/88gdeiu3ZP40COS4bI4sGhNQlZAaAQ6szfPfr68zVpsXxiyLKr5zYcTpKX4jjcwyNsNNdcV1tDJc71fA==
|
||||
gridstack@11.1.2:
|
||||
version "11.1.2"
|
||||
resolved "https://registry.yarnpkg.com/gridstack/-/gridstack-11.1.2.tgz#e72091e2883f7b37cbd150c218d38eebc9fc4f18"
|
||||
integrity sha512-6wJ5RffnFchF63/Yhs6tcZcWxRG1EgCnxgejbQsAjQ6Qj8QqKjew73jPq5c1yCAiyEAsXxI2tOJ8lZABOAZxoQ==
|
||||
|
||||
has-bigints@^1.0.1, has-bigints@^1.0.2:
|
||||
version "1.0.2"
|
||||
@ -1970,6 +1970,11 @@ immutable@^4.0.0:
|
||||
resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.3.7.tgz#c70145fc90d89fb02021e65c84eb0226e4e5a381"
|
||||
integrity sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==
|
||||
|
||||
immutable@^5.0.2:
|
||||
version "5.0.3"
|
||||
resolved "https://registry.yarnpkg.com/immutable/-/immutable-5.0.3.tgz#aa037e2313ea7b5d400cd9298fa14e404c933db1"
|
||||
integrity sha512-P8IdPQHq3lA1xVeBRi5VPqUm5HDgKnx0Ru51wZz5mjxHr5n3RWhjIpOFU7ybkUxfB+5IToy+OLaHYDBIWsv+uw==
|
||||
|
||||
import-fresh@^3.2.1:
|
||||
version "3.3.0"
|
||||
resolved "https://registry.yarnpkg.com/import-fresh/-/import-fresh-3.3.0.tgz#37162c25fcb9ebaa2e6e53d5b4d88ce17d9e0c2b"
|
||||
@ -2656,15 +2661,16 @@ safe-regex-test@^1.0.3:
|
||||
es-errors "^1.3.0"
|
||||
is-regex "^1.1.4"
|
||||
|
||||
sass@1.80.4:
|
||||
version "1.80.4"
|
||||
resolved "https://registry.yarnpkg.com/sass/-/sass-1.80.4.tgz#bc0418fd796cad2f1a1309d8b4d7fe44b7027de0"
|
||||
integrity sha512-rhMQ2tSF5CsuuspvC94nPM9rToiAFw2h3JTrLlgmNw1MH79v8Cr3DH6KF6o6r+8oofY3iYVPUf66KzC8yuVN1w==
|
||||
sass@1.82.0:
|
||||
version "1.82.0"
|
||||
resolved "https://registry.yarnpkg.com/sass/-/sass-1.82.0.tgz#30da277af3d0fa6042e9ceabd0d984ed6d07df70"
|
||||
integrity sha512-j4GMCTa8elGyN9A7x7bEglx0VgSpNUG4W4wNedQ33wSMdnkqQCT8HTwOaVSV4e6yQovcu/3Oc4coJP/l0xhL2Q==
|
||||
dependencies:
|
||||
"@parcel/watcher" "^2.4.1"
|
||||
chokidar "^4.0.0"
|
||||
immutable "^4.0.0"
|
||||
immutable "^5.0.2"
|
||||
source-map-js ">=0.6.2 <2.0.0"
|
||||
optionalDependencies:
|
||||
"@parcel/watcher" "^2.4.1"
|
||||
|
||||
sass@^1.71.1:
|
||||
version "1.77.8"
|
||||
@ -2864,13 +2870,13 @@ 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.3.1:
|
||||
version "2.3.1"
|
||||
resolved "https://registry.yarnpkg.com/tom-select/-/tom-select-2.3.1.tgz#df338d9082874cd0bceb3bee87ed0184447c47f1"
|
||||
integrity sha512-QS4vnOcB6StNGqX4sGboGXL2fkhBF2gIBB+8Hwv30FZXYPn0CyYO8kkdATRvwfCTThxiR4WcXwKJZ3cOmtI9eg==
|
||||
tom-select@2.4.1:
|
||||
version "2.4.1"
|
||||
resolved "https://registry.yarnpkg.com/tom-select/-/tom-select-2.4.1.tgz#6a0b6df8af3df7b09b22dd965eb75ce4d1c547bc"
|
||||
integrity sha512-adI8H8+wk8RRzHYLQ3bXSk2Q+FAq/kzAATrcWlJ2fbIrEzb0VkwaXzKHTAlBwSJrhqbPJvhV/0eypFkED/nAug==
|
||||
dependencies:
|
||||
"@orchidjs/sifter" "^1.0.3"
|
||||
"@orchidjs/unicode-variants" "^1.0.4"
|
||||
"@orchidjs/sifter" "^1.1.0"
|
||||
"@orchidjs/unicode-variants" "^1.1.2"
|
||||
|
||||
ts-api-utils@^1.3.0:
|
||||
version "1.3.0"
|
||||
|
@ -1,3 +1,3 @@
|
||||
version: "4.1.5"
|
||||
version: "4.1.10"
|
||||
edition: "Community"
|
||||
published: "2024-10-28"
|
||||
published: "2024-12-23"
|
||||
|
@ -19,7 +19,7 @@ Blocks:
|
||||
<div class="page">
|
||||
|
||||
{# Sidebar #}
|
||||
<aside class="navbar navbar-vertical navbar-expand-lg">
|
||||
<aside class="navbar navbar-vertical navbar-expand-lg d-print-none">
|
||||
|
||||
{% if 'commercial' in settings.RELEASE.features %}
|
||||
<img class="motif" src="{% static 'motif.svg' %}" alt="{% trans "NetBox Motif" %}">
|
||||
|
@ -87,7 +87,7 @@
|
||||
{% for name, field in backend.parameters.items %}
|
||||
<tr>
|
||||
<th scope="row">{{ field.label }}</th>
|
||||
{% if name in backend.sensitive_parameters and not perms.core.change_datasource %}
|
||||
{% if name in backend.sensitive_parameters %}
|
||||
<td>********</td>
|
||||
{% else %}
|
||||
<td>{{ object.parameters|get_key:name|placeholder }}</td>
|
||||
|
@ -5,7 +5,7 @@
|
||||
{% block title %}{{ object }} - {% trans "Config" %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-3">
|
||||
<div class="row">
|
||||
<div class="col-5">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Config Template" %}</h2>
|
||||
@ -48,19 +48,28 @@
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<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>
|
||||
{% if config_template %}
|
||||
<pre class="card-body">{{ rendered_config }}</pre>
|
||||
{% 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="card-body text-muted">{% trans "No configuration template found" %}</div>
|
||||
<div class="alert alert-warning">
|
||||
<h4 class="alert-title mb-1">{% trans "Error rendering template" %}</h4>
|
||||
{% trans error_message %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-info">
|
||||
{% trans "No configuration template has been assigned for this device." %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
@ -1,15 +1,8 @@
|
||||
{% load static %}
|
||||
{% comment %}
|
||||
This template derives from the strawberry-graphql project:
|
||||
https://github.com/strawberry-graphql/strawberry/blob/main/strawberry/static/graphiql.html
|
||||
{% endcomment %}
|
||||
<!--
|
||||
The request to this GraphQL server provided the header "Accept: text/html"
|
||||
and as a result has been presented GraphiQL - an in-browser IDE for
|
||||
exploring GraphQL.
|
||||
If you wish to receive JSON, provide the header "Accept: application/json" or
|
||||
add "&raw" to the end of the URL within a browser.
|
||||
-->
|
||||
{% load static %}
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
@ -112,10 +105,7 @@ add "&raw" to the end of the URL within a browser.
|
||||
headers["x-csrftoken"] = csrfToken;
|
||||
}
|
||||
|
||||
const subscriptionsEnabled = JSON.parse("{{ SUBSCRIPTION_ENABLED }}");
|
||||
const subscriptionUrl = subscriptionsEnabled
|
||||
? httpUrlToWebSockeUrl(fetchURL)
|
||||
: null;
|
||||
const subscriptionUrl = httpUrlToWebSockeUrl(fetchURL);
|
||||
|
||||
const fetcher = GraphiQL.createFetcher({
|
||||
url: fetchURL,
|
@ -34,7 +34,7 @@
|
||||
<div class="col col-md-12">
|
||||
<h3>{% trans "Search Results" %}</h3>
|
||||
<div class="table-responsive">
|
||||
{% render_table table 'inc/table.html' %}
|
||||
{% render_table table 'inc/table_htmx.html' %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -5,7 +5,7 @@
|
||||
{% block title %}{{ object }} - {% trans "Config" %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-3">
|
||||
<div class="row">
|
||||
<div class="col-5">
|
||||
<div class="card">
|
||||
<h2 class="card-header">{% trans "Config Template" %}</h2>
|
||||
@ -48,19 +48,28 @@
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<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>
|
||||
{% if config_template %}
|
||||
<pre class="card-body">{{ rendered_config }}</pre>
|
||||
{% 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="card-body text-muted">{% trans "No configuration template found" %}</div>
|
||||
<div class="alert alert-warning">
|
||||
<h4 class="alert-title mb-1">{% trans "Error rendering template" %}</h4>
|
||||
{% trans error_message %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% 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.
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
@ -10,9 +10,9 @@ __all__ = (
|
||||
)
|
||||
|
||||
|
||||
def to_grams(weight, unit):
|
||||
def to_grams(weight, unit) -> int:
|
||||
"""
|
||||
Convert the given weight to kilograms.
|
||||
Convert the given weight to integer grams.
|
||||
"""
|
||||
try:
|
||||
if weight < 0:
|
||||
@ -21,13 +21,13 @@ def to_grams(weight, unit):
|
||||
raise TypeError(_("Invalid value '{weight}' for weight (must be a number)").format(weight=weight))
|
||||
|
||||
if unit == WeightUnitChoices.UNIT_KILOGRAM:
|
||||
return weight * 1000
|
||||
return int(weight * 1000)
|
||||
if unit == WeightUnitChoices.UNIT_GRAM:
|
||||
return weight
|
||||
return int(weight)
|
||||
if unit == WeightUnitChoices.UNIT_POUND:
|
||||
return weight * Decimal(453.592)
|
||||
return int(weight * Decimal(453.592))
|
||||
if unit == WeightUnitChoices.UNIT_OUNCE:
|
||||
return weight * Decimal(28.3495)
|
||||
return int(weight * Decimal(28.3495))
|
||||
raise ValueError(
|
||||
_("Unknown unit {unit}. Must be one of the following: {valid_units}").format(
|
||||
unit=unit,
|
||||
|
@ -28,10 +28,14 @@ class DataFileLoader(BaseLoader):
|
||||
raise TemplateNotFound(template)
|
||||
|
||||
# Find and pre-fetch referenced templates
|
||||
if referenced_templates := find_referenced_templates(environment.parse(template_source)):
|
||||
if referenced_templates := tuple(find_referenced_templates(environment.parse(template_source))):
|
||||
related_files = DataFile.objects.filter(source=self.data_source)
|
||||
# None indicates the use of dynamic resolution. If dependent files are statically
|
||||
# defined, we can filter by path for optimization.
|
||||
if None not in referenced_templates:
|
||||
related_files = related_files.filter(path__in=referenced_templates)
|
||||
self.cache_templates({
|
||||
df.path: df.data_as_string for df in
|
||||
DataFile.objects.filter(source=self.data_source, path__in=referenced_templates)
|
||||
df.path: df.data_as_string for df in related_files
|
||||
})
|
||||
|
||||
return template_source, template, lambda: True
|
||||
|
@ -144,6 +144,19 @@ class APIPaginationTestCase(APITestCase):
|
||||
self.assertIsNone(response.data['previous'])
|
||||
self.assertEqual(len(response.data['results']), page_size)
|
||||
|
||||
@override_settings(MAX_PAGE_SIZE=30)
|
||||
def test_default_page_size_with_small_max_page_size(self):
|
||||
response = self.client.get(self.url, format='json', **self.header)
|
||||
page_size = get_config().MAX_PAGE_SIZE
|
||||
paginate_count = get_config().PAGINATE_COUNT
|
||||
self.assertLess(page_size, 100, "Default page size not sufficient for data set")
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data['count'], 100)
|
||||
self.assertTrue(response.data['next'].endswith(f'?limit={paginate_count}&offset={paginate_count}'))
|
||||
self.assertIsNone(response.data['previous'])
|
||||
self.assertEqual(len(response.data['results']), paginate_count)
|
||||
|
||||
def test_custom_page_size(self):
|
||||
response = self.client.get(f'{self.url}?limit=10', format='json', **self.header)
|
||||
|
||||
@ -153,15 +166,15 @@ class APIPaginationTestCase(APITestCase):
|
||||
self.assertIsNone(response.data['previous'])
|
||||
self.assertEqual(len(response.data['results']), 10)
|
||||
|
||||
@override_settings(MAX_PAGE_SIZE=20)
|
||||
@override_settings(MAX_PAGE_SIZE=80)
|
||||
def test_max_page_size(self):
|
||||
response = self.client.get(f'{self.url}?limit=0', format='json', **self.header)
|
||||
|
||||
self.assertHttpStatus(response, status.HTTP_200_OK)
|
||||
self.assertEqual(response.data['count'], 100)
|
||||
self.assertTrue(response.data['next'].endswith('?limit=20&offset=20'))
|
||||
self.assertTrue(response.data['next'].endswith('?limit=80&offset=80'))
|
||||
self.assertIsNone(response.data['previous'])
|
||||
self.assertEqual(len(response.data['results']), 20)
|
||||
self.assertEqual(len(response.data['results']), 80)
|
||||
|
||||
@override_settings(MAX_PAGE_SIZE=0)
|
||||
def test_max_page_size_disabled(self):
|
||||
|
@ -171,7 +171,7 @@ class VirtualMachineBulkEditForm(NetBoxModelBulkEditForm):
|
||||
)
|
||||
disk = forms.IntegerField(
|
||||
required=False,
|
||||
label=_('Disk (GB)')
|
||||
label=_('Disk (MB)')
|
||||
)
|
||||
description = forms.CharField(
|
||||
label=_('Description'),
|
||||
@ -331,7 +331,7 @@ class VirtualDiskBulkEditForm(NetBoxModelBulkEditForm):
|
||||
)
|
||||
size = forms.IntegerField(
|
||||
required=False,
|
||||
label=_('Size (GB)')
|
||||
label=_('Size (MB)')
|
||||
)
|
||||
description = forms.CharField(
|
||||
label=_('Description'),
|
||||
|
@ -248,7 +248,7 @@ class VirtualDiskFilterForm(NetBoxModelFilterSetForm):
|
||||
label=_('Virtual machine')
|
||||
)
|
||||
size = forms.IntegerField(
|
||||
label=_('Size (GB)'),
|
||||
label=_('Size (MB)'),
|
||||
required=False,
|
||||
min_value=1
|
||||
)
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user