mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-23 17:08:41 -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
|
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
|
||||||
|
8
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
8
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
@ -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
|
||||||
|
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
|
- 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
5
.gitignore
vendored
@ -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/*
|
||||||
|
@ -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).
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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/).
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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/
|
||||||
|
@ -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.
|
||||||
|
@ -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
|
||||||
|
@ -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',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -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
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
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>
|
<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 = """
|
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'),
|
||||||
|
@ -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):
|
||||||
|
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
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
|
* @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();
|
||||||
|
@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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);
|
||||||
|
@ -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);
|
||||||
|
}
|
||||||
|
@ -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();
|
||||||
|
@ -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');
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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"
|
||||||
|
@ -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 %}
|
||||||
|
@ -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 %}
|
||||||
|
@ -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 %}
|
||||||
|
@ -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 %}
|
||||||
|
@ -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">
|
||||||
|
@ -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' %}
|
||||||
|
@ -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 }}
|
||||||
|
@ -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)
|
||||||
|
@ -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,
|
||||||
]
|
]
|
||||||
|
|
||||||
|
@ -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.
|
||||||
|
@ -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.
|
||||||
|
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):
|
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'
|
||||||
|
@ -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):
|
||||||
|
@ -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
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