Merge pull request #7220 from netbox-community/develop

Release v3.0.2
This commit is contained in:
Jeremy Stretch 2021-09-08 16:45:05 -04:00 committed by GitHub
commit b55c85b2af
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
56 changed files with 500 additions and 230 deletions

View File

@ -17,7 +17,7 @@ body:
What version of NetBox are you currently running? (If you don't have access to the most
recent NetBox release, consider testing on our [demo instance](https://demo.netbox.dev/)
before opening a bug report to see if your issue has already been addressed.)
placeholder: v3.0.1
placeholder: v3.0.2
validations:
required: true
- type: dropdown

View File

@ -14,7 +14,7 @@ body:
attributes:
label: NetBox version
description: What version of NetBox are you currently running?
placeholder: v3.0.1
placeholder: v3.0.2
validations:
required: true
- type: dropdown
@ -30,8 +30,10 @@ body:
attributes:
label: Proposed functionality
description: >
Describe in detail the new feature or behavior you'd like to propose. Include any specific
changes to work flows, data models, or the user interface.
Describe in detail the new feature or behavior you are proposing. Include any specific changes
to work flows, data models, and/or the user interface. The more detail you provide here, the
greater chance your proposal has of being discussed. Feature requests which don't include an
actionable implementation plan will be rejected.
validations:
required: true
- type: textarea

View File

@ -59,6 +59,9 @@ jobs:
- name: Check UI ESLint, TypeScript, and Prettier Compliance
run: yarn --cwd netbox/project-static validate
- name: Validate Static Asset Integrity
run: scripts/verify-bundles.sh
- name: Run tests
run: coverage run --source="netbox/" netbox/manage.py test netbox/

5
.gitignore vendored
View File

@ -1,16 +1,13 @@
*.pyc
*.swp
node_modules
npm-debug.log*
yarn-debug.log*
yarn-error.log*
/netbox/project-static/.cache
/netbox/project-static/node_modules
/netbox/project-static/docs/*
!/netbox/project-static/docs/.info
/netbox/netbox/configuration.py
/netbox/netbox/ldap_config.py
/netbox/project-static/.cache
/netbox/project-static/node_modules
/netbox/reports/*
!/netbox/reports/__init__.py
/netbox/scripts/*

View File

@ -26,4 +26,4 @@ For the exhaustive list of exposed metrics, visit the `/metrics` endpoint on you
When deploying NetBox in a multiprocess manner (e.g. running multiple Gunicorn workers) the Prometheus client library requires the use of a shared directory to collect metrics from all worker processes. To configure this, first create or designate a local directory to which the worker processes have read and write access, and then configure your WSGI service (e.g. Gunicorn) to define this path as the `prometheus_multiproc_dir` environment variable.
!!! warning
If having accurate long-term metrics in a multiprocess environment is crucial to your deployment, it's recommended you use the `uwsgi` library instead of `gunicorn`. The issue lies in the way `gunicorn` tracks worker processes (vs `uwsgi`) which helps manage the metrics files created by the above configurations. If you're using NetBox with gunicorn in a containerized enviroment following the one-process-per-container methodology, then you will likely not need to change to `uwsgi`. More details can be found in [issue #3779](https://github.com/netbox-community/netbox/issues/3779#issuecomment-590547562).
If having accurate long-term metrics in a multiprocess environment is crucial to your deployment, it's recommended you use the `uwsgi` library instead of `gunicorn`. The issue lies in the way `gunicorn` tracks worker processes (vs `uwsgi`) which helps manage the metrics files created by the above configurations. If you're using NetBox with gunicorn in a containerized environment following the one-process-per-container methodology, then you will likely not need to change to `uwsgi`. More details can be found in [issue #3779](https://github.com/netbox-community/netbox/issues/3779#issuecomment-590547562).

View File

@ -34,11 +34,11 @@ class Foo(models.Model):
## 3. Update relevant querysets
If you're adding a relational field (e.g. `ForeignKey`) and intend to include the data when retreiving a list of objects, be sure to include the field using `prefetch_related()` as appropriate. This will optimize the view and avoid extraneous database queries.
If you're adding a relational field (e.g. `ForeignKey`) and intend to include the data when retrieving a list of objects, be sure to include the field using `prefetch_related()` as appropriate. This will optimize the view and avoid extraneous database queries.
## 4. Update API serializer
Extend the model's API serializer in `<app>.api.serializers` to include the new field. In most cases, it will not be necessary to also extend the nested serializer, which produces a minimal represenation of the model.
Extend the model's API serializer in `<app>.api.serializers` to include the new field. In most cases, it will not be necessary to also extend the nested serializer, which produces a minimal representation of the model.
## 5. Add field to forms

View File

@ -45,7 +45,7 @@ NetBox provides both a singular and plural query field for each object type:
* `$OBJECT`: Returns a single object. Must specify the object's unique ID as `(id: 123)`.
* `$OBJECT_list`: Returns a list of objects, optionally filtered by given parameters.
For example, query `device(id:123)` to fetch a specific device (identified by its unique ID), and query `device_list` (with an optional set of fitlers) to fetch all devices.
For example, query `device(id:123)` to fetch a specific device (identified by its unique ID), and query `device_list` (with an optional set of filters) to fetch all devices.
For more detail on constructing GraphQL queries, see the [Graphene documentation](https://docs.graphene-python.org/en/latest/).

View File

@ -70,19 +70,22 @@ If `git` is not already installed, install it:
Next, clone the **master** branch of the NetBox GitHub repository into the current directory. (This branch always holds the current stable release.)
```no-highlight
sudo git clone -b master https://github.com/netbox-community/netbox.git .
sudo git clone -b master --depth 1 https://github.com/netbox-community/netbox.git .
```
!!! note
The `git clone` command above utilizes a "shallow clone" to retrieve only the most recent commit. If you need to download the entire history, omit the `--depth 1` argument.
The `git clone` command should generate output similar to the following:
```
Cloning into '.'...
remote: Counting objects: 1994, done.
remote: Compressing objects: 100% (150/150), done.
remote: Total 1994 (delta 80), reused 0 (delta 0), pack-reused 1842
Receiving objects: 100% (1994/1994), 472.36 KiB | 0 bytes/s, done.
Resolving deltas: 100% (1495/1495), done.
Checking connectivity... done.
remote: Enumerating objects: 996, done.
remote: Counting objects: 100% (996/996), done.
remote: Compressing objects: 100% (935/935), done.
remote: Total 996 (delta 148), reused 386 (delta 34), pack-reused 0
Receiving objects: 100% (996/996), 4.26 MiB | 9.81 MiB/s, done.
Resolving deltas: 100% (148/148), done.
```
!!! note

View File

@ -14,7 +14,7 @@ While the provided configuration should suffice for most initial installations,
## systemd Setup
We'll use systemd to control both gunicorn and NetBox's background worker process. First, copy `contrib/netbox.service` and `contrib/netbox-rq.service` to the `/etc/systemd/system/` directory and reload the systemd dameon:
We'll use systemd to control both gunicorn and NetBox's background worker process. First, copy `contrib/netbox.service` and `contrib/netbox-rq.service` to the `/etc/systemd/system/` directory and reload the systemd daemon:
```no-highlight
sudo cp -v /opt/netbox/contrib/*.service /etc/systemd/system/

View File

@ -4,6 +4,6 @@ A platform defines the type of software running on a device or virtual machine.
Platforms may optionally be limited by manufacturer: If a platform is assigned to a particular manufacturer, it can only be assigned to devices with a type belonging to that manufacturer.
The platform model is also used to indicate which [NAPALM](../../additional-features/napalm.md) driver and any associated arguments NetBox should use when connecting to a remote device. The name of the driver along with optional parameters are stored with the platform.
The platform model is also used to indicate which NAPALM driver (if any) and any associated arguments NetBox should use when connecting to a remote device. The name of the driver along with optional parameters are stored with the platform.
The assignment of platforms to devices is an optional feature, and may be disregarded if not desired.

View File

@ -1,5 +1,26 @@
# NetBox v3.0
## v3.0.2 (2021-09-08)
### Bug Fixes
* [#7131](https://github.com/netbox-community/netbox/issues/7131) - Fix issue where Site fields were hidden when editing a VLAN group
* [#7148](https://github.com/netbox-community/netbox/issues/7148) - Fix issue where static query parameters with multiple values were not queried properly
* [#7153](https://github.com/netbox-community/netbox/issues/7153) - Allow clearing of assigned device type images
* [#7162](https://github.com/netbox-community/netbox/issues/7162) - Ensure consistent treatment of `BASE_PATH` for UI-driven API requests
* [#7164](https://github.com/netbox-community/netbox/issues/7164) - Fix styling of "decommissioned" label for circuits
* [#7169](https://github.com/netbox-community/netbox/issues/7169) - Fix CSV import file upload
* [#7176](https://github.com/netbox-community/netbox/issues/7176) - Fix issue where query parameters were duplicated across different forms of the same type
* [#7179](https://github.com/netbox-community/netbox/issues/7179) - Prevent obscuring "connect" pop-up for interfaces under device view
* [#7188](https://github.com/netbox-community/netbox/issues/7188) - Fix issue where select fields with `null_option` did not render or send the null option
* [#7189](https://github.com/netbox-community/netbox/issues/7189) - Set connection factory for django-redis when Sentinel is in use
* [#7191](https://github.com/netbox-community/netbox/issues/7191) - Fix issue where API-backed multi-select elements cleared selected options when adding new options
* [#7193](https://github.com/netbox-community/netbox/issues/7193) - Fix prefix (flat) template issue when viewing child prefixes with prefixes available
* [#7205](https://github.com/netbox-community/netbox/issues/7205) - Fix issue where selected fields with `null_option` set were not added to applied filters
* [#7209](https://github.com/netbox-community/netbox/issues/7209) - Allow unlimited API results when `MAX_PAGE_SIZE` is disabled
---
## v3.0.1 (2021-09-01)
### Bug Fixes

View File

@ -29,7 +29,7 @@ class CircuitStatusChoices(ChoiceSet):
STATUS_PLANNED: 'info',
STATUS_PROVISIONING: 'primary',
STATUS_OFFLINE: 'danger',
STATUS_DECOMMISSIONED: 'default',
STATUS_DECOMMISSIONED: 'secondary',
}

View File

@ -23,10 +23,10 @@ from tenancy.forms import TenancyFilterForm, TenancyForm
from tenancy.models import Tenant
from utilities.forms import (
APISelect, APISelectMultiple, add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
ColorField, CommentField, CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVTypedChoiceField,
DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField,
NumericArrayField, SelectWithPK, SmallTextarea, SlugField, StaticSelect, StaticSelectMultiple, TagFilterField,
BOOLEAN_WITH_BLANK_CHOICES,
ClearableFileInput, ColorField, CommentField, CSVChoiceField, CSVContentTypeField, CSVModelChoiceField,
CSVTypedChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model,
JSONField, NumericArrayField, SelectWithPK, SmallTextarea, SlugField, StaticSelect, StaticSelectMultiple,
TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
)
from virtualization.models import Cluster, ClusterGroup
from .choices import *
@ -1271,10 +1271,10 @@ class DeviceTypeForm(BootstrapMixin, CustomFieldModelForm):
)
widgets = {
'subdevice_role': StaticSelect(),
'front_image': forms.ClearableFileInput(attrs={
'front_image': ClearableFileInput(attrs={
'accept': DEVICETYPE_IMAGE_FORMATS
}),
'rear_image': forms.ClearableFileInput(attrs={
'rear_image': ClearableFileInput(attrs={
'accept': DEVICETYPE_IMAGE_FORMATS
})
}

View File

@ -0,0 +1,26 @@
from django.db import migrations
def clear_secrets_changelog(apps, schema_editor):
"""
Delete all ObjectChange records referencing a model within the old secrets app (pre-v3.0).
"""
ContentType = apps.get_model('contenttypes', 'ContentType')
ObjectChange = apps.get_model('extras', 'ObjectChange')
content_type_ids = ContentType.objects.filter(app_label='secrets').values_list('id', flat=True)
ObjectChange.objects.filter(changed_object_type__in=content_type_ids).delete()
class Migration(migrations.Migration):
dependencies = [
('extras', '0061_extras_change_logging'),
]
operations = [
migrations.RunPython(
code=clear_secrets_changelog,
reverse_code=migrations.RunPython.noop
),
]

View File

@ -25,6 +25,15 @@ PREFIX_LINK = """
<a href="{% if record.pk %}{% url 'ipam:prefix' pk=record.pk %}{% else %}{% url 'ipam:prefix_add' %}?prefix={{ record }}{% if object.vrf %}&vrf={{ object.vrf.pk }}{% endif %}{% if object.site %}&site={{ object.site.pk }}{% endif %}{% if object.tenant %}&tenant_group={{ object.tenant.group.pk }}&tenant={{ object.tenant.pk }}{% endif %}{% endif %}">{{ record.prefix }}</a>
"""
PREFIXFLAT_LINK = """
{% load helpers %}
{% if record.pk %}
<a href="{% url 'ipam:prefix' pk=record.pk %}">{{ record.prefix }}</a>
{% else %}
&mdash;
{% endif %}
"""
PREFIX_ROLE_LINK = """
{% if record.role %}
<a href="{% url 'ipam:prefix_list' %}?role={{ record.role.slug }}">{{ record.role }}</a>
@ -281,10 +290,10 @@ class PrefixTable(BaseTable):
template_code=PREFIX_LINK,
attrs={'td': {'class': 'text-nowrap'}}
)
prefix_flat = tables.Column(
accessor=Accessor('prefix'),
linkify=True,
verbose_name='Prefix (Flat)'
prefix_flat = tables.TemplateColumn(
template_code=PREFIXFLAT_LINK,
attrs={'td': {'class': 'text-nowrap'}},
verbose_name='Prefix (Flat)',
)
depth = tables.Column(
accessor=Accessor('_depth'),

View File

@ -34,13 +34,22 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination):
return list(queryset[self.offset:])
def get_limit(self, request):
limit = super().get_limit(request)
# Enforce maximum page size
if self.limit_query_param:
try:
limit = int(request.query_params[self.limit_query_param])
if limit < 0:
raise ValueError()
# Enforce maximum page size, if defined
if settings.MAX_PAGE_SIZE:
limit = min(limit, settings.MAX_PAGE_SIZE)
if limit == 0:
return settings.MAX_PAGE_SIZE
else:
return min(limit, settings.MAX_PAGE_SIZE)
return limit
except (KeyError, ValueError):
pass
return self.default_limit
def get_next_link(self):

View File

@ -16,7 +16,7 @@ from django.core.validators import URLValidator
# Environment setup
#
VERSION = '3.0.1'
VERSION = '3.0.2'
# Hostname
HOSTNAME = platform.node()
@ -250,6 +250,7 @@ CACHES = {
}
}
if CACHING_REDIS_SENTINELS:
DJANGO_REDIS_CONNECTION_FACTORY = 'django_redis.pool.SentinelConnectionFactory'
CACHES['default']['LOCATION'] = f'{CACHING_REDIS_PROTO}://{CACHING_REDIS_SENTINEL_SERVICE}/{CACHING_REDIS_DATABASE}'
CACHES['default']['OPTIONS']['CLIENT_CLASS'] = 'django_redis.client.SentinelClient'
CACHES['default']['OPTIONS']['SENTINELS'] = CACHING_REDIS_SENTINELS

View File

@ -21,8 +21,7 @@ from extras.signals import clear_webhooks
from utilities.error_handlers import handle_protectederror
from utilities.exceptions import AbortTransaction, PermissionsViolation
from utilities.forms import (
BootstrapMixin, BulkRenameForm, ConfirmationForm, CSVDataField, CSVFileField, ImportForm, TableConfigForm,
restrict_form_fields,
BootstrapMixin, BulkRenameForm, ConfirmationForm, CSVDataField, CSVFileField, ImportForm, restrict_form_fields,
)
from utilities.permissions import get_permission_for_model
from utilities.tables import paginate_table

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -8,12 +8,12 @@ import { isTruthy, apiPatch, hasError, getElements } from '../util';
* @param element Connection Toggle Button Element
*/
function toggleConnection(element: HTMLButtonElement): void {
const id = element.getAttribute('data');
const url = element.getAttribute('data-url');
const connected = element.classList.contains('connected');
const status = connected ? 'planned' : 'connected';
if (isTruthy(id)) {
apiPatch(`/api/dcim/cables/${id}/`, { status }).then(res => {
if (isTruthy(url)) {
apiPatch(url, { status }).then(res => {
if (hasError(res)) {
// If the API responds with an error, show it to the user.
createToast('danger', 'Error', res.error).show();

View File

@ -1,8 +1,21 @@
import { getElements, toggleVisibility } from '../util';
type ShowHideMap = {
/**
* Name of view to which this map should apply.
*
* @example vlangroup_edit
*/
[view: string]: {
/**
* Default layout.
*/
default: { hide: string[]; show: string[] };
[k: string]: { hide: string[]; show: string[] };
/**
* Field name to layout mapping.
*/
[fieldName: string]: { hide: string[]; show: string[] };
};
};
/**
@ -14,6 +27,7 @@ type ShowHideMap = {
* showHideMap.region.show should be shown.
*/
const showHideMap: ShowHideMap = {
vlangroup_edit: {
region: {
hide: ['id_sitegroup', 'id_site', 'id_location', 'id_rack', 'id_clustergroup', 'id_cluster'],
show: ['id_region'],
@ -54,6 +68,7 @@ const showHideMap: ShowHideMap = {
],
show: [],
},
},
};
/**
* Toggle visibility of a given element's parent.
@ -76,11 +91,11 @@ function toggleParentVisibility(query: string, action: 'show' | 'hide') {
/**
* Handle changes to the Scope Type field.
*/
function handleScopeChange(element: HTMLSelectElement) {
function handleScopeChange<P extends keyof ShowHideMap>(view: P, element: HTMLSelectElement) {
// Scope type's innerText looks something like `DCIM > region`.
const scopeType = element.options[element.selectedIndex].innerText.toLowerCase();
for (const [scope, fields] of Object.entries(showHideMap)) {
for (const [scope, fields] of Object.entries(showHideMap[view])) {
// If the scope type ends with the specified scope, toggle its field visibility according to
// the show/hide values.
if (scopeType.endsWith(scope)) {
@ -94,7 +109,7 @@ function handleScopeChange(element: HTMLSelectElement) {
break;
} else {
// Otherwise, hide all fields.
for (const field of showHideMap.default.hide) {
for (const field of showHideMap[view].default.hide) {
toggleParentVisibility(`#${field}`, 'hide');
}
}
@ -105,8 +120,12 @@ function handleScopeChange(element: HTMLSelectElement) {
* Initialize scope type select event listeners.
*/
export function initScopeSelector(): void {
for (const element of getElements<HTMLSelectElement>('#id_scope_type')) {
handleScopeChange(element);
element.addEventListener('change', () => handleScopeChange(element));
for (const view of Object.keys(showHideMap)) {
for (const element of getElements<HTMLSelectElement>(
`html[data-netbox-url-name="${view}"] #id_scope_type`,
)) {
handleScopeChange(view, element);
element.addEventListener('change', () => handleScopeChange(view, element));
}
}
}

View File

@ -4,7 +4,7 @@ import { apiGetBase, hasError, getNetboxData } from './util';
let timeout: number = 1000;
interface JobInfo {
id: Nullable<string>;
url: Nullable<string>;
complete: boolean;
}
@ -23,15 +23,16 @@ function asyncTimeout(ms: number) {
function getJobInfo(): JobInfo {
let complete = false;
const id = getNetboxData('data-job-id');
const jobComplete = getNetboxData('data-job-complete');
// Determine the API URL for the job status
const url = getNetboxData('data-job-url');
// Determine the job completion status, if present. If the job is not complete, the value will be
// "None". Otherwise, it will be a stringified date.
const jobComplete = getNetboxData('data-job-complete');
if (typeof jobComplete === 'string' && jobComplete.toLowerCase() !== 'none') {
complete = true;
}
return { id, complete };
return { url, complete };
}
/**
@ -59,10 +60,10 @@ function updateLabel(status: JobStatus) {
/**
* Recursively check the job's status.
* @param id Job ID
* @param url API URL for job result
*/
async function checkJobStatus(id: string) {
const res = await apiGetBase<APIJobResult>(`/api/extras/job-results/${id}/`);
async function checkJobStatus(url: string) {
const res = await apiGetBase<APIJobResult>(url);
if (hasError(res)) {
// If the response is an API error, display an error message and stop checking for job status.
const toast = createToast('danger', 'Error', res.error);
@ -82,17 +83,17 @@ async function checkJobStatus(id: string) {
if (timeout < 10000) {
timeout += 1000;
}
await Promise.all([checkJobStatus(id), asyncTimeout(timeout)]);
await Promise.all([checkJobStatus(url), asyncTimeout(timeout)]);
}
}
}
function initJobs() {
const { id, complete } = getJobInfo();
const { url, complete } = getJobInfo();
if (id !== null && !complete) {
if (url !== null && !complete) {
// If there is a job ID and it is not completed, check for the job's status.
Promise.resolve(checkJobStatus(id));
Promise.resolve(checkJobStatus(url));
}
}

View File

@ -5,7 +5,7 @@ import SlimSelect from 'slim-select';
import { createToast } from '../../bs';
import { hasUrl, hasExclusions, isTrigger } from '../util';
import { DynamicParamsMap } from './dynamicParams';
import { isStaticParams } from './types';
import { isStaticParams, isOption } from './types';
import {
hasMore,
isTruthy,
@ -23,7 +23,7 @@ import type { Option } from 'slim-select/dist/data';
import type { Trigger, PathFilter, ApplyMethod, QueryFilter } from './types';
// Empty placeholder option.
const PLACEHOLDER = {
const EMPTY_PLACEHOLDER = {
value: '',
text: '',
placeholder: true,
@ -52,6 +52,18 @@ export class APISelect {
*/
public readonly placeholder: string;
/**
* Empty/placeholder option. Display text is optionally overridden via the `data-empty-option`
* attribute.
*/
public readonly emptyOption: Option;
/**
* Null option. When `data-null-option` attribute is a string, the value is used to created an
* option of type `{text: '<value from data-null-option>': 'null'}`.
*/
public readonly nullOption: Nullable<Option> = null;
/**
* Event that will initiate the API call to NetBox to load option data. By default, the trigger
* is `'load'`, so data will be fetched when the element renders on the page.
@ -144,11 +156,6 @@ export class APISelect {
*/
private preSorted: boolean = false;
/**
* This instance's available options.
*/
private _options: Option[] = [PLACEHOLDER];
/**
* Array of options values which should be considered disabled or static.
*/
@ -181,6 +188,24 @@ export class APISelect {
this.disabledOptions = this.getDisabledOptions();
this.disabledAttributes = this.getDisabledAttributes();
const emptyOption = base.getAttribute('data-empty-option');
if (isTruthy(emptyOption)) {
this.emptyOption = {
text: emptyOption,
value: '',
};
} else {
this.emptyOption = EMPTY_PLACEHOLDER;
}
const nullOption = base.getAttribute('data-null-option');
if (isTruthy(nullOption)) {
this.nullOption = {
text: nullOption,
value: 'null',
};
}
this.slim = new SlimSelect({
select: this.base,
allowDeselect: true,
@ -265,7 +290,7 @@ export class APISelect {
* This instance's available options.
*/
private get options(): Option[] {
return this._options;
return this.slim.data.data.filter(isOption);
}
/**
@ -275,28 +300,30 @@ export class APISelect {
*/
private set options(optionsIn: Option[]) {
let newOptions = optionsIn;
// Ensure null option is present, if it exists.
if (this.nullOption !== null) {
newOptions = [this.nullOption, ...newOptions];
}
// Sort options unless this element is pre-sorted.
if (!this.preSorted) {
newOptions = optionsIn.sort((a, b) => (a.text.toLowerCase() > b.text.toLowerCase() ? 1 : -1));
newOptions = newOptions.sort((a, b) =>
a.text.toLowerCase() > b.text.toLowerCase() ? 1 : -1,
);
}
// Deduplicate options each time they're set.
let deduplicated = uniqueByProperty(newOptions, 'value');
const deduplicated = uniqueByProperty(newOptions, 'value');
// Determine if the new options have a placeholder.
const hasPlaceholder = typeof deduplicated.find(o => o.value === '') !== 'undefined';
// Get the placeholder index (note: if there is no placeholder, the index will be `-1`).
const placeholderIdx = deduplicated.findIndex(o => o.value === '');
if (hasPlaceholder && placeholderIdx < 0) {
// If there is a placeholder but it is not the first element (due to sorting or other merge
// issues), remove it from the options array and place it in front.
deduplicated.splice(placeholderIdx);
deduplicated = [PLACEHOLDER, ...deduplicated];
if (hasPlaceholder && placeholderIdx >= 0) {
// If there is an existing placeholder, replace it.
deduplicated[placeholderIdx] = this.emptyOption;
} else {
// If there is not a placeholder, add one to the front.
deduplicated.unshift(this.emptyOption);
}
if (!hasPlaceholder) {
// If there is no placeholder, add one to the front of the array.
deduplicated = [PLACEHOLDER, ...deduplicated];
}
this._options = deduplicated;
this.slim.setData(deduplicated);
}
@ -304,7 +331,7 @@ export class APISelect {
* Remove all options and reset back to the generic placeholder.
*/
private resetOptions(): void {
this.options = [PLACEHOLDER];
this.options = [this.emptyOption];
}
/**
@ -348,7 +375,12 @@ export class APISelect {
const fetcher = debounce((event: Event) => this.handleSearch(event), 300, false);
// Query the API when the input value changes or a value is pasted.
this.slim.slim.search.input.addEventListener('keyup', event => fetcher(event));
this.slim.slim.search.input.addEventListener('keyup', event => {
// Only search when necessary keys are pressed.
if (!event.key.match(/^(Arrow|Enter|Tab).*/)) {
return fetcher(event);
}
});
this.slim.slim.search.input.addEventListener('paste', event => fetcher(event));
// Watch every scroll event to determine if the scroll position is at bottom.
@ -437,7 +469,7 @@ export class APISelect {
for (const result of data.results) {
let text = result.display;
if (typeof result._depth === 'number') {
if (typeof result._depth === 'number' && result._depth > 0) {
// If the object has a `_depth` property, indent its display text.
if (!this.preSorted) {
this.preSorted = true;
@ -534,7 +566,7 @@ export class APISelect {
*/
private async getOptions(action: ApplyMethod = 'merge'): Promise<void> {
if (this.queryUrl.includes(`{{`)) {
this.options = [PLACEHOLDER];
this.resetOptions();
return;
}
await this.fetchOptions(this.queryUrl, action);

View File

@ -1,4 +1,5 @@
import type { Stringifiable } from 'query-string';
import type { Option, Optgroup } from 'slim-select/dist/data';
/**
* Map of string keys to primitive array values accepted by `query-string`. Keys are used as
@ -187,3 +188,12 @@ export function isStaticParams(value: unknown): value is DataStaticParam[] {
}
return false;
}
/**
* Type guard to determine if a SlimSelect `dataObject` is an `Option`.
*
* @param data Option or Option Group
*/
export function isOption(data: Option | Optgroup): data is Option {
return !('options' in data);
}

View File

@ -53,8 +53,8 @@ function removeColumns(event: Event): void {
/**
* Submit form configuration to the NetBox API.
*/
async function submitFormConfig(formConfig: Dict<Dict>): Promise<APIResponse<APIUserConfig>> {
return await apiPatch<APIUserConfig>('/api/users/config/', formConfig);
async function submitFormConfig(url: string, formConfig: Dict<Dict>): Promise<APIResponse<APIUserConfig>> {
return await apiPatch<APIUserConfig>(url, formConfig);
}
/**
@ -66,6 +66,18 @@ function handleSubmit(event: Event): void {
const element = event.currentTarget as HTMLFormElement;
// Get the API URL for submitting the form
const url = element.getAttribute('data-url');
if (url == null) {
const toast = createToast(
'danger',
'Error Updating Table Configuration',
'No API path defined for configuration form.'
);
toast.show();
return;
}
// Get all the selected options from any select element in the form.
const options = getSelectedOptions(element);
@ -83,7 +95,7 @@ function handleSubmit(event: Event): void {
const data = path.reduceRight<Dict<Dict>>((value, key) => ({ [key]: value }), formData);
// Submit the resulting object to the API to update the user's preferences for this table.
submitFormConfig(data).then(res => {
submitFormConfig(url, data).then(res => {
if (hasError(res)) {
const toast = createToast('danger', 'Error Updating Table Configuration', res.error);
toast.show();

View File

@ -1,5 +1,4 @@
import Cookie from 'cookie';
import queryString from 'query-string';
type Method = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
type ReqData = URLSearchParams | Dict | undefined | unknown;
@ -105,60 +104,8 @@ function getCsrfToken(): string {
return csrfToken;
}
/**
* Get the NetBox `settings.BASE_PATH` from the `<html/>` element's data attributes.
*
* @returns If there is no `BASE_PATH` specified, the return value will be `''`.
*/ function getBasePath(): string {
const value = document.documentElement.getAttribute('data-netbox-base-path');
if (value === null) {
return '';
}
return value;
}
/**
* Build a NetBox URL that includes `settings.BASE_PATH` and enforces leading and trailing slashes.
*
* @example
* ```js
* // With a BASE_PATH of 'netbox/'
* const url = buildUrl('/api/dcim/devices');
* console.log(url);
* // => /netbox/api/dcim/devices/
* ```
*
* @param path Relative path _after_ (excluding) the `BASE_PATH`.
*/
function buildUrl(destination: string): string {
// Separate the path from any URL search params.
const [pathname, search] = destination.split(/(?=\?)/g);
// If the `origin` exists in the API path (as in the case of paginated responses), remove it.
const origin = new RegExp(window.location.origin, 'g');
const path = pathname.replaceAll(origin, '');
const basePath = getBasePath();
// Combine `BASE_PATH` with this request's path, removing _all_ slashes.
let combined = [...basePath.split('/'), ...path.split('/')].filter(p => p);
if (combined[0] !== '/') {
// Ensure the URL has a leading slash.
combined = ['', ...combined];
}
if (combined[combined.length - 1] !== '/') {
// Ensure the URL has a trailing slash.
combined = [...combined, ''];
}
const url = combined.join('/');
// Construct an object from the URL search params so it can be re-serialized with the new URL.
const query = Object.fromEntries(new URLSearchParams(search).entries());
return queryString.stringifyUrl({ url, query });
}
export async function apiRequest<R extends Dict, D extends ReqData = undefined>(
path: string,
url: string,
method: Method,
data?: D,
): Promise<APIResponse<R>> {
@ -170,7 +117,6 @@ export async function apiRequest<R extends Dict, D extends ReqData = undefined>(
body = JSON.stringify(data);
headers.set('content-type', 'application/json');
}
const url = buildUrl(path);
const res = await fetch(url, { method, body, headers, credentials: 'same-origin' });
const contentType = res.headers.get('Content-Type');

View File

@ -971,7 +971,7 @@ div.card-overlay {
// Page-specific styles.
html {
// Shade the home page content background-color.
&[data-netbox-path='/'] {
&[data-netbox-url-name='home'] {
.content-container,
.search {
background-color: $gray-100 !important;
@ -985,7 +985,7 @@ html {
}
// Don't show the django-messages toasts on the login screen in favor of the alert component.
&[data-netbox-path*='/login'] {
&[data-netbox-url-name='login'] {
#django-messages {
display: none;
}

View File

@ -4,7 +4,7 @@
<!DOCTYPE html>
<html
lang="en"
data-netbox-path="{{ request.path }}"
data-netbox-url-name="{{ request.resolver_match.url_name }}"
data-netbox-base-path="{{ settings.BASE_PATH }}"
{% if preferences|get_key:'ui.colormode' == 'dark'%}
data-netbox-color-mode="dark"

View File

@ -34,7 +34,7 @@
</div>
</div>
</div>
{% include 'inc/responsive_table.html' with table=interface_table %}
{% render_table interface_table 'inc/table.html' %}
<div class="noprint bulk-buttons">
<div class="bulk-button-group">
{% if perms.dcim.change_interface %}

View File

@ -1,10 +1,10 @@
{% if perms.dcim.change_cable %}
{% if cable.status == 'connected' %}
<button type="button" class="btn btn-warning btn-sm cable-toggle connected" title="Mark Planned" data="{{ cable.pk }}">
<button type="button" class="btn btn-warning btn-sm cable-toggle connected" title="Mark Planned" data-url="{% url 'dcim-api:cable-detail' pk=cable.pk %}">
<i class="mdi mdi-lan-disconnect" aria-hidden="true"></i>
</button>
{% else %}
<button type="button" class="btn btn-info btn-sm cable-toggle" title="Mark Installed" data="{{ cable.pk }}">
<button type="button" class="btn btn-info btn-sm cable-toggle" title="Mark Installed" data-url="{% url 'dcim-api:cable-detail' pk=cable.pk %}">
<i class="mdi mdi-lan-connect" aria-hidden="true"></i>
</button>
{% endif %}

View File

@ -96,6 +96,6 @@
{% endblock %}
{% block data %}
<span data-job-id="{{ result.pk }}"></span>
<span data-job-url="{% url 'extras-api:jobresult-detail' pk=result.pk %}"></span>
<span data-job-complete="{{ result.completed }}"></span>
{% endblock %}

View File

@ -112,6 +112,6 @@
{% endblock content-wrapper %}
{% block data %}
<span data-job-id="{{ result.pk }}"></span>
<span data-job-url="{% url 'extras-api:jobresult-detail' pk=result.pk %}"></span>
<span data-job-complete="{{ result.completed }}"></span>
{% endblock %}

View File

@ -17,9 +17,24 @@
{% block content %}
<div class="row">
<div class="col col-md-12 col-lg-10 offset-lg-1">
<form action="" method="post" class="form">
<ul class="nav nav-pills px-3" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" role="tab" type="button" data-bs-target="#csv" data-bs-toggle="tab">CSV Data</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" role="tab" type="button" data-bs-target="#csv-file" data-bs-toggle="tab">CSV File Upload</button>
</li>
</ul>
<form action="" method="post" enctype="multipart/form-data" class="form">
{% csrf_token %}
{% render_form form %}
<div class="tab-content border-0">
<div role="tabpanel" class="tab-pane active" id="csv">
{% render_field form.csv %}
</div>
<div role="tabpanel" class="tab-pane" id="csv-file">
{% render_field form.csv_file %}
</div>
</div>
<div class="form-group">
<div class="col col-md-12 text-end">
{% if return_url %}

View File

@ -86,7 +86,7 @@
</div>
</div>
{% elif field|widget_type == 'fileinput' or field|widget_type == 'clearablefileinput' %}
{% elif field|widget_type == 'fileinput' %}
<div class="input-group mb-3">
<input
class="form-control"
@ -100,6 +100,16 @@
<label for="{{ field.id_for_label }}" class="input-group-text">{{ field.label|bettertitle }}</label>
</div>
{% elif field|widget_type == 'clearablefileinput' %}
<div class="row mb-3">
<label for="{{ field.id_for_label }}" class="form-label col col-md-3 text-lg-end{% if field.field.required %} required{% endif %}">
{{ field.label }}
</label>
<div class="col col-md-9">
{{ field }}
</div>
</div>
{% elif field|widget_type == 'selectmultiple' %}
<div class="row mb-3">
<label for="{{ field.id_for_label }}" class="form-label col col-md-3 text-lg-end{% if field.field.required %} required{% endif %}">

View File

@ -7,7 +7,7 @@
<h5 class="modal-title">Table Configuration</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form class="form-horizontal userconfigform" data-config-root="tables.{{ form.table_name }}">
<form class="form-horizontal userconfigform" data-url="{% url 'users-api:userconfig-list' %}" data-config-root="tables.{{ form.table_name }}">
<div class="modal-body row">
<div class="col-5 text-center">
{{ form.available_columns.label }}

View File

@ -376,7 +376,7 @@ class DynamicModelChoiceMixin:
widget = widgets.APISelect
def __init__(self, query_params=None, initial_params=None, null_option=None, disabled_indicator=None, fetch_trigger=None,
*args, **kwargs):
empty_label=None, *args, **kwargs):
self.query_params = query_params or {}
self.initial_params = initial_params or {}
self.null_option = null_option
@ -386,11 +386,14 @@ class DynamicModelChoiceMixin:
# to_field_name is set by ModelChoiceField.__init__(), but we need to set it early for reference
# by widget_attrs()
self.to_field_name = kwargs.get('to_field_name')
self.empty_option = empty_label or ""
super().__init__(*args, **kwargs)
def widget_attrs(self, widget):
attrs = {}
attrs = {
'data-empty-option': self.empty_option
}
# Set value-field attribute if the field specifies to_field_name
if self.to_field_name:
@ -474,3 +477,13 @@ class DynamicModelMultipleChoiceField(DynamicModelChoiceMixin, forms.ModelMultip
"""
filter = django_filters.ModelMultipleChoiceFilter
widget = widgets.APISelectMultiple
def clean(self, value):
"""
When null option is enabled and "None" is sent as part of a form to be submitted, it is sent as the
string 'null'. This will check for that condition and gracefully handle the conversion to a NoneType.
"""
if self.null_option is not None and settings.FILTERS_NULL_CHOICE_VALUE in value:
value = [v for v in value if v != settings.FILTERS_NULL_CHOICE_VALUE]
return [None, *value]
return super().clean(value)

View File

@ -4,7 +4,7 @@ import re
import yaml
from django import forms
from .widgets import APISelect, APISelectMultiple, StaticSelect
from .widgets import APISelect, APISelectMultiple, ClearableFileInput, StaticSelect
__all__ = (
@ -29,12 +29,12 @@ class BootstrapMixin(forms.BaseForm):
exempt_widgets = [
forms.CheckboxInput,
forms.ClearableFileInput,
forms.FileInput,
forms.RadioSelect,
forms.Select,
APISelect,
APISelectMultiple,
ClearableFileInput,
StaticSelect,
]

View File

@ -1,6 +1,7 @@
import re
from django import forms
from django.conf import settings
from django.forms.models import fields_for_model
from utilities.choices import unpack_grouped_choices
@ -120,13 +121,20 @@ def get_selected_values(form, field_name):
if not hasattr(form, 'cleaned_data'):
form.is_valid()
filter_data = form.cleaned_data.get(field_name)
field = form.fields[field_name]
# Selection field
if hasattr(form.fields[field_name], 'choices'):
if hasattr(field, 'choices'):
try:
choices = dict(unpack_grouped_choices(form.fields[field_name].choices))
choices = unpack_grouped_choices(field.choices)
if hasattr(field, 'null_option'):
# If the field has a `null_option` attribute set and it is selected,
# add it to the field's grouped choices.
if field.null_option is not None and None in filter_data:
choices.append((settings.FILTERS_NULL_CHOICE_VALUE, field.null_option))
return [
label for value, label in choices.items() if str(value) in filter_data
label for value, label in choices if str(value) in filter_data or None in filter_data
]
except TypeError:
# Field uses dynamic choices. Show all that have been populated.

View File

@ -12,6 +12,7 @@ __all__ = (
'APISelect',
'APISelectMultiple',
'BulkEditNullBooleanSelect',
'ClearableFileInput',
'ColorSelect',
'ContentTypeSelect',
'DatePicker',
@ -135,6 +136,13 @@ class NumericArrayField(SimpleArrayField):
return super().to_python(value)
class ClearableFileInput(forms.ClearableFileInput):
"""
Override Django's stock ClearableFileInput with a custom template.
"""
template_name = 'widgets/clearable_file_input.html'
class APISelect(SelectWithDisabled):
"""
A select widget populated via an API call
@ -155,6 +163,13 @@ class APISelect(SelectWithDisabled):
if api_url:
self.attrs['data-url'] = '/{}{}'.format(settings.BASE_PATH, api_url.lstrip('/')) # Inject BASE_PATH
def __deepcopy__(self, memo):
"""Reset `static_params` and `dynamic_params` when APISelect is deepcopied."""
result = super().__deepcopy__(memo)
result.dynamic_params = {}
result.static_params = {}
return result
def _process_query_param(self, key: str, value: JSONPrimitive) -> None:
"""
Based on query param value's type and value, update instance's dynamic/static params.

View File

@ -0,0 +1,24 @@
<div class="row">
<div class="col-6">
{% if widget.is_initial %}
<a href="{{ widget.value.url }}">{{ widget.value }}</a>
{% if not widget.required %}
<br />
<input type="checkbox" name="{{ widget.checkbox_name }}" id="{{ widget.checkbox_id }}"{% if widget.attrs.disabled %} disabled{% endif %}>
<label for="{{ widget.checkbox_id }}">{{ widget.clear_checkbox_label }}</label>
{% endif %}
{% else %}
<span class="text-muted">None assigned</span>
{% endif %}
</div>
<div class="col-6">
<input
class="form-control"
type="{{ widget.type }}"
name="{{ widget.name }}"
id="id_{{ widget.name }}"
accept="{{ widget.attrs.accept }}"
{% if widget.required %}required{% endif %}
/>
</div>
</div>

View File

@ -39,13 +39,13 @@ class APITestCase(ModelTestCase):
def setUp(self):
"""
Create a superuser and token for API calls.
Create a user and token for API calls.
"""
# Create the test user and assign permissions
self.user = User.objects.create_user(username='testuser')
self.add_permissions(*self.user_permissions)
self.token = Token.objects.create(user=self.user)
self.header = {'HTTP_AUTHORIZATION': 'Token {}'.format(self.token.key)}
self.header = {'HTTP_AUTHORIZATION': f'Token {self.token.key}'}
def _get_view_namespace(self):
return f'{self.view_namespace or self.model._meta.app_label}-api'

View File

@ -1,7 +1,8 @@
import urllib.parse
from django.conf import settings
from django.contrib.contenttypes.models import ContentType
from django.test import Client, TestCase
from django.test import Client, TestCase, override_settings
from django.urls import reverse
from rest_framework import status
@ -122,6 +123,59 @@ class WritableNestedSerializerTest(APITestCase):
self.assertEqual(VLAN.objects.count(), 0)
class APIPaginationTestCase(APITestCase):
user_permissions = ('dcim.view_site',)
@classmethod
def setUpTestData(cls):
cls.url = reverse('dcim-api:site-list')
# Create a large number of Sites for testing
Site.objects.bulk_create([
Site(name=f'Site {i}', slug=f'site-{i}') for i in range(1, 101)
])
def test_default_page_size(self):
response = self.client.get(self.url, format='json', **self.header)
page_size = settings.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={page_size}&offset={page_size}'))
self.assertIsNone(response.data['previous'])
self.assertEqual(len(response.data['results']), page_size)
def test_custom_page_size(self):
response = self.client.get(f'{self.url}?limit=10', format='json', **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['count'], 100)
self.assertTrue(response.data['next'].endswith(f'?limit=10&offset=10'))
self.assertIsNone(response.data['previous'])
self.assertEqual(len(response.data['results']), 10)
@override_settings(MAX_PAGE_SIZE=20)
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(f'?limit=20&offset=20'))
self.assertIsNone(response.data['previous'])
self.assertEqual(len(response.data['results']), 20)
@override_settings(MAX_PAGE_SIZE=0)
def test_max_page_size_disabled(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.assertIsNone(response.data['next'])
self.assertIsNone(response.data['previous'])
self.assertEqual(len(response.data['results']), 100)
class APIDocsTestCase(TestCase):
def setUp(self):

View File

@ -3,7 +3,7 @@ django-cors-headers==3.8.0
django-debug-toolbar==3.2.2
django-filter==2.4.0
django-graphiql-debug-toolbar==0.2.0
django-mptt==0.13.2
django-mptt==0.13.3
django-pglocks==1.0.4
django-prometheus==2.1.0
django-redis==5.0.0
@ -18,9 +18,9 @@ gunicorn==20.1.0
Jinja2==3.0.1
Markdown==3.3.4
markdown-include==0.6.0
mkdocs-material==7.2.5
mkdocs-material==7.2.6
netaddr==0.8.0
Pillow==8.3.1
Pillow==8.3.2
psycopg2-binary==2.9.1
pycryptodome==3.10.1
PyYAML==5.4.1

41
scripts/verify-bundles.sh Executable file
View File

@ -0,0 +1,41 @@
#!/usr/bin/env bash
# This script verifies the integrity of *bundled* static assets by re-running the bundling process
# and checking for changed files. Because bundle output should not change given the same source
# input, the bundle process shouldn't produce any changes. If they do, it's an indication that
# the dist files have been altered, or that dist files were not committed. In either case, tests
# should fail.
PROJECT_STATIC="$PWD/netbox/project-static"
DIST="$PROJECT_STATIC/dist/"
# Bundle static assets.
bundle() {
echo "Bundling static assets..."
yarn --cwd $PROJECT_STATIC bundle >/dev/null 2>&1
if [[ $? != 0 ]]; then
echo "Error bundling static assets"
exit 1
fi
}
# See if any files have changed.
check_dist() {
local diff=$(git --no-pager diff $DIST)
if [[ $diff != "" ]]; then
local SHA=$(git rev-parse HEAD)
echo "Commit '$SHA' produced different static assets than were committed"
exit 1
fi
}
bundle
check_dist
if [[ $? = 0 ]]; then
echo "Static asset check passed"
exit 0
else
echo "Error checking static asset integrity"
exit 1
fi