mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-23 04:22:01 -06:00
commit
b55c85b2af
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@ -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
|
||||
|
8
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
8
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
@ -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
|
||||
|
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
@ -58,6 +58,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
5
.gitignore
vendored
@ -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/*
|
||||
|
@ -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).
|
||||
|
@ -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
|
||||
|
||||
|
@ -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/).
|
||||
|
||||
|
@ -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
|
||||
|
@ -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/
|
||||
|
@ -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.
|
||||
|
@ -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
|
||||
|
@ -29,7 +29,7 @@ class CircuitStatusChoices(ChoiceSet):
|
||||
STATUS_PLANNED: 'info',
|
||||
STATUS_PROVISIONING: 'primary',
|
||||
STATUS_OFFLINE: 'danger',
|
||||
STATUS_DECOMMISSIONED: 'default',
|
||||
STATUS_DECOMMISSIONED: 'secondary',
|
||||
}
|
||||
|
||||
|
||||
|
@ -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
|
||||
})
|
||||
}
|
||||
|
26
netbox/extras/migrations/0062_clear_secrets_changelog.py
Normal file
26
netbox/extras/migrations/0062_clear_secrets_changelog.py
Normal 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
|
||||
),
|
||||
]
|
@ -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 %}
|
||||
—
|
||||
{% 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'),
|
||||
|
@ -34,13 +34,22 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination):
|
||||
return list(queryset[self.offset:])
|
||||
|
||||
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
|
||||
if settings.MAX_PAGE_SIZE:
|
||||
limit = min(limit, settings.MAX_PAGE_SIZE)
|
||||
|
||||
return limit
|
||||
return self.default_limit
|
||||
|
||||
def get_next_link(self):
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
BIN
netbox/project-static/dist/config.js
vendored
BIN
netbox/project-static/dist/config.js
vendored
Binary file not shown.
BIN
netbox/project-static/dist/config.js.map
vendored
BIN
netbox/project-static/dist/config.js.map
vendored
Binary file not shown.
BIN
netbox/project-static/dist/jobs.js
vendored
BIN
netbox/project-static/dist/jobs.js
vendored
Binary file not shown.
BIN
netbox/project-static/dist/jobs.js.map
vendored
BIN
netbox/project-static/dist/jobs.js.map
vendored
Binary file not shown.
BIN
netbox/project-static/dist/lldp.js
vendored
BIN
netbox/project-static/dist/lldp.js
vendored
Binary file not shown.
BIN
netbox/project-static/dist/lldp.js.map
vendored
BIN
netbox/project-static/dist/lldp.js.map
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox-dark.css
vendored
BIN
netbox/project-static/dist/netbox-dark.css
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox-light.css
vendored
BIN
netbox/project-static/dist/netbox-light.css
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox-print.css
vendored
BIN
netbox/project-static/dist/netbox-print.css
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox.js
vendored
BIN
netbox/project-static/dist/netbox.js
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox.js.map
vendored
BIN
netbox/project-static/dist/netbox.js.map
vendored
Binary file not shown.
BIN
netbox/project-static/dist/status.js
vendored
BIN
netbox/project-static/dist/status.js
vendored
Binary file not shown.
BIN
netbox/project-static/dist/status.js.map
vendored
BIN
netbox/project-static/dist/status.js.map
vendored
Binary file not shown.
@ -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();
|
||||
|
@ -1,8 +1,21 @@
|
||||
import { getElements, toggleVisibility } from '../util';
|
||||
|
||||
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.
|
||||
*/
|
||||
const showHideMap: ShowHideMap = {
|
||||
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'],
|
||||
show: ['id_sitegroup'],
|
||||
},
|
||||
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'],
|
||||
show: ['id_region', 'id_sitegroup', 'id_site', 'id_location'],
|
||||
},
|
||||
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'],
|
||||
show: ['id_clustergroup'],
|
||||
},
|
||||
cluster: {
|
||||
hide: ['id_region', 'id_sitegroup', 'id_site', 'id_location', 'id_rack'],
|
||||
show: ['id_clustergroup', 'id_cluster'],
|
||||
},
|
||||
default: {
|
||||
hide: [
|
||||
'id_region',
|
||||
'id_sitegroup',
|
||||
'id_site',
|
||||
'id_location',
|
||||
'id_rack',
|
||||
'id_clustergroup',
|
||||
'id_cluster',
|
||||
],
|
||||
show: [],
|
||||
vlangroup_edit: {
|
||||
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'],
|
||||
show: ['id_sitegroup'],
|
||||
},
|
||||
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'],
|
||||
show: ['id_region', 'id_sitegroup', 'id_site', 'id_location'],
|
||||
},
|
||||
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'],
|
||||
show: ['id_clustergroup'],
|
||||
},
|
||||
cluster: {
|
||||
hide: ['id_region', 'id_sitegroup', 'id_site', 'id_location', 'id_rack'],
|
||||
show: ['id_clustergroup', 'id_cluster'],
|
||||
},
|
||||
default: {
|
||||
hide: [
|
||||
'id_region',
|
||||
'id_sitegroup',
|
||||
'id_site',
|
||||
'id_location',
|
||||
'id_rack',
|
||||
'id_clustergroup',
|
||||
'id_cluster',
|
||||
],
|
||||
show: [],
|
||||
},
|
||||
},
|
||||
};
|
||||
/**
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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);
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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();
|
||||
|
@ -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');
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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"
|
||||
|
@ -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 %}
|
||||
|
@ -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 %}
|
||||
|
@ -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 %}
|
||||
|
@ -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 %}
|
||||
|
@ -17,17 +17,32 @@
|
||||
{% block content %}
|
||||
<div class="row">
|
||||
<div class="col col-md-12 col-lg-10 offset-lg-1">
|
||||
<form action="" method="post" class="form">
|
||||
{% csrf_token %}
|
||||
{% render_form form %}
|
||||
<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>
|
||||
<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 %}
|
||||
<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 %}
|
||||
<a href="{{ return_url }}" class="btn btn-outline-danger">Cancel</a>
|
||||
{% endif %}
|
||||
<button type="submit" class="btn btn-primary">Submit</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{% if fields %}
|
||||
<div class="row my-3">
|
||||
|
@ -86,18 +86,28 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% elif field|widget_type == 'fileinput' or field|widget_type == 'clearablefileinput' %}
|
||||
<div class="input-group mb-3">
|
||||
<input
|
||||
class="form-control"
|
||||
type="file"
|
||||
name="{{ field.name }}"
|
||||
placeholder="{{ field.placeholder }}"
|
||||
id="id_{{ field.name }}"
|
||||
accept="{{ field.field.widget.attrs.accept }}"
|
||||
{% if field.is_required %}required{% endif %}
|
||||
/>
|
||||
<label for="{{ field.id_for_label }}" class="input-group-text">{{ field.label|bettertitle }}</label>
|
||||
{% elif field|widget_type == 'fileinput' %}
|
||||
<div class="input-group mb-3">
|
||||
<input
|
||||
class="form-control"
|
||||
type="file"
|
||||
name="{{ field.name }}"
|
||||
placeholder="{{ field.placeholder }}"
|
||||
id="id_{{ field.name }}"
|
||||
accept="{{ field.field.widget.attrs.accept }}"
|
||||
{% if field.is_required %}required{% endif %}
|
||||
/>
|
||||
<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' %}
|
||||
|
@ -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 }}
|
||||
|
@ -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)
|
||||
|
@ -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,
|
||||
]
|
||||
|
||||
|
@ -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.
|
||||
|
@ -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.
|
||||
|
24
netbox/utilities/templates/widgets/clearable_file_input.html
Normal file
24
netbox/utilities/templates/widgets/clearable_file_input.html
Normal 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>
|
@ -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'
|
||||
|
@ -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):
|
||||
|
@ -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
41
scripts/verify-bundles.sh
Executable 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
|
Loading…
Reference in New Issue
Block a user