Просмотр исходного кода

Merge pull request #7220 from netbox-community/develop

Release v3.0.2
Jeremy Stretch 4 лет назад
Родитель
Сommit
b55c85b2af
56 измененных файлов с 501 добавлено и 231 удалено
  1. 1 1
      .github/ISSUE_TEMPLATE/bug_report.yaml
  2. 5 3
      .github/ISSUE_TEMPLATE/feature_request.yaml
  3. 3 0
      .github/workflows/ci.yml
  4. 1 4
      .gitignore
  5. 1 1
      docs/additional-features/prometheus-metrics.md
  6. 2 2
      docs/development/extending-models.md
  7. 1 1
      docs/graphql-api/overview.md
  8. 10 7
      docs/installation/3-netbox.md
  9. 1 1
      docs/installation/4-gunicorn.md
  10. 1 1
      docs/models/dcim/platform.md
  11. 21 0
      docs/release-notes/version-3.0.md
  12. 1 1
      netbox/circuits/choices.py
  13. 6 6
      netbox/dcim/forms.py
  14. 26 0
      netbox/extras/migrations/0062_clear_secrets_changelog.py
  15. 13 4
      netbox/ipam/tables.py
  16. 16 7
      netbox/netbox/api/pagination.py
  17. 2 1
      netbox/netbox/settings.py
  18. 1 2
      netbox/netbox/views/generic.py
  19. 0 0
      netbox/project-static/dist/config.js
  20. 0 0
      netbox/project-static/dist/config.js.map
  21. 0 0
      netbox/project-static/dist/jobs.js
  22. 0 0
      netbox/project-static/dist/jobs.js.map
  23. 0 0
      netbox/project-static/dist/lldp.js
  24. 0 0
      netbox/project-static/dist/lldp.js.map
  25. 0 0
      netbox/project-static/dist/netbox-dark.css
  26. 0 0
      netbox/project-static/dist/netbox-light.css
  27. 0 0
      netbox/project-static/dist/netbox-print.css
  28. 0 0
      netbox/project-static/dist/netbox.js
  29. 0 0
      netbox/project-static/dist/netbox.js.map
  30. 0 0
      netbox/project-static/dist/status.js
  31. 0 0
      netbox/project-static/dist/status.js.map
  32. 3 3
      netbox/project-static/src/buttons/connectionToggle.ts
  33. 66 47
      netbox/project-static/src/forms/scopeSelector.ts
  34. 12 11
      netbox/project-static/src/jobs.ts
  35. 57 25
      netbox/project-static/src/select/api/apiSelect.ts
  36. 10 0
      netbox/project-static/src/select/api/types.ts
  37. 15 3
      netbox/project-static/src/tableConfig.ts
  38. 1 55
      netbox/project-static/src/util.ts
  39. 2 2
      netbox/project-static/styles/netbox.scss
  40. 1 1
      netbox/templates/base/base.html
  41. 1 1
      netbox/templates/dcim/device/interfaces.html
  42. 2 2
      netbox/templates/dcim/inc/cable_toggle_buttons.html
  43. 1 1
      netbox/templates/extras/report_result.html
  44. 1 1
      netbox/templates/extras/script_result.html
  45. 25 10
      netbox/templates/generic/object_bulk_import.html
  46. 22 12
      netbox/templates/utilities/render_field.html
  47. 1 1
      netbox/templates/utilities/templatetags/table_config_form.html
  48. 15 2
      netbox/utilities/forms/fields.py
  49. 2 2
      netbox/utilities/forms/forms.py
  50. 12 4
      netbox/utilities/forms/utils.py
  51. 15 0
      netbox/utilities/forms/widgets.py
  52. 24 0
      netbox/utilities/templates/widgets/clearable_file_input.html
  53. 2 2
      netbox/utilities/testing/api.py
  54. 55 1
      netbox/utilities/tests/test_api.py
  55. 3 3
      requirements.txt
  56. 41 0
      scripts/verify-bundles.sh

+ 1 - 1
.github/ISSUE_TEMPLATE/bug_report.yaml

@@ -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

+ 5 - 3
.github/ISSUE_TEMPLATE/feature_request.yaml

@@ -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 - 0
.github/workflows/ci.yml

@@ -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/

+ 1 - 4
.gitignore

@@ -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/*

+ 1 - 1
docs/additional-features/prometheus-metrics.md

@@ -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).

+ 2 - 2
docs/development/extending-models.md

@@ -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
 

+ 1 - 1
docs/graphql-api/overview.md

@@ -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/).
 

+ 10 - 7
docs/installation/3-netbox.md

@@ -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

+ 1 - 1
docs/installation/4-gunicorn.md

@@ -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/

+ 1 - 1
docs/models/dcim/platform.md

@@ -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.

+ 21 - 0
docs/release-notes/version-3.0.md

@@ -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

+ 1 - 1
netbox/circuits/choices.py

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

+ 6 - 6
netbox/dcim/forms.py

@@ -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 - 0
netbox/extras/migrations/0062_clear_secrets_changelog.py

@@ -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
+        ),
+    ]

+ 13 - 4
netbox/ipam/tables.py

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

+ 16 - 7
netbox/netbox/api/pagination.py

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

+ 2 - 1
netbox/netbox/settings.py

@@ -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

+ 1 - 2
netbox/netbox/views/generic.py

@@ -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

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/config.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/config.js.map


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/jobs.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/jobs.js.map


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/lldp.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/lldp.js.map


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox-dark.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox-light.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox-print.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox.js.map


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/status.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/status.js.map


+ 3 - 3
netbox/project-static/src/buttons/connectionToggle.ts

@@ -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();

+ 66 - 47
netbox/project-static/src/forms/scopeSelector.ts

@@ -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));
+    }
   }
 }

+ 12 - 11
netbox/project-static/src/jobs.ts

@@ -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));
   }
 }
 

+ 57 - 25
netbox/project-static/src/select/api/apiSelect.ts

@@ -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) {
-      // If there is no placeholder, add one to the front of the array.
-      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);
     }
-
-    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);

+ 10 - 0
netbox/project-static/src/select/api/types.ts

@@ -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);
+}

+ 15 - 3
netbox/project-static/src/tableConfig.ts

@@ -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 - 55
netbox/project-static/src/util.ts

@@ -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');

+ 2 - 2
netbox/project-static/styles/netbox.scss

@@ -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;
     }

+ 1 - 1
netbox/templates/base/base.html

@@ -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"

+ 1 - 1
netbox/templates/dcim/device/interfaces.html

@@ -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 %}

+ 2 - 2
netbox/templates/dcim/inc/cable_toggle_buttons.html

@@ -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 %}

+ 1 - 1
netbox/templates/extras/report_result.html

@@ -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 %}

+ 1 - 1
netbox/templates/extras/script_result.html

@@ -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 %}

+ 25 - 10
netbox/templates/generic/object_bulk_import.html

@@ -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">

+ 22 - 12
netbox/templates/utilities/render_field.html

@@ -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' %}

+ 1 - 1
netbox/templates/utilities/templatetags/table_config_form.html

@@ -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 }}

+ 15 - 2
netbox/utilities/forms/fields.py

@@ -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)

+ 2 - 2
netbox/utilities/forms/forms.py

@@ -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,
         ]
 

+ 12 - 4
netbox/utilities/forms/utils.py

@@ -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.

+ 15 - 0
netbox/utilities/forms/widgets.py

@@ -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 - 0
netbox/utilities/templates/widgets/clearable_file_input.html

@@ -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>

+ 2 - 2
netbox/utilities/testing/api.py

@@ -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'

+ 55 - 1
netbox/utilities/tests/test_api.py

@@ -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 - 3
requirements.txt

@@ -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 - 0
scripts/verify-bundles.sh

@@ -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

Некоторые файлы не были показаны из-за большого количества измененных файлов