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 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/) 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.) before opening a bug report to see if your issue has already been addressed.)
placeholder: v3.0.1 placeholder: v3.0.2
validations: validations:
required: true required: true
- type: dropdown - type: dropdown

View File

@ -14,7 +14,7 @@ body:
attributes: attributes:
label: NetBox version label: NetBox version
description: What version of NetBox are you currently running? description: What version of NetBox are you currently running?
placeholder: v3.0.1 placeholder: v3.0.2
validations: validations:
required: true required: true
- type: dropdown - type: dropdown
@ -30,8 +30,10 @@ body:
attributes: attributes:
label: Proposed functionality label: Proposed functionality
description: > description: >
Describe in detail the new feature or behavior you'd like to propose. Include any specific Describe in detail the new feature or behavior you are proposing. Include any specific changes
changes to work flows, data models, or the user interface. 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: validations:
required: true required: true
- type: textarea - type: textarea

View File

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

5
.gitignore vendored
View File

@ -1,16 +1,13 @@
*.pyc *.pyc
*.swp *.swp
node_modules
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
/netbox/project-static/.cache /netbox/project-static/node_modules
/netbox/project-static/docs/* /netbox/project-static/docs/*
!/netbox/project-static/docs/.info !/netbox/project-static/docs/.info
/netbox/netbox/configuration.py /netbox/netbox/configuration.py
/netbox/netbox/ldap_config.py /netbox/netbox/ldap_config.py
/netbox/project-static/.cache
/netbox/project-static/node_modules
/netbox/reports/* /netbox/reports/*
!/netbox/reports/__init__.py !/netbox/reports/__init__.py
/netbox/scripts/* /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. 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 !!! 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 ## 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 ## 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 ## 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`: 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. * `$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/). 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.) Next, clone the **master** branch of the NetBox GitHub repository into the current directory. (This branch always holds the current stable release.)
```no-highlight ```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: The `git clone` command should generate output similar to the following:
``` ```
Cloning into '.'... Cloning into '.'...
remote: Counting objects: 1994, done. remote: Enumerating objects: 996, done.
remote: Compressing objects: 100% (150/150), done. remote: Counting objects: 100% (996/996), done.
remote: Total 1994 (delta 80), reused 0 (delta 0), pack-reused 1842 remote: Compressing objects: 100% (935/935), done.
Receiving objects: 100% (1994/1994), 472.36 KiB | 0 bytes/s, done. remote: Total 996 (delta 148), reused 386 (delta 34), pack-reused 0
Resolving deltas: 100% (1495/1495), done. Receiving objects: 100% (996/996), 4.26 MiB | 9.81 MiB/s, done.
Checking connectivity... done. Resolving deltas: 100% (148/148), done.
``` ```
!!! note !!! note

View File

@ -14,7 +14,7 @@ While the provided configuration should suffice for most initial installations,
## systemd Setup ## 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 ```no-highlight
sudo cp -v /opt/netbox/contrib/*.service /etc/systemd/system/ 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. 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. 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 # 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) ## v3.0.1 (2021-09-01)
### Bug Fixes ### Bug Fixes

View File

@ -29,7 +29,7 @@ class CircuitStatusChoices(ChoiceSet):
STATUS_PLANNED: 'info', STATUS_PLANNED: 'info',
STATUS_PROVISIONING: 'primary', STATUS_PROVISIONING: 'primary',
STATUS_OFFLINE: 'danger', 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 tenancy.models import Tenant
from utilities.forms import ( from utilities.forms import (
APISelect, APISelectMultiple, add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, APISelect, APISelectMultiple, add_blank_choice, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect,
ColorField, CommentField, CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVTypedChoiceField, ClearableFileInput, ColorField, CommentField, CSVChoiceField, CSVContentTypeField, CSVModelChoiceField,
DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model, JSONField, CSVTypedChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField, form_from_model,
NumericArrayField, SelectWithPK, SmallTextarea, SlugField, StaticSelect, StaticSelectMultiple, TagFilterField, JSONField, NumericArrayField, SelectWithPK, SmallTextarea, SlugField, StaticSelect, StaticSelectMultiple,
BOOLEAN_WITH_BLANK_CHOICES, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
) )
from virtualization.models import Cluster, ClusterGroup from virtualization.models import Cluster, ClusterGroup
from .choices import * from .choices import *
@ -1271,10 +1271,10 @@ class DeviceTypeForm(BootstrapMixin, CustomFieldModelForm):
) )
widgets = { widgets = {
'subdevice_role': StaticSelect(), 'subdevice_role': StaticSelect(),
'front_image': forms.ClearableFileInput(attrs={ 'front_image': ClearableFileInput(attrs={
'accept': DEVICETYPE_IMAGE_FORMATS 'accept': DEVICETYPE_IMAGE_FORMATS
}), }),
'rear_image': forms.ClearableFileInput(attrs={ 'rear_image': ClearableFileInput(attrs={
'accept': DEVICETYPE_IMAGE_FORMATS '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> <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 = """ PREFIX_ROLE_LINK = """
{% if record.role %} {% if record.role %}
<a href="{% url 'ipam:prefix_list' %}?role={{ record.role.slug }}">{{ record.role }}</a> <a href="{% url 'ipam:prefix_list' %}?role={{ record.role.slug }}">{{ record.role }}</a>
@ -281,10 +290,10 @@ class PrefixTable(BaseTable):
template_code=PREFIX_LINK, template_code=PREFIX_LINK,
attrs={'td': {'class': 'text-nowrap'}} attrs={'td': {'class': 'text-nowrap'}}
) )
prefix_flat = tables.Column( prefix_flat = tables.TemplateColumn(
accessor=Accessor('prefix'), template_code=PREFIXFLAT_LINK,
linkify=True, attrs={'td': {'class': 'text-nowrap'}},
verbose_name='Prefix (Flat)' verbose_name='Prefix (Flat)',
) )
depth = tables.Column( depth = tables.Column(
accessor=Accessor('_depth'), accessor=Accessor('_depth'),

View File

@ -34,13 +34,22 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination):
return list(queryset[self.offset:]) return list(queryset[self.offset:])
def get_limit(self, request): def get_limit(self, request):
limit = super().get_limit(request) 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:
if limit == 0:
return settings.MAX_PAGE_SIZE
else:
return min(limit, settings.MAX_PAGE_SIZE)
return limit
except (KeyError, ValueError):
pass
# Enforce maximum page size return self.default_limit
if settings.MAX_PAGE_SIZE:
limit = min(limit, settings.MAX_PAGE_SIZE)
return limit
def get_next_link(self): def get_next_link(self):

View File

@ -16,7 +16,7 @@ from django.core.validators import URLValidator
# Environment setup # Environment setup
# #
VERSION = '3.0.1' VERSION = '3.0.2'
# Hostname # Hostname
HOSTNAME = platform.node() HOSTNAME = platform.node()
@ -250,6 +250,7 @@ CACHES = {
} }
} }
if CACHING_REDIS_SENTINELS: 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']['LOCATION'] = f'{CACHING_REDIS_PROTO}://{CACHING_REDIS_SENTINEL_SERVICE}/{CACHING_REDIS_DATABASE}'
CACHES['default']['OPTIONS']['CLIENT_CLASS'] = 'django_redis.client.SentinelClient' CACHES['default']['OPTIONS']['CLIENT_CLASS'] = 'django_redis.client.SentinelClient'
CACHES['default']['OPTIONS']['SENTINELS'] = CACHING_REDIS_SENTINELS 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.error_handlers import handle_protectederror
from utilities.exceptions import AbortTransaction, PermissionsViolation from utilities.exceptions import AbortTransaction, PermissionsViolation
from utilities.forms import ( from utilities.forms import (
BootstrapMixin, BulkRenameForm, ConfirmationForm, CSVDataField, CSVFileField, ImportForm, TableConfigForm, BootstrapMixin, BulkRenameForm, ConfirmationForm, CSVDataField, CSVFileField, ImportForm, restrict_form_fields,
restrict_form_fields,
) )
from utilities.permissions import get_permission_for_model from utilities.permissions import get_permission_for_model
from utilities.tables import paginate_table 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 * @param element Connection Toggle Button Element
*/ */
function toggleConnection(element: HTMLButtonElement): void { function toggleConnection(element: HTMLButtonElement): void {
const id = element.getAttribute('data'); const url = element.getAttribute('data-url');
const connected = element.classList.contains('connected'); const connected = element.classList.contains('connected');
const status = connected ? 'planned' : 'connected'; const status = connected ? 'planned' : 'connected';
if (isTruthy(id)) { if (isTruthy(url)) {
apiPatch(`/api/dcim/cables/${id}/`, { status }).then(res => { apiPatch(url, { status }).then(res => {
if (hasError(res)) { if (hasError(res)) {
// If the API responds with an error, show it to the user. // If the API responds with an error, show it to the user.
createToast('danger', 'Error', res.error).show(); createToast('danger', 'Error', res.error).show();

View File

@ -1,8 +1,21 @@
import { getElements, toggleVisibility } from '../util'; import { getElements, toggleVisibility } from '../util';
type ShowHideMap = { type ShowHideMap = {
default: { hide: string[]; show: string[] }; /**
[k: string]: { hide: string[]; show: string[] }; * Name of view to which this map should apply.
*
* @example vlangroup_edit
*/
[view: string]: {
/**
* Default layout.
*/
default: { hide: string[]; show: string[] };
/**
* Field name to layout mapping.
*/
[fieldName: string]: { hide: string[]; show: string[] };
};
}; };
/** /**
@ -14,45 +27,47 @@ type ShowHideMap = {
* showHideMap.region.show should be shown. * showHideMap.region.show should be shown.
*/ */
const showHideMap: ShowHideMap = { const showHideMap: ShowHideMap = {
region: { vlangroup_edit: {
hide: ['id_sitegroup', 'id_site', 'id_location', 'id_rack', 'id_clustergroup', 'id_cluster'], region: {
show: ['id_region'], hide: ['id_sitegroup', 'id_site', 'id_location', 'id_rack', 'id_clustergroup', 'id_cluster'],
}, show: ['id_region'],
'site group': { },
hide: ['id_region', 'id_site', 'id_location', 'id_rack', 'id_clustergroup', 'id_cluster'], 'site group': {
show: ['id_sitegroup'], hide: ['id_region', 'id_site', 'id_location', 'id_rack', 'id_clustergroup', 'id_cluster'],
}, show: ['id_sitegroup'],
site: { },
hide: ['id_location', 'id_rack', 'id_clustergroup', 'id_cluster'], site: {
show: ['id_region', 'id_sitegroup', 'id_site'], hide: ['id_location', 'id_rack', 'id_clustergroup', 'id_cluster'],
}, show: ['id_region', 'id_sitegroup', 'id_site'],
location: { },
hide: ['id_rack', 'id_clustergroup', 'id_cluster'], location: {
show: ['id_region', 'id_sitegroup', 'id_site', 'id_location'], hide: ['id_rack', 'id_clustergroup', 'id_cluster'],
}, show: ['id_region', 'id_sitegroup', 'id_site', 'id_location'],
rack: { },
hide: ['id_clustergroup', 'id_cluster'], rack: {
show: ['id_region', 'id_sitegroup', 'id_site', 'id_location', 'id_rack'], hide: ['id_clustergroup', 'id_cluster'],
}, show: ['id_region', 'id_sitegroup', 'id_site', 'id_location', 'id_rack'],
'cluster group': { },
hide: ['id_region', 'id_sitegroup', 'id_site', 'id_location', 'id_rack', 'id_cluster'], 'cluster group': {
show: ['id_clustergroup'], hide: ['id_region', 'id_sitegroup', 'id_site', 'id_location', 'id_rack', 'id_cluster'],
}, show: ['id_clustergroup'],
cluster: { },
hide: ['id_region', 'id_sitegroup', 'id_site', 'id_location', 'id_rack'], cluster: {
show: ['id_clustergroup', 'id_cluster'], hide: ['id_region', 'id_sitegroup', 'id_site', 'id_location', 'id_rack'],
}, show: ['id_clustergroup', 'id_cluster'],
default: { },
hide: [ default: {
'id_region', hide: [
'id_sitegroup', 'id_region',
'id_site', 'id_sitegroup',
'id_location', 'id_site',
'id_rack', 'id_location',
'id_clustergroup', 'id_rack',
'id_cluster', 'id_clustergroup',
], 'id_cluster',
show: [], ],
show: [],
},
}, },
}; };
/** /**
@ -76,11 +91,11 @@ function toggleParentVisibility(query: string, action: 'show' | 'hide') {
/** /**
* Handle changes to the Scope Type field. * 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`. // Scope type's innerText looks something like `DCIM > region`.
const scopeType = element.options[element.selectedIndex].innerText.toLowerCase(); const scopeType = element.options[element.selectedIndex].innerText.toLowerCase();
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 // If the scope type ends with the specified scope, toggle its field visibility according to
// the show/hide values. // the show/hide values.
if (scopeType.endsWith(scope)) { if (scopeType.endsWith(scope)) {
@ -94,7 +109,7 @@ function handleScopeChange(element: HTMLSelectElement) {
break; break;
} else { } else {
// Otherwise, hide all fields. // Otherwise, hide all fields.
for (const field of showHideMap.default.hide) { for (const field of showHideMap[view].default.hide) {
toggleParentVisibility(`#${field}`, 'hide'); toggleParentVisibility(`#${field}`, 'hide');
} }
} }
@ -105,8 +120,12 @@ function handleScopeChange(element: HTMLSelectElement) {
* Initialize scope type select event listeners. * Initialize scope type select event listeners.
*/ */
export function initScopeSelector(): void { export function initScopeSelector(): void {
for (const element of getElements<HTMLSelectElement>('#id_scope_type')) { for (const view of Object.keys(showHideMap)) {
handleScopeChange(element); for (const element of getElements<HTMLSelectElement>(
element.addEventListener('change', () => handleScopeChange(element)); `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; let timeout: number = 1000;
interface JobInfo { interface JobInfo {
id: Nullable<string>; url: Nullable<string>;
complete: boolean; complete: boolean;
} }
@ -23,15 +23,16 @@ function asyncTimeout(ms: number) {
function getJobInfo(): JobInfo { function getJobInfo(): JobInfo {
let complete = false; let complete = false;
const id = getNetboxData('data-job-id'); // Determine the API URL for the job status
const jobComplete = getNetboxData('data-job-complete'); const url = getNetboxData('data-job-url');
// Determine the job completion status, if present. If the job is not complete, the value will be // 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. // "None". Otherwise, it will be a stringified date.
const jobComplete = getNetboxData('data-job-complete');
if (typeof jobComplete === 'string' && jobComplete.toLowerCase() !== 'none') { if (typeof jobComplete === 'string' && jobComplete.toLowerCase() !== 'none') {
complete = true; complete = true;
} }
return { id, complete }; return { url, complete };
} }
/** /**
@ -59,10 +60,10 @@ function updateLabel(status: JobStatus) {
/** /**
* Recursively check the job's status. * Recursively check the job's status.
* @param id Job ID * @param url API URL for job result
*/ */
async function checkJobStatus(id: string) { async function checkJobStatus(url: string) {
const res = await apiGetBase<APIJobResult>(`/api/extras/job-results/${id}/`); const res = await apiGetBase<APIJobResult>(url);
if (hasError(res)) { if (hasError(res)) {
// If the response is an API error, display an error message and stop checking for job status. // If the response is an API error, display an error message and stop checking for job status.
const toast = createToast('danger', 'Error', res.error); const toast = createToast('danger', 'Error', res.error);
@ -82,17 +83,17 @@ async function checkJobStatus(id: string) {
if (timeout < 10000) { if (timeout < 10000) {
timeout += 1000; timeout += 1000;
} }
await Promise.all([checkJobStatus(id), asyncTimeout(timeout)]); await Promise.all([checkJobStatus(url), asyncTimeout(timeout)]);
} }
} }
} }
function initJobs() { 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. // 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 { createToast } from '../../bs';
import { hasUrl, hasExclusions, isTrigger } from '../util'; import { hasUrl, hasExclusions, isTrigger } from '../util';
import { DynamicParamsMap } from './dynamicParams'; import { DynamicParamsMap } from './dynamicParams';
import { isStaticParams } from './types'; import { isStaticParams, isOption } from './types';
import { import {
hasMore, hasMore,
isTruthy, isTruthy,
@ -23,7 +23,7 @@ import type { Option } from 'slim-select/dist/data';
import type { Trigger, PathFilter, ApplyMethod, QueryFilter } from './types'; import type { Trigger, PathFilter, ApplyMethod, QueryFilter } from './types';
// Empty placeholder option. // Empty placeholder option.
const PLACEHOLDER = { const EMPTY_PLACEHOLDER = {
value: '', value: '',
text: '', text: '',
placeholder: true, placeholder: true,
@ -52,6 +52,18 @@ export class APISelect {
*/ */
public readonly placeholder: string; 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 * 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. * 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; private preSorted: boolean = false;
/**
* This instance's available options.
*/
private _options: Option[] = [PLACEHOLDER];
/** /**
* Array of options values which should be considered disabled or static. * Array of options values which should be considered disabled or static.
*/ */
@ -181,6 +188,24 @@ export class APISelect {
this.disabledOptions = this.getDisabledOptions(); this.disabledOptions = this.getDisabledOptions();
this.disabledAttributes = this.getDisabledAttributes(); 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({ this.slim = new SlimSelect({
select: this.base, select: this.base,
allowDeselect: true, allowDeselect: true,
@ -265,7 +290,7 @@ export class APISelect {
* This instance's available options. * This instance's available options.
*/ */
private get options(): Option[] { 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[]) { private set options(optionsIn: Option[]) {
let newOptions = optionsIn; 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) { 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. // 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. // Determine if the new options have a placeholder.
const hasPlaceholder = typeof deduplicated.find(o => o.value === '') !== 'undefined'; const hasPlaceholder = typeof deduplicated.find(o => o.value === '') !== 'undefined';
// Get the placeholder index (note: if there is no placeholder, the index will be `-1`). // Get the placeholder index (note: if there is no placeholder, the index will be `-1`).
const placeholderIdx = deduplicated.findIndex(o => o.value === ''); const placeholderIdx = deduplicated.findIndex(o => o.value === '');
if (hasPlaceholder && placeholderIdx < 0) { if (hasPlaceholder && placeholderIdx >= 0) {
// If there is a placeholder but it is not the first element (due to sorting or other merge // If there is an existing placeholder, replace it.
// issues), remove it from the options array and place it in front. deduplicated[placeholderIdx] = this.emptyOption;
deduplicated.splice(placeholderIdx); } else {
deduplicated = [PLACEHOLDER, ...deduplicated]; // 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); this.slim.setData(deduplicated);
} }
@ -304,7 +331,7 @@ export class APISelect {
* Remove all options and reset back to the generic placeholder. * Remove all options and reset back to the generic placeholder.
*/ */
private resetOptions(): void { 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); const fetcher = debounce((event: Event) => this.handleSearch(event), 300, false);
// Query the API when the input value changes or a value is pasted. // 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)); this.slim.slim.search.input.addEventListener('paste', event => fetcher(event));
// Watch every scroll event to determine if the scroll position is at bottom. // 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) { for (const result of data.results) {
let text = result.display; 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 the object has a `_depth` property, indent its display text.
if (!this.preSorted) { if (!this.preSorted) {
this.preSorted = true; this.preSorted = true;
@ -534,7 +566,7 @@ export class APISelect {
*/ */
private async getOptions(action: ApplyMethod = 'merge'): Promise<void> { private async getOptions(action: ApplyMethod = 'merge'): Promise<void> {
if (this.queryUrl.includes(`{{`)) { if (this.queryUrl.includes(`{{`)) {
this.options = [PLACEHOLDER]; this.resetOptions();
return; return;
} }
await this.fetchOptions(this.queryUrl, action); await this.fetchOptions(this.queryUrl, action);

View File

@ -1,4 +1,5 @@
import type { Stringifiable } from 'query-string'; 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 * 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; 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. * Submit form configuration to the NetBox API.
*/ */
async function submitFormConfig(formConfig: Dict<Dict>): Promise<APIResponse<APIUserConfig>> { async function submitFormConfig(url: string, formConfig: Dict<Dict>): Promise<APIResponse<APIUserConfig>> {
return await apiPatch<APIUserConfig>('/api/users/config/', formConfig); return await apiPatch<APIUserConfig>(url, formConfig);
} }
/** /**
@ -66,6 +66,18 @@ function handleSubmit(event: Event): void {
const element = event.currentTarget as HTMLFormElement; 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. // Get all the selected options from any select element in the form.
const options = getSelectedOptions(element); const options = getSelectedOptions(element);
@ -83,7 +95,7 @@ function handleSubmit(event: Event): void {
const data = path.reduceRight<Dict<Dict>>((value, key) => ({ [key]: value }), formData); 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. // 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)) { if (hasError(res)) {
const toast = createToast('danger', 'Error Updating Table Configuration', res.error); const toast = createToast('danger', 'Error Updating Table Configuration', res.error);
toast.show(); toast.show();

View File

@ -1,5 +1,4 @@
import Cookie from 'cookie'; import Cookie from 'cookie';
import queryString from 'query-string';
type Method = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'; type Method = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE';
type ReqData = URLSearchParams | Dict | undefined | unknown; type ReqData = URLSearchParams | Dict | undefined | unknown;
@ -105,60 +104,8 @@ function getCsrfToken(): string {
return csrfToken; 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>( export async function apiRequest<R extends Dict, D extends ReqData = undefined>(
path: string, url: string,
method: Method, method: Method,
data?: D, data?: D,
): Promise<APIResponse<R>> { ): Promise<APIResponse<R>> {
@ -170,7 +117,6 @@ export async function apiRequest<R extends Dict, D extends ReqData = undefined>(
body = JSON.stringify(data); body = JSON.stringify(data);
headers.set('content-type', 'application/json'); headers.set('content-type', 'application/json');
} }
const url = buildUrl(path);
const res = await fetch(url, { method, body, headers, credentials: 'same-origin' }); const res = await fetch(url, { method, body, headers, credentials: 'same-origin' });
const contentType = res.headers.get('Content-Type'); const contentType = res.headers.get('Content-Type');

View File

@ -971,7 +971,7 @@ div.card-overlay {
// Page-specific styles. // Page-specific styles.
html { html {
// Shade the home page content background-color. // Shade the home page content background-color.
&[data-netbox-path='/'] { &[data-netbox-url-name='home'] {
.content-container, .content-container,
.search { .search {
background-color: $gray-100 !important; 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. // 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 { #django-messages {
display: none; display: none;
} }

View File

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

View File

@ -34,7 +34,7 @@
</div> </div>
</div> </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="noprint bulk-buttons">
<div class="bulk-button-group"> <div class="bulk-button-group">
{% if perms.dcim.change_interface %} {% if perms.dcim.change_interface %}

View File

@ -1,10 +1,10 @@
{% if perms.dcim.change_cable %} {% if perms.dcim.change_cable %}
{% if cable.status == 'connected' %} {% 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> <i class="mdi mdi-lan-disconnect" aria-hidden="true"></i>
</button> </button>
{% else %} {% 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> <i class="mdi mdi-lan-connect" aria-hidden="true"></i>
</button> </button>
{% endif %} {% endif %}

View File

@ -96,6 +96,6 @@
{% endblock %} {% endblock %}
{% block data %} {% 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> <span data-job-complete="{{ result.completed }}"></span>
{% endblock %} {% endblock %}

View File

@ -112,6 +112,6 @@
{% endblock content-wrapper %} {% endblock content-wrapper %}
{% block data %} {% 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> <span data-job-complete="{{ result.completed }}"></span>
{% endblock %} {% endblock %}

View File

@ -17,17 +17,32 @@
{% block content %} {% block content %}
<div class="row"> <div class="row">
<div class="col col-md-12 col-lg-10 offset-lg-1"> <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">
{% csrf_token %} <li class="nav-item" role="presentation">
{% render_form form %} <button class="nav-link active" role="tab" type="button" data-bs-target="#csv" data-bs-toggle="tab">CSV Data</button>
<div class="form-group"> </li>
<div class="col col-md-12 text-end"> <li class="nav-item" role="presentation">
{% if return_url %} <button class="nav-link" role="tab" type="button" data-bs-target="#csv-file" data-bs-toggle="tab">CSV File Upload</button>
<a href="{{ return_url }}" class="btn btn-outline-danger">Cancel</a> </li>
{% endif %} </ul>
<button type="submit" class="btn btn-primary">Submit</button> <form action="" method="post" enctype="multipart/form-data" class="form">
</div> {% csrf_token %}
<div class="tab-content border-0">
<div role="tabpanel" class="tab-pane active" id="csv">
{% render_field form.csv %}
</div> </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 %}
<a href="{{ return_url }}" class="btn btn-outline-danger">Cancel</a>
{% endif %}
<button type="submit" class="btn btn-primary">Submit</button>
</div>
</div>
</form> </form>
{% if fields %} {% if fields %}
<div class="row my-3"> <div class="row my-3">

View File

@ -86,18 +86,28 @@
</div> </div>
</div> </div>
{% elif field|widget_type == 'fileinput' or field|widget_type == 'clearablefileinput' %} {% elif field|widget_type == 'fileinput' %}
<div class="input-group mb-3"> <div class="input-group mb-3">
<input <input
class="form-control" class="form-control"
type="file" type="file"
name="{{ field.name }}" name="{{ field.name }}"
placeholder="{{ field.placeholder }}" placeholder="{{ field.placeholder }}"
id="id_{{ field.name }}" id="id_{{ field.name }}"
accept="{{ field.field.widget.attrs.accept }}" accept="{{ field.field.widget.attrs.accept }}"
{% if field.is_required %}required{% endif %} {% if field.is_required %}required{% endif %}
/> />
<label for="{{ field.id_for_label }}" class="input-group-text">{{ field.label|bettertitle }}</label> <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> </div>
{% elif field|widget_type == 'selectmultiple' %} {% elif field|widget_type == 'selectmultiple' %}

View File

@ -7,7 +7,7 @@
<h5 class="modal-title">Table Configuration</h5> <h5 class="modal-title">Table Configuration</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </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="modal-body row">
<div class="col-5 text-center"> <div class="col-5 text-center">
{{ form.available_columns.label }} {{ form.available_columns.label }}

View File

@ -376,7 +376,7 @@ class DynamicModelChoiceMixin:
widget = widgets.APISelect widget = widgets.APISelect
def __init__(self, query_params=None, initial_params=None, null_option=None, disabled_indicator=None, fetch_trigger=None, 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.query_params = query_params or {}
self.initial_params = initial_params or {} self.initial_params = initial_params or {}
self.null_option = null_option 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 # to_field_name is set by ModelChoiceField.__init__(), but we need to set it early for reference
# by widget_attrs() # by widget_attrs()
self.to_field_name = kwargs.get('to_field_name') self.to_field_name = kwargs.get('to_field_name')
self.empty_option = empty_label or ""
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
def widget_attrs(self, widget): def widget_attrs(self, widget):
attrs = {} attrs = {
'data-empty-option': self.empty_option
}
# Set value-field attribute if the field specifies to_field_name # Set value-field attribute if the field specifies to_field_name
if self.to_field_name: if self.to_field_name:
@ -474,3 +477,13 @@ class DynamicModelMultipleChoiceField(DynamicModelChoiceMixin, forms.ModelMultip
""" """
filter = django_filters.ModelMultipleChoiceFilter filter = django_filters.ModelMultipleChoiceFilter
widget = widgets.APISelectMultiple 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 import yaml
from django import forms from django import forms
from .widgets import APISelect, APISelectMultiple, StaticSelect from .widgets import APISelect, APISelectMultiple, ClearableFileInput, StaticSelect
__all__ = ( __all__ = (
@ -29,12 +29,12 @@ class BootstrapMixin(forms.BaseForm):
exempt_widgets = [ exempt_widgets = [
forms.CheckboxInput, forms.CheckboxInput,
forms.ClearableFileInput,
forms.FileInput, forms.FileInput,
forms.RadioSelect, forms.RadioSelect,
forms.Select, forms.Select,
APISelect, APISelect,
APISelectMultiple, APISelectMultiple,
ClearableFileInput,
StaticSelect, StaticSelect,
] ]

View File

@ -1,6 +1,7 @@
import re import re
from django import forms from django import forms
from django.conf import settings
from django.forms.models import fields_for_model from django.forms.models import fields_for_model
from utilities.choices import unpack_grouped_choices from utilities.choices import unpack_grouped_choices
@ -120,13 +121,20 @@ def get_selected_values(form, field_name):
if not hasattr(form, 'cleaned_data'): if not hasattr(form, 'cleaned_data'):
form.is_valid() form.is_valid()
filter_data = form.cleaned_data.get(field_name) filter_data = form.cleaned_data.get(field_name)
field = form.fields[field_name]
# Selection field # Selection field
if hasattr(form.fields[field_name], 'choices'): if hasattr(field, 'choices'):
try: 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 [ 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: except TypeError:
# Field uses dynamic choices. Show all that have been populated. # Field uses dynamic choices. Show all that have been populated.

View File

@ -12,6 +12,7 @@ __all__ = (
'APISelect', 'APISelect',
'APISelectMultiple', 'APISelectMultiple',
'BulkEditNullBooleanSelect', 'BulkEditNullBooleanSelect',
'ClearableFileInput',
'ColorSelect', 'ColorSelect',
'ContentTypeSelect', 'ContentTypeSelect',
'DatePicker', 'DatePicker',
@ -135,6 +136,13 @@ class NumericArrayField(SimpleArrayField):
return super().to_python(value) 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): class APISelect(SelectWithDisabled):
""" """
A select widget populated via an API call A select widget populated via an API call
@ -155,6 +163,13 @@ class APISelect(SelectWithDisabled):
if api_url: if api_url:
self.attrs['data-url'] = '/{}{}'.format(settings.BASE_PATH, api_url.lstrip('/')) # Inject BASE_PATH 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: 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. 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): 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 # Create the test user and assign permissions
self.user = User.objects.create_user(username='testuser') self.user = User.objects.create_user(username='testuser')
self.add_permissions(*self.user_permissions) self.add_permissions(*self.user_permissions)
self.token = Token.objects.create(user=self.user) 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): def _get_view_namespace(self):
return f'{self.view_namespace or self.model._meta.app_label}-api' return f'{self.view_namespace or self.model._meta.app_label}-api'

View File

@ -1,7 +1,8 @@
import urllib.parse import urllib.parse
from django.conf import settings
from django.contrib.contenttypes.models import ContentType 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 django.urls import reverse
from rest_framework import status from rest_framework import status
@ -122,6 +123,59 @@ class WritableNestedSerializerTest(APITestCase):
self.assertEqual(VLAN.objects.count(), 0) 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): class APIDocsTestCase(TestCase):
def setUp(self): def setUp(self):

View File

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