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

Merge branch 'feature' into 9856-strawberry-2

Arthur 2 лет назад
Родитель
Сommit
a171a02844
63 измененных файлов с 1337 добавлено и 3000 удалено
  1. 76 7
      docs/customization/custom-scripts.md
  2. 36 140
      docs/customization/reports.md
  3. 6 0
      docs/release-notes/version-4.0.md
  4. 1 0
      mkdocs.yml
  5. 1 4
      netbox/core/forms/filtersets.py
  6. 0 12
      netbox/dcim/filtersets.py
  7. 6 14
      netbox/dcim/forms/filtersets.py
  8. 0 18
      netbox/extras/api/serializers.py
  9. 0 1
      netbox/extras/api/urls.py
  10. 0 106
      netbox/extras/api/views.py
  11. 13 0
      netbox/extras/choices.py
  12. 3 10
      netbox/extras/forms/filtersets.py
  13. 0 65
      netbox/extras/management/commands/runreport.py
  14. 12 3
      netbox/extras/management/commands/runscript.py
  15. 31 0
      netbox/extras/migrations/0107_convert_reports_to_scripts.py
  16. 0 1
      netbox/extras/models/__init__.py
  17. 0 80
      netbox/extras/models/reports.py
  18. 3 1
      netbox/extras/models/scripts.py
  19. 16 231
      netbox/extras/reports.py
  20. 166 32
      netbox/extras/scripts.py
  21. 0 31
      netbox/extras/tests/test_api.py
  22. 0 9
      netbox/extras/urls.py
  23. 3 2
      netbox/extras/utils.py
  24. 17 188
      netbox/extras/views.py
  25. 1 7
      netbox/netbox/navigation/menu.py
  26. 0 0
      netbox/project-static/dist/netbox.css
  27. 0 0
      netbox/project-static/dist/netbox.js
  28. 0 0
      netbox/project-static/dist/netbox.js.map
  29. 30 0
      netbox/project-static/package-lock.json
  30. 2 2
      netbox/project-static/package.json
  31. 2 2
      netbox/project-static/src/htmx.ts
  32. 1 0
      netbox/project-static/src/index.ts
  33. 0 15
      netbox/project-static/src/links.ts
  34. 2 4
      netbox/project-static/src/netbox.ts
  35. 0 1002
      netbox/project-static/src/select/api/apiSelect.ts
  36. 0 10
      netbox/project-static/src/select/api/index.ts
  37. 0 199
      netbox/project-static/src/select/api/types.ts
  38. 2 2
      netbox/project-static/src/select/classes/dynamicParamsMap.ts
  39. 305 0
      netbox/project-static/src/select/classes/dynamicTomSelect.ts
  40. 0 82
      netbox/project-static/src/select/color.ts
  41. 9 0
      netbox/project-static/src/select/config.ts
  42. 51 0
      netbox/project-static/src/select/dynamic.ts
  43. 6 7
      netbox/project-static/src/select/index.ts
  44. 25 22
      netbox/project-static/src/select/static.ts
  45. 66 0
      netbox/project-static/src/select/types.ts
  46. 0 26
      netbox/project-static/src/select/util.ts
  47. 2 2
      netbox/project-static/styles/netbox.scss
  48. 0 195
      netbox/project-static/styles/overrides/_slim-select.scss
  49. 2 1
      netbox/project-static/tsconfig.json
  50. 237 65
      netbox/project-static/yarn.lock
  51. 1 1
      netbox/templates/django/forms/widgets/select.html
  52. 0 77
      netbox/templates/extras/htmx/report_result.html
  53. 103 33
      netbox/templates/extras/htmx/script_result.html
  54. 0 43
      netbox/templates/extras/report.html
  55. 0 128
      netbox/templates/extras/report_list.html
  56. 0 17
      netbox/templates/extras/report_result.html
  57. 93 52
      netbox/templates/extras/script_list.html
  58. 2 4
      netbox/users/forms/model_forms.py
  59. 0 16
      netbox/utilities/fields.py
  60. 1 21
      netbox/utilities/forms/fields/dynamic.py
  61. 2 6
      netbox/utilities/forms/widgets/apiselect.py
  62. 1 2
      netbox/utilities/forms/widgets/select.py
  63. 1 2
      netbox/vpn/forms/model_forms.py

+ 76 - 7
docs/customization/custom-scripts.md

@@ -5,8 +5,17 @@ Custom scripting was introduced to provide a way for users to execute custom log
 * Automatically populate new devices and cables in preparation for a new site deployment
 * Create a range of new reserved prefixes or IP addresses
 * Fetch data from an external source and import it to NetBox
+* Update objects with invalid or incomplete data
 
-Custom scripts are Python code and exist outside of the official NetBox code base, so they can be updated and changed without interfering with the core NetBox installation. And because they're completely custom, there is no inherent limitation on what a script can accomplish.
+They can also be used as a mechanism for validating the integrity of data within NetBox. Script authors can define test to check object against specific rules and conditions. For example, you can write script to check that:
+
+* All top-of-rack switches have a console connection
+* Every router has a loopback interface with an IP address assigned
+* Each interface description conforms to a standard format
+* Every site has a minimum set of VLANs defined
+* All IP addresses have a parent prefix
+
+Custom scripts are Python code which exists outside the NetBox code base, so they can be updated and changed without interfering with the core NetBox installation. And because they're completely custom, there is no inherent limitation on what a script can accomplish.
 
 ## Writing Custom Scripts
 
@@ -135,13 +144,73 @@ These two methods will load data in YAML or JSON format, respectively, from file
 
 The Script object provides a set of convenient functions for recording messages at different severity levels:
 
-* `log_debug`
-* `log_success`
-* `log_info`
-* `log_warning`
-* `log_failure`
+* `log_debug(message, object=None)`
+* `log_success(message, object=None)`
+* `log_info(message, object=None)`
+* `log_warning(message, object=None)`
+* `log_failure(message, object=None)`
+
+Log messages are returned to the user upon execution of the script. Markdown rendering is supported for log messages. A message may optionally be associated with a particular object by passing it as the second argument to the logging method.
+
+## Test Methods
+
+A script can define one or more test methods to report on certain conditions. All test methods must have a name beginning with `test_` and accept no arguments beyond `self`.
+
+These methods are detected and run automatically when the script is executed, unless its `run()` method has been overridden. (When overriding `run()`, `run_tests()` can be called to run all test methods present in the script.)
+
+!!! info
+    This functionality was ported from [legacy reports](./reports.md) in NetBox v4.0.
 
-Log messages are returned to the user upon execution of the script. Markdown rendering is supported for log messages.
+### Example
+
+```
+from dcim.choices import DeviceStatusChoices
+from dcim.models import ConsolePort, Device, PowerPort
+from extras.scripts import Script
+
+
+class DeviceConnectionsReport(Script):
+    description = "Validate the minimum physical connections for each device"
+
+    def test_console_connection(self):
+
+        # Check that every console port for every active device has a connection defined.
+        active = DeviceStatusChoices.STATUS_ACTIVE
+        for console_port in ConsolePort.objects.prefetch_related('device').filter(device__status=active):
+            if not console_port.connected_endpoints:
+                self.log_failure(
+                    f"No console connection defined for {console_port.name}",
+                    console_port.device,
+                )
+            elif not console_port.connection_status:
+                self.log_warning(
+                    f"Console connection for {console_port.name} marked as planned",
+                    console_port.device,
+                )
+            else:
+                self.log_success("Passed", console_port.device)
+
+    def test_power_connections(self):
+
+        # Check that every active device has at least two connected power supplies.
+        for device in Device.objects.filter(status=DeviceStatusChoices.STATUS_ACTIVE):
+            connected_ports = 0
+            for power_port in PowerPort.objects.filter(device=device):
+                if power_port.connected_endpoints:
+                    connected_ports += 1
+                    if not power_port.path.is_active:
+                        self.log_warning(
+                            f"Power connection for {power_port.name} marked as planned",
+                            device,
+                        )
+            if connected_ports < 2:
+                self.log_failure(
+                    f"{connected_ports} connected power supplies found (2 needed)",
+                    device,
+                )
+            else:
+                self.log_success("Passed", device)
+```
 
 ## Change Logging
 

+ 36 - 140
docs/customization/reports.md

@@ -1,167 +1,63 @@
 # NetBox Reports
 
-A NetBox report is a mechanism for validating the integrity of data within NetBox. Running a report allows the user to verify that the objects defined within NetBox meet certain arbitrary conditions. For example, you can write reports to check that:
-
-* All top-of-rack switches have a console connection
-* Every router has a loopback interface with an IP address assigned
-* Each interface description conforms to a standard format
-* Every site has a minimum set of VLANs defined
-* All IP addresses have a parent prefix
-
-...and so on. Reports are completely customizable, so there's practically no limit to what you can test for.
-
-## Writing Reports
-
-Reports must be saved as files in the [`REPORTS_ROOT`](../configuration/system.md#reports_root) path (which defaults to `netbox/reports/`). Each file created within this path is considered a separate module. Each module holds one or more reports (Python classes), each of which performs a certain function. The logic of each report is broken into discrete test methods, each of which applies a small portion of the logic comprising the overall test.
-
 !!! warning
-    The reports path includes a file named `__init__.py`, which registers the path as a Python module. Do not delete this file.
+    Reports are deprecated beginning with NetBox v4.0, and their functionality has been merged with [custom scripts](./custom-scripts.md). While backward compatibility has been maintained, users are advised to convert legacy reports into custom scripts soon, as support for legacy reports will be removed in a future release.
 
-For example, we can create a module named `devices.py` to hold all of our reports which pertain to devices in NetBox. Within that module, we might define several reports. Each report is defined as a Python class inheriting from `extras.reports.Report`.
+## Converting Reports to Scripts
 
-```
-from extras.reports import Report
-
-class DeviceConnectionsReport(Report):
-    description = "Validate the minimum physical connections for each device"
-
-class DeviceIPsReport(Report):
-    description = "Check that every device has a primary IP address assigned"
-```
+### Step 1: Update Class Definition
 
-Within each report class, we'll create a number of test methods to execute our report's logic. In DeviceConnectionsReport, for instance, we want to ensure that every live device has a console connection, an out-of-band management connection, and two power connections.
+Change the parent class from `Report` to `Script`:
 
-```
-from dcim.choices import DeviceStatusChoices
-from dcim.models import ConsolePort, Device, PowerPort
+```python title="Old code"
 from extras.reports import Report
 
-
-class DeviceConnectionsReport(Report):
-    description = "Validate the minimum physical connections for each device"
-
-    def test_console_connection(self):
-
-        # Check that every console port for every active device has a connection defined.
-        active = DeviceStatusChoices.STATUS_ACTIVE
-        for console_port in ConsolePort.objects.prefetch_related('device').filter(device__status=active):
-            if not console_port.connected_endpoints:
-                self.log_failure(
-                    console_port.device,
-                    "No console connection defined for {}".format(console_port.name)
-                )
-            elif not console_port.connection_status:
-                self.log_warning(
-                    console_port.device,
-                    "Console connection for {} marked as planned".format(console_port.name)
-                )
-            else:
-                self.log_success(console_port.device)
-
-    def test_power_connections(self):
-
-        # Check that every active device has at least two connected power supplies.
-        for device in Device.objects.filter(status=DeviceStatusChoices.STATUS_ACTIVE):
-            connected_ports = 0
-            for power_port in PowerPort.objects.filter(device=device):
-                if power_port.connected_endpoints:
-                    connected_ports += 1
-                    if not power_port.path.is_active:
-                        self.log_warning(
-                            device,
-                            "Power connection for {} marked as planned".format(power_port.name)
-                        )
-            if connected_ports < 2:
-                self.log_failure(
-                    device,
-                    "{} connected power supplies found (2 needed)".format(connected_ports)
-                )
-            else:
-                self.log_success(device)
-```
-
-As you can see, reports are completely customizable. Validation logic can be as simple or as complex as needed. Also note that the `description` attribute support markdown syntax. It will be rendered in the report list page.
-
-!!! warning
-    Reports should never alter data: If you find yourself using the `create()`, `save()`, `update()`, or `delete()` methods on objects within reports, stop and re-evaluate what you're trying to accomplish. Note that there are no safeguards against the accidental alteration or destruction of data.
-
-## Report Attributes
-
-### `description`
-
-A human-friendly description of what your report does.
-
-### `scheduling_enabled`
-
-By default, a report can be scheduled for execution at a later time. Setting `scheduling_enabled` to False disables this ability: Only immediate execution will be possible. (This also disables the ability to set a recurring execution interval.)
-
-### `job_timeout`
-
-Set the maximum allowed runtime for the report. If not set, `RQ_DEFAULT_TIMEOUT` will be used.
-
-## Logging
-
-The following methods are available to log results within a report:
-
-* log(message)
-* log_success(object, message=None)
-* log_info(object, message)
-* log_warning(object, message)
-* log_failure(object, message)
-
-The recording of one or more failure messages will automatically flag a report as failed. It is advised to log a success for each object that is evaluated so that the results will reflect how many objects are being reported on. (The inclusion of a log message is optional for successes.) Messages recorded with `log()` will appear in a report's results but are not associated with a particular object or status. Log messages also support using markdown syntax and will be rendered on the report result page.
-
-To perform additional tasks, such as sending an email or calling a webhook, before or after a report is run, extend the `pre_run()` and/or `post_run()` methods, respectively.
-
-By default, reports within a module are ordered alphabetically in the reports list page. To return reports in a specific order, you can define the `report_order` variable at the end of your module. The `report_order` variable is a tuple which contains each Report class in the desired order. Any reports that are omitted from this list will be listed last.
-
+class MyReport(Report):
 ```
-from extras.reports import Report
-
-class DeviceConnectionsReport(Report)
-    pass
 
-class DeviceIPsReport(Report)
-    pass
+```python title="New code"
+from extras.scripts import Script
 
-report_order = (DeviceIPsReport, DeviceConnectionsReport)
+class MyReport(Script):
 ```
 
-Once you have created a report, it will appear in the reports list. Initially, reports will have no results associated with them. To generate results, run the report.
-
-## Running Reports
+### Step 2: Update Logging Calls
 
-!!! note
-    To run a report, a user must be assigned permissions for `Extras > Report`, `Extras > Report Module`, and `Core > Managed File` objects. They must also be assigned the `extras.run_report` permission. This is achieved by assigning the user (or group) a permission on the Report object and specifying the `run` action in "Permissions" as shown below.
+Reports and scripts both provide logging methods, however their signatures differ. All script logging methods accept a message as the first parameter, and accept an object as an optional second parameter.
 
-    ![Adding the run action to a permission](../media/run_permission.png)
+Additionally, the Report class' generic `log()` method is **not** available on Script. Users are advised to replace calls of this method with `log_info()`.
 
-### Via the Web UI
+Use the table below as a reference when updating these methods.
 
-Reports can be run via the web UI by navigating to the report and clicking the "run report" button at top right. Once a report has been run, its associated results will be included in the report view. It is possible to schedule a report to be executed at specified time in the future. A scheduled report can be canceled by deleting the associated job result object.
+| Report (old)                  | Script (New)                |
+|-------------------------------|-----------------------------|
+| `log(message)`                | `log_info(message)`         |
+| `log_debug(obj, message)`[^1] | `log_debug(message, obj)`   |
+| `log_info(obj, message)`      | `log_info(message, obj)`    |
+| `log_success(obj, message)`   | `log_success(message, obj)` |
+| `log_warning(obj, message)`   | `log_warning(message, obj)` |
+| `log_failure(obj, message)`   | `log_failure(message, obj)` |
 
-### Via the API
+[^1]: `log_debug()` was added to the Report class in v4.0 to avoid confusion with the same method on Script
 
-To run a report via the API, simply issue a POST request to its `run` endpoint. Reports are identified by their module and class name.
-
-```
-    POST /api/extras/reports/<module>.<name>/run/
+```python title="Old code"
+self.log_failure(
+    console_port.device,
+    f"No console connection defined for {console_port.name}"
+)
 ```
 
-Our example report above would be called as:
-
-```
-    POST /api/extras/reports/devices.DeviceConnectionsReport/run/
+```python title="New code"
+self.log_failure(
+    f"No console connection defined for {console_port.name}",
+    obj=console_port.device,
+)
 ```
 
-Optionally `schedule_at` can be passed in the form data with a datetime string to schedule a script at the specified date and time.
-
-### Via the CLI
+### Other Notes
 
-Reports can be run on the CLI by invoking the management command:
+Existing reports will be converted to scripts automatically upon upgrading to NetBox v4.0, and previous job history will be retained. However, users are advised to convert legacy reports into custom scripts at the earliest opportunity, as support for legacy reports will be removed in a future release.
 
-```
-python3 manage.py runreport <module>
-```
+The `pre_run()` and `post_run()` Report methods have been carried over to Script. These are called automatically by Script's `run()` method. (Note that if you opt to override this method, you are responsible for calling `pre_run()` and `post_run()` where applicable.)
 
-where ``<module>`` is the name of the python file in the ``reports`` directory without the ``.py`` extension.  One or more report modules may be specified.
+The `is_valid()` method on Report is no longer needed and has been removed.

+ 6 - 0
docs/release-notes/version-4.0.md

@@ -2,6 +2,10 @@
 
 ## v4.0.0 (FUTURE)
 
+### Breaking Changes
+
+* The deprecated `device_role` & `device_role_id` filters for devices have been removed. (Use `role` and `role_id` instead.)
+
 ### New Features
 
 #### Complete UI Refresh ([#12128](https://github.com/netbox-community/netbox/issues/12128))
@@ -26,3 +30,5 @@ The NetBox user interface has been completely refreshed and updated.
 * [#14657](https://github.com/netbox-community/netbox/issues/14657) - Remove backward compatibility for old permissions mapping under `ActionsMixin`
 * [#14658](https://github.com/netbox-community/netbox/issues/14658) - Remove backward compatibility for importing `process_webhook()` (now `extras.webhooks.send_webhook()`)
 * [#14740](https://github.com/netbox-community/netbox/issues/14740) - Remove the obsolete `BootstrapMixin` form mixin class
+* [#15099](https://github.com/netbox-community/netbox/issues/15099) - Remove obsolete `device_role` and `device_role_id` filters for devices
+* [#15100](https://github.com/netbox-community/netbox/issues/15100) - Remove obsolete `NullableCharField` class

+ 1 - 0
mkdocs.yml

@@ -52,6 +52,7 @@ extra_css:
 markdown_extensions:
     - admonition
     - attr_list
+    - footnotes
     - pymdownx.emoji:
         emoji_index: !!python/name:material.extensions.emoji.twemoji
         emoji_generator: !!python/name:material.extensions.emoji.to_svg

+ 1 - 4
netbox/core/forms/filtersets.py

@@ -119,10 +119,7 @@ class JobFilterForm(SavedFiltersMixin, FilterForm):
     user = DynamicModelMultipleChoiceField(
         queryset=get_user_model().objects.all(),
         required=False,
-        label=_('User'),
-        widget=APISelectMultiple(
-            api_url='/api/users/users/',
-        )
+        label=_('User')
     )
 
 

+ 0 - 12
netbox/dcim/filtersets.py

@@ -1288,18 +1288,6 @@ class DeviceComponentFilterSet(django_filters.FilterSet):
         to_field_name='name',
         label=_('Virtual Chassis'),
     )
-    # TODO: Remove in v4.0
-    device_role_id = django_filters.ModelMultipleChoiceFilter(
-        field_name='device__role',
-        queryset=DeviceRole.objects.all(),
-        label=_('Device role (ID)'),
-    )
-    device_role = django_filters.ModelMultipleChoiceFilter(
-        field_name='device__role__slug',
-        queryset=DeviceRole.objects.all(),
-        to_field_name='slug',
-        label=_('Device role (slug)'),
-    )
 
     def search(self, queryset, name, value):
         if not value.strip():

+ 6 - 14
netbox/dcim/forms/filtersets.py

@@ -393,10 +393,7 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
     user_id = DynamicModelMultipleChoiceField(
         queryset=get_user_model().objects.all(),
         required=False,
-        label=_('User'),
-        widget=APISelectMultiple(
-            api_url='/api/users/users/',
-        )
+        label=_('User')
     )
     tag = TagFilterField(model)
 
@@ -551,8 +548,7 @@ class ModuleTypeFilterForm(NetBoxModelFilterSetForm):
     manufacturer_id = DynamicModelMultipleChoiceField(
         queryset=Manufacturer.objects.all(),
         required=False,
-        label=_('Manufacturer'),
-        fetch_trigger='open'
+        label=_('Manufacturer')
     )
     part_number = forms.CharField(
         label=_('Part number'),
@@ -828,8 +824,7 @@ class VirtualDeviceContextFilterForm(
     device = DynamicModelMultipleChoiceField(
         queryset=Device.objects.all(),
         required=False,
-        label=_('Device'),
-        fetch_trigger='open'
+        label=_('Device')
     )
     status = forms.MultipleChoiceField(
         label=_('Status'),
@@ -855,8 +850,7 @@ class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxMo
     manufacturer_id = DynamicModelMultipleChoiceField(
         queryset=Manufacturer.objects.all(),
         required=False,
-        label=_('Manufacturer'),
-        fetch_trigger='open'
+        label=_('Manufacturer')
     )
     module_type_id = DynamicModelMultipleChoiceField(
         queryset=ModuleType.objects.all(),
@@ -864,8 +858,7 @@ class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, NetBoxMo
         query_params={
             'manufacturer_id': '$manufacturer_id'
         },
-        label=_('Type'),
-        fetch_trigger='open'
+        label=_('Type')
     )
     status = forms.MultipleChoiceField(
         label=_('Status'),
@@ -1414,8 +1407,7 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
     role_id = DynamicModelMultipleChoiceField(
         queryset=InventoryItemRole.objects.all(),
         required=False,
-        label=_('Role'),
-        fetch_trigger='open'
+        label=_('Role')
     )
     manufacturer_id = DynamicModelMultipleChoiceField(
         queryset=Manufacturer.objects.all(),

+ 0 - 18
netbox/extras/api/serializers.py

@@ -50,8 +50,6 @@ __all__ = (
     'SavedFilterSerializer',
     'ScriptDetailSerializer',
     'ScriptInputSerializer',
-    'ScriptLogMessageSerializer',
-    'ScriptOutputSerializer',
     'ScriptSerializer',
     'TagSerializer',
     'WebhookSerializer',
@@ -604,22 +602,6 @@ class ScriptInputSerializer(serializers.Serializer):
         return value
 
 
-class ScriptLogMessageSerializer(serializers.Serializer):
-    status = serializers.SerializerMethodField(read_only=True)
-    message = serializers.SerializerMethodField(read_only=True)
-
-    def get_status(self, instance):
-        return instance[0]
-
-    def get_message(self, instance):
-        return instance[1]
-
-
-class ScriptOutputSerializer(serializers.Serializer):
-    log = ScriptLogMessageSerializer(many=True, read_only=True)
-    output = serializers.CharField(read_only=True)
-
-
 #
 # Change logging
 #

+ 0 - 1
netbox/extras/api/urls.py

@@ -20,7 +20,6 @@ router.register('image-attachments', views.ImageAttachmentViewSet)
 router.register('journal-entries', views.JournalEntryViewSet)
 router.register('config-contexts', views.ConfigContextViewSet)
 router.register('config-templates', views.ConfigTemplateViewSet)
-router.register('reports', views.ReportViewSet, basename='report')
 router.register('scripts', views.ScriptViewSet, basename='script')
 router.register('object-changes', views.ObjectChangeViewSet)
 router.register('content-types', views.ContentTypeViewSet)

+ 0 - 106
netbox/extras/api/views.py

@@ -16,7 +16,6 @@ from core.choices import JobStatusChoices
 from core.models import Job
 from extras import filtersets
 from extras.models import *
-from extras.reports import get_module_and_report, run_report
 from extras.scripts import get_module_and_script, run_script
 from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
 from netbox.api.features import SyncedDataMixin
@@ -211,111 +210,6 @@ class ConfigTemplateViewSet(SyncedDataMixin, ConfigTemplateRenderMixin, NetBoxMo
         return self.render_configtemplate(request, configtemplate, context)
 
 
-#
-# Reports
-#
-
-class ReportViewSet(ViewSet):
-    permission_classes = [IsAuthenticatedOrLoginNotRequired]
-    _ignore_model_permissions = True
-    schema = None
-    lookup_value_regex = '[^/]+'  # Allow dots
-
-    def _get_report(self, pk):
-        try:
-            module_name, report_name = pk.split('.', maxsplit=1)
-        except ValueError:
-            raise Http404
-
-        module, report = get_module_and_report(module_name, report_name)
-        if report is None:
-            raise Http404
-
-        return module, report
-
-    def list(self, request):
-        """
-        Compile all reports and their related results (if any). Result data is deferred in the list view.
-        """
-        results = {
-            job.name: job
-            for job in Job.objects.filter(
-                object_type=ContentType.objects.get(app_label='extras', model='reportmodule'),
-                status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
-            ).order_by('name', '-created').distinct('name').defer('data')
-        }
-
-        report_list = []
-        for report_module in ReportModule.objects.restrict(request.user):
-            report_list.extend([report() for report in report_module.reports.values()])
-
-        # Attach Job objects to each report (if any)
-        for report in report_list:
-            report.result = results.get(report.name, None)
-
-        serializer = serializers.ReportSerializer(report_list, many=True, context={
-            'request': request,
-        })
-
-        return Response({'count': len(report_list), 'results': serializer.data})
-
-    def retrieve(self, request, pk):
-        """
-        Retrieve a single Report identified as "<module>.<report>".
-        """
-        module, report = self._get_report(pk)
-
-        # Retrieve the Report and Job, if any.
-        object_type = ContentType.objects.get(app_label='extras', model='reportmodule')
-        report.result = Job.objects.filter(
-            object_type=object_type,
-            name=report.name,
-            status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
-        ).first()
-
-        serializer = serializers.ReportDetailSerializer(report, context={
-            'request': request
-        })
-
-        return Response(serializer.data)
-
-    @action(detail=True, methods=['post'])
-    def run(self, request, pk):
-        """
-        Run a Report identified as "<module>.<script>" and return the pending Job as the result
-        """
-        # Check that the user has permission to run reports.
-        if not request.user.has_perm('extras.run_report'):
-            raise PermissionDenied("This user does not have permission to run reports.")
-
-        # Check that at least one RQ worker is running
-        if not Worker.count(get_connection('default')):
-            raise RQWorkerNotRunningException()
-
-        # Retrieve and run the Report. This will create a new Job.
-        module, report_cls = self._get_report(pk)
-        report = report_cls
-        input_serializer = serializers.ReportInputSerializer(
-            data=request.data,
-            context={'report': report}
-        )
-
-        if input_serializer.is_valid():
-            report.result = Job.enqueue(
-                run_report,
-                instance=module,
-                name=report.class_name,
-                user=request.user,
-                job_timeout=report.job_timeout,
-                schedule_at=input_serializer.validated_data.get('schedule_at'),
-                interval=input_serializer.validated_data.get('interval')
-            )
-            serializer = serializers.ReportDetailSerializer(report, context={'request': request})
-
-            return Response(serializer.data)
-        return Response(input_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
-
-
 #
 # Scripts
 #

+ 13 - 0
netbox/extras/choices.py

@@ -1,3 +1,5 @@
+import logging
+
 from django.utils.translation import gettext_lazy as _
 
 from utilities.choices import ButtonColorChoices, ChoiceSet
@@ -164,6 +166,7 @@ class JournalEntryKindChoices(ChoiceSet):
 
 class LogLevelChoices(ChoiceSet):
 
+    LOG_DEBUG = 'debug'
     LOG_DEFAULT = 'default'
     LOG_SUCCESS = 'success'
     LOG_INFO = 'info'
@@ -171,6 +174,7 @@ class LogLevelChoices(ChoiceSet):
     LOG_FAILURE = 'failure'
 
     CHOICES = (
+        (LOG_DEBUG, _('Debug'), 'teal'),
         (LOG_DEFAULT, _('Default'), 'gray'),
         (LOG_SUCCESS, _('Success'), 'green'),
         (LOG_INFO, _('Info'), 'cyan'),
@@ -178,6 +182,15 @@ class LogLevelChoices(ChoiceSet):
         (LOG_FAILURE, _('Failure'), 'red'),
     )
 
+    SYSTEM_LEVELS = {
+        LOG_DEBUG: logging.DEBUG,
+        LOG_DEFAULT: logging.INFO,
+        LOG_SUCCESS: logging.INFO,
+        LOG_INFO: logging.INFO,
+        LOG_WARNING: logging.WARNING,
+        LOG_FAILURE: logging.ERROR,
+    }
+
 
 class DurationChoices(ChoiceSet):
 

+ 3 - 10
netbox/extras/forms/filtersets.py

@@ -381,8 +381,7 @@ class ConfigContextFilterForm(SavedFiltersMixin, FilterForm):
     cluster_type_id = DynamicModelMultipleChoiceField(
         queryset=ClusterType.objects.all(),
         required=False,
-        label=_('Cluster types'),
-        fetch_trigger='open'
+        label=_('Cluster types')
     )
     cluster_group_id = DynamicModelMultipleChoiceField(
         queryset=ClusterGroup.objects.all(),
@@ -462,10 +461,7 @@ class JournalEntryFilterForm(NetBoxModelFilterSetForm):
     created_by_id = DynamicModelMultipleChoiceField(
         queryset=get_user_model().objects.all(),
         required=False,
-        label=_('User'),
-        widget=APISelectMultiple(
-            api_url='/api/users/users/',
-        )
+        label=_('User')
     )
     assigned_object_type_id = DynamicModelMultipleChoiceField(
         queryset=ContentType.objects.all(),
@@ -508,10 +504,7 @@ class ObjectChangeFilterForm(SavedFiltersMixin, FilterForm):
     user_id = DynamicModelMultipleChoiceField(
         queryset=get_user_model().objects.all(),
         required=False,
-        label=_('User'),
-        widget=APISelectMultiple(
-            api_url='/api/users/users/',
-        )
+        label=_('User')
     )
     changed_object_type_id = DynamicModelMultipleChoiceField(
         queryset=ContentType.objects.all(),

+ 0 - 65
netbox/extras/management/commands/runreport.py

@@ -1,65 +0,0 @@
-import time
-
-from django.core.management.base import BaseCommand
-from django.utils import timezone
-
-from core.choices import JobStatusChoices
-from core.models import Job
-from extras.models import ReportModule
-from extras.reports import run_report
-
-
-class Command(BaseCommand):
-    help = "Run a report to validate data in NetBox"
-
-    def add_arguments(self, parser):
-        parser.add_argument('reports', nargs='+', help="Report(s) to run")
-
-    def handle(self, *args, **options):
-
-        for module in ReportModule.objects.all():
-            for report in module.reports.values():
-                if module.name in options['reports'] or report.full_name in options['reports']:
-
-                    # Run the report and create a new Job
-                    self.stdout.write(
-                        "[{:%H:%M:%S}] Running {}...".format(timezone.now(), report.full_name)
-                    )
-
-                    job = Job.enqueue(
-                        run_report,
-                        instance=module,
-                        name=report.class_name,
-                        job_timeout=report.job_timeout
-                    )
-
-                    # Wait on the job to finish
-                    while job.status not in JobStatusChoices.TERMINAL_STATE_CHOICES:
-                        time.sleep(1)
-                        job = Job.objects.get(pk=job.pk)
-
-                    # Report on success/failure
-                    if job.status == JobStatusChoices.STATUS_FAILED:
-                        status = self.style.ERROR('FAILED')
-                    elif job == JobStatusChoices.STATUS_ERRORED:
-                        status = self.style.ERROR('ERRORED')
-                    else:
-                        status = self.style.SUCCESS('SUCCESS')
-
-                    for test_name, attrs in job.data.items():
-                        self.stdout.write(
-                            "\t{}: {} success, {} info, {} warning, {} failure".format(
-                                test_name, attrs['success'], attrs['info'], attrs['warning'], attrs['failure']
-                            )
-                        )
-                    self.stdout.write(
-                        "[{:%H:%M:%S}] {}: {}".format(timezone.now(), report.full_name, status)
-                    )
-                    self.stdout.write(
-                        "[{:%H:%M:%S}] {}: Duration {}".format(timezone.now(), report.full_name, job.duration)
-                    )
-
-        # Wrap things up
-        self.stdout.write(
-            "[{:%H:%M:%S}] Finished".format(timezone.now())
-        )

+ 12 - 3
netbox/extras/management/commands/runscript.py

@@ -10,7 +10,6 @@ from django.db import transaction
 
 from core.choices import JobStatusChoices
 from core.models import Job
-from extras.api.serializers import ScriptOutputSerializer
 from extras.context_managers import event_tracking
 from extras.scripts import get_module_and_script
 from extras.signals import clear_events
@@ -34,6 +33,7 @@ class Command(BaseCommand):
         parser.add_argument('script', help="Script to run")
 
     def handle(self, *args, **options):
+
         def _run_script():
             """
             Core script execution task. We capture this within a subfunction to allow for conditionally wrapping it with
@@ -48,7 +48,7 @@ class Command(BaseCommand):
                 except AbortTransaction:
                     script.log_info("Database changes have been reverted automatically.")
                     clear_events.send(request)
-                job.data = ScriptOutputSerializer(script).data
+                job.data = script.get_job_data()
                 job.terminate()
             except Exception as e:
                 stacktrace = traceback.format_exc()
@@ -58,9 +58,17 @@ class Command(BaseCommand):
                 script.log_info("Database changes have been reverted due to error.")
                 logger.error(f"Exception raised during script execution: {e}")
                 clear_events.send(request)
-                job.data = ScriptOutputSerializer(script).data
+                job.data = script.get_job_data()
                 job.terminate(status=JobStatusChoices.STATUS_ERRORED, error=repr(e))
 
+            # Print any test method results
+            for test_name, attrs in job.data['tests'].items():
+                self.stdout.write(
+                    "\t{}: {} success, {} info, {} warning, {} failure".format(
+                        test_name, attrs['success'], attrs['info'], attrs['warning'], attrs['failure']
+                    )
+                )
+
             logger.info(f"Script completed in {job.duration}")
 
         User = get_user_model()
@@ -69,6 +77,7 @@ class Command(BaseCommand):
         script = options['script']
         loglevel = options['loglevel']
         commit = options['commit']
+
         try:
             data = json.loads(options['data'])
         except TypeError:

+ 31 - 0
netbox/extras/migrations/0107_convert_reports_to_scripts.py

@@ -0,0 +1,31 @@
+from django.db import migrations
+
+
+def convert_reportmodule_jobs(apps, schema_editor):
+    ContentType = apps.get_model('contenttypes', 'ContentType')
+    Job = apps.get_model('core', 'Job')
+
+    # Convert all ReportModule jobs to ScriptModule jobs
+    if reportmodule_ct := ContentType.objects.filter(app_label='extras', model='reportmodule').first():
+        scriptmodule_ct = ContentType.objects.get(app_label='extras', model='scriptmodule')
+        Job.objects.filter(object_type_id=reportmodule_ct.id).update(object_type_id=scriptmodule_ct.id)
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('extras', '0106_bookmark_user_cascade_deletion'),
+    ]
+
+    operations = [
+        migrations.RunPython(
+            code=convert_reportmodule_jobs,
+            reverse_code=migrations.RunPython.noop
+        ),
+        migrations.DeleteModel(
+            name='Report',
+        ),
+        migrations.DeleteModel(
+            name='ReportModule',
+        ),
+    ]

+ 0 - 1
netbox/extras/models/__init__.py

@@ -3,7 +3,6 @@ from .configs import *
 from .customfields import *
 from .dashboard import *
 from .models import *
-from .reports import *
 from .scripts import *
 from .search import *
 from .staging import *

+ 0 - 80
netbox/extras/models/reports.py

@@ -1,80 +0,0 @@
-import inspect
-import logging
-from functools import cached_property
-
-from django.db import models
-from django.urls import reverse
-from django.utils.translation import gettext_lazy as _
-
-from core.choices import ManagedFileRootPathChoices
-from core.models import ManagedFile
-from extras.utils import is_report
-from netbox.models.features import JobsMixin, EventRulesMixin
-from utilities.querysets import RestrictedQuerySet
-from .mixins import PythonModuleMixin
-
-logger = logging.getLogger('netbox.reports')
-
-__all__ = (
-    'Report',
-    'ReportModule',
-)
-
-
-class Report(EventRulesMixin, models.Model):
-    """
-    Dummy model used to generate permissions for reports. Does not exist in the database.
-    """
-    class Meta:
-        managed = False
-
-
-class ReportModuleManager(models.Manager.from_queryset(RestrictedQuerySet)):
-
-    def get_queryset(self):
-        return super().get_queryset().filter(file_root=ManagedFileRootPathChoices.REPORTS)
-
-
-class ReportModule(PythonModuleMixin, JobsMixin, ManagedFile):
-    """
-    Proxy model for report module files.
-    """
-    objects = ReportModuleManager()
-
-    class Meta:
-        proxy = True
-        verbose_name = _('report module')
-        verbose_name_plural = _('report modules')
-
-    def get_absolute_url(self):
-        return reverse('extras:report_list')
-
-    def __str__(self):
-        return self.python_name
-
-    @cached_property
-    def reports(self):
-
-        def _get_name(cls):
-            # For child objects in submodules use the full import path w/o the root module as the name
-            return cls.full_name.split(".", maxsplit=1)[1]
-
-        try:
-            module = self.get_module()
-        except (ImportError, SyntaxError) as e:
-            logger.error(f"Unable to load report module {self.name}, exception: {e}")
-            return {}
-        reports = {}
-        ordered = getattr(module, 'report_order', [])
-
-        for cls in ordered:
-            reports[_get_name(cls)] = cls
-        for name, cls in inspect.getmembers(module, is_report):
-            if cls not in ordered:
-                reports[_get_name(cls)] = cls
-
-        return reports
-
-    def save(self, *args, **kwargs):
-        self.file_root = ManagedFileRootPathChoices.REPORTS
-        return super().save(*args, **kwargs)

+ 3 - 1
netbox/extras/models/scripts.py

@@ -3,6 +3,7 @@ import logging
 from functools import cached_property
 
 from django.db import models
+from django.db.models import Q
 from django.urls import reverse
 from django.utils.translation import gettext_lazy as _
 
@@ -32,7 +33,8 @@ class Script(EventRulesMixin, models.Model):
 class ScriptModuleManager(models.Manager.from_queryset(RestrictedQuerySet)):
 
     def get_queryset(self):
-        return super().get_queryset().filter(file_root=ManagedFileRootPathChoices.SCRIPTS)
+        return super().get_queryset().filter(
+            Q(file_root=ManagedFileRootPathChoices.SCRIPTS) | Q(file_root=ManagedFileRootPathChoices.REPORTS))
 
 
 class ScriptModule(PythonModuleMixin, JobsMixin, ManagedFile):

+ 16 - 231
netbox/extras/reports.py

@@ -1,248 +1,33 @@
-import inspect
-import logging
-import traceback
-from datetime import timedelta
-
-from django.utils import timezone
-from django.utils.functional import classproperty
-from django_rq import job
-
-from core.choices import JobStatusChoices
-from core.models import Job
 from .choices import LogLevelChoices
-from .models import ReportModule
+from .scripts import BaseScript
 
 __all__ = (
     'Report',
-    'get_module_and_report',
-    'run_report',
 )
 
-logger = logging.getLogger(__name__)
-
-
-def get_module_and_report(module_name, report_name):
-    module = ReportModule.objects.get(file_path=f'{module_name}.py')
-    report = module.reports.get(report_name)()
-    return module, report
-
-
-@job('default')
-def run_report(job, *args, **kwargs):
-    """
-    Helper function to call the run method on a report. This is needed to get around the inability to pickle an instance
-    method for queueing into the background processor.
-    """
-    job.start()
-
-    module = ReportModule.objects.get(pk=job.object_id)
-    report = module.reports.get(job.name)()
-
-    try:
-        report.run(job)
-    except Exception as e:
-        job.terminate(status=JobStatusChoices.STATUS_ERRORED, error=repr(e))
-        logging.error(f"Error during execution of report {job.name}")
-    finally:
-        # Schedule the next job if an interval has been set
-        if job.interval:
-            new_scheduled_time = job.scheduled + timedelta(minutes=job.interval)
-            Job.enqueue(
-                run_report,
-                instance=job.object,
-                name=job.name,
-                user=job.user,
-                job_timeout=report.job_timeout,
-                schedule_at=new_scheduled_time,
-                interval=job.interval
-            )
-
-
-class Report(object):
-    """
-    NetBox users can extend this object to write custom reports to be used for validating data within NetBox. Each
-    report must have one or more test methods named `test_*`.
-
-    The `_results` attribute of a completed report will take the following form:
-
-    {
-        'test_bar': {
-            'failures': 42,
-            'log': [
-                (<datetime>, <level>, <object>, <message>),
-                ...
-            ]
-        },
-        'test_foo': {
-            'failures': 0,
-            'log': [
-                (<datetime>, <level>, <object>, <message>),
-                ...
-            ]
-        }
-    }
-    """
-    description = None
-    scheduling_enabled = True
-    job_timeout = None
-
-    def __init__(self):
-
-        self._results = {}
-        self.active_test = None
-        self.failed = False
-
-        self.logger = logging.getLogger(f"netbox.reports.{self.__module__}.{self.__class__.__name__}")
-
-        # Compile test methods and initialize results skeleton
-        test_methods = []
-        for method in dir(self):
-            if method.startswith('test_') and callable(getattr(self, method)):
-                test_methods.append(method)
-                self._results[method] = {
-                    'success': 0,
-                    'info': 0,
-                    'warning': 0,
-                    'failure': 0,
-                    'log': [],
-                }
-        self.test_methods = test_methods
 
-    @classproperty
-    def module(self):
-        return self.__module__
-
-    @classproperty
-    def class_name(self):
-        return self.__name__
-
-    @classproperty
-    def full_name(self):
-        return f'{self.module}.{self.class_name}'
-
-    @property
-    def name(self):
-        """
-        Override this attribute to set a custom display name.
-        """
-        return self.class_name
-
-    @property
-    def filename(self):
-        return inspect.getfile(self.__class__)
-
-    @property
-    def source(self):
-        return inspect.getsource(self.__class__)
-
-    @property
-    def is_valid(self):
-        """
-        Indicates whether the report can be run.
-        """
-        return bool(self.test_methods)
+class Report(BaseScript):
 
     #
-    # Logging methods
+    # Legacy logging methods for Reports
     #
 
-    def _log(self, obj, message, level=LogLevelChoices.LOG_DEFAULT):
-        """
-        Log a message from a test method. Do not call this method directly; use one of the log_* wrappers below.
-        """
-        if level not in LogLevelChoices.values():
-            raise Exception(f"Unknown logging level: {level}")
-        self._results[self.active_test]['log'].append((
-            timezone.now().isoformat(),
-            level,
-            str(obj) if obj else None,
-            obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else None,
-            message,
-        ))
-
+    # There is no generic log() equivalent on BaseScript
     def log(self, message):
-        """
-        Log a message which is not associated with a particular object.
-        """
-        self._log(None, message, level=LogLevelChoices.LOG_DEFAULT)
-        self.logger.info(message)
-
-    def log_success(self, obj, message=None):
-        """
-        Record a successful test against an object. Logging a message is optional.
-        """
-        if message:
-            self._log(obj, message, level=LogLevelChoices.LOG_SUCCESS)
-        self._results[self.active_test]['success'] += 1
-        self.logger.info(f"Success | {obj}: {message}")
-
-    def log_info(self, obj, message):
-        """
-        Log an informational message.
-        """
-        self._log(obj, message, level=LogLevelChoices.LOG_INFO)
-        self._results[self.active_test]['info'] += 1
-        self.logger.info(f"Info | {obj}: {message}")
-
-    def log_warning(self, obj, message):
-        """
-        Log a warning.
-        """
-        self._log(obj, message, level=LogLevelChoices.LOG_WARNING)
-        self._results[self.active_test]['warning'] += 1
-        self.logger.info(f"Warning | {obj}: {message}")
-
-    def log_failure(self, obj, message):
-        """
-        Log a failure. Calling this method will automatically mark the report as failed.
-        """
-        self._log(obj, message, level=LogLevelChoices.LOG_FAILURE)
-        self._results[self.active_test]['failure'] += 1
-        self.logger.info(f"Failure | {obj}: {message}")
-        self.failed = True
-
-    #
-    # Run methods
-    #
-
-    def run(self, job):
-        """
-        Run the report and save its results. Each test method will be executed in order.
-        """
-        self.logger.info(f"Running report")
+        self._log(message, None, level=LogLevelChoices.LOG_DEFAULT)
 
-        # Perform any post-run tasks
-        self.pre_run()
+    def log_success(self, obj=None, message=None):
+        super().log_success(message, obj)
 
-        try:
-            for method_name in self.test_methods:
-                self.active_test = method_name
-                test_method = getattr(self, method_name)
-                test_method()
-            job.data = self._results
-            if self.failed:
-                self.logger.warning("Report failed")
-                job.terminate(status=JobStatusChoices.STATUS_FAILED)
-            else:
-                self.logger.info("Report completed successfully")
-                job.terminate()
-        except Exception as e:
-            stacktrace = traceback.format_exc()
-            self.log_failure(None, f"An exception occurred: {type(e).__name__}: {e} <pre>{stacktrace}</pre>")
-            logger.error(f"Exception raised during report execution: {e}")
-            job.terminate(status=JobStatusChoices.STATUS_ERRORED, error=repr(e))
+    def log_info(self, obj=None, message=None):
+        super().log_info(message, obj)
 
-        # Perform any post-run tasks
-        self.post_run()
+    def log_warning(self, obj=None, message=None):
+        super().log_warning(message, obj)
 
-    def pre_run(self):
-        """
-        Extend this method to include any tasks which should execute *before* the report is run.
-        """
-        pass
+    def log_failure(self, obj=None, message=None):
+        super().log_failure(message, obj)
 
-    def post_run(self):
-        """
-        Extend this method to include any tasks which should execute *after* the report is run.
-        """
-        pass
+    # Added in v4.0 to avoid confusion with the log_debug() method provided by BaseScript
+    def log_debug(self, obj=None, message=None):
+        super().log_debug(message, obj)

+ 166 - 32
netbox/extras/scripts.py

@@ -10,11 +10,12 @@ from django import forms
 from django.conf import settings
 from django.core.validators import RegexValidator
 from django.db import transaction
+from django.utils import timezone
 from django.utils.functional import classproperty
+from django.utils.translation import gettext as _
 
 from core.choices import JobStatusChoices
 from core.models import Job
-from extras.api.serializers import ScriptOutputSerializer
 from extras.choices import LogLevelChoices
 from extras.models import ScriptModule
 from extras.signals import clear_events
@@ -25,6 +26,8 @@ from utilities.forms import add_blank_choice
 from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField
 from .context_managers import event_tracking
 from .forms import ScriptForm
+from .utils import is_report
+
 
 __all__ = (
     'BaseScript',
@@ -270,17 +273,28 @@ class BaseScript:
         pass
 
     def __init__(self):
+        self.messages = []  # Primary script log
+        self.tests = {}  # Mapping of logs for test methods
+        self.output = ''
+        self.failed = False
+        self._current_test = None  # Tracks the current test method being run (if any)
 
         # Initiate the log
         self.logger = logging.getLogger(f"netbox.scripts.{self.__module__}.{self.__class__.__name__}")
-        self.log = []
 
         # Declare the placeholder for the current request
         self.request = None
 
-        # Grab some info about the script
-        self.filename = inspect.getfile(self.__class__)
-        self.source = inspect.getsource(self.__class__)
+        # Compile test methods and initialize results skeleton
+        for method in dir(self):
+            if method.startswith('test_') and callable(getattr(self, method)):
+                self.tests[method] = {
+                    LogLevelChoices.LOG_SUCCESS: 0,
+                    LogLevelChoices.LOG_INFO: 0,
+                    LogLevelChoices.LOG_WARNING: 0,
+                    LogLevelChoices.LOG_FAILURE: 0,
+                    'log': [],
+                }
 
     def __str__(self):
         return self.name
@@ -331,6 +345,14 @@ class BaseScript:
     def scheduling_enabled(self):
         return getattr(self.Meta, 'scheduling_enabled', True)
 
+    @property
+    def filename(self):
+        return inspect.getfile(self.__class__)
+
+    @property
+    def source(self):
+        return inspect.getsource(self.__class__)
+
     @classmethod
     def _get_vars(cls):
         vars = {}
@@ -356,9 +378,28 @@ class BaseScript:
         return ordered_vars
 
     def run(self, data, commit):
-        raise NotImplementedError("The script must define a run() method.")
+        """
+        Override this method with custom script logic.
+        """
+
+        # Backward compatibility for legacy Reports
+        self.pre_run()
+        self.run_tests()
+        self.post_run()
 
+    def get_job_data(self):
+        """
+        Return a dictionary of data to attach to the script's Job.
+        """
+        return {
+            'log': self.messages,
+            'output': self.output,
+            'tests': self.tests,
+        }
+
+    #
     # Form rendering
+    #
 
     def get_fieldsets(self):
         fieldsets = []
@@ -397,29 +438,66 @@ class BaseScript:
 
         return form
 
+    #
     # Logging
+    #
+
+    def _log(self, message, obj=None, level=LogLevelChoices.LOG_DEFAULT):
+        """
+        Log a message. Do not call this method directly; use one of the log_* wrappers below.
+        """
+        if level not in LogLevelChoices.values():
+            raise ValueError(f"Invalid logging level: {level}")
+
+        # A test method is currently active, so log the message using legacy Report logging
+        if self._current_test:
+
+            # TODO: Use a dataclass for test method logs
+            self.tests[self._current_test]['log'].append((
+                timezone.now().isoformat(),
+                level,
+                str(obj) if obj else None,
+                obj.get_absolute_url() if hasattr(obj, 'get_absolute_url') else None,
+                str(message),
+            ))
+
+            # Increment the event counter for this level
+            if level in self.tests[self._current_test]:
+                self.tests[self._current_test][level] += 1
+
+        elif message:
 
-    def log_debug(self, message):
-        self.logger.log(logging.DEBUG, message)
-        self.log.append((LogLevelChoices.LOG_DEFAULT, str(message)))
+            # Record to the script's log
+            self.messages.append({
+                'time': timezone.now().isoformat(),
+                'status': level,
+                'message': str(message),
+            })
 
-    def log_success(self, message):
-        self.logger.log(logging.INFO, message)  # No syslog equivalent for SUCCESS
-        self.log.append((LogLevelChoices.LOG_SUCCESS, str(message)))
+            # Record to the system log
+            if obj:
+                message = f"{obj}: {message}"
+            self.logger.log(LogLevelChoices.SYSTEM_LEVELS[level], message)
 
-    def log_info(self, message):
-        self.logger.log(logging.INFO, message)
-        self.log.append((LogLevelChoices.LOG_INFO, str(message)))
+    def log_debug(self, message, obj=None):
+        self._log(message, obj, level=LogLevelChoices.LOG_DEBUG)
 
-    def log_warning(self, message):
-        self.logger.log(logging.WARNING, message)
-        self.log.append((LogLevelChoices.LOG_WARNING, str(message)))
+    def log_success(self, message, obj=None):
+        self._log(message, obj, level=LogLevelChoices.LOG_SUCCESS)
 
-    def log_failure(self, message):
-        self.logger.log(logging.ERROR, message)
-        self.log.append((LogLevelChoices.LOG_FAILURE, str(message)))
+    def log_info(self, message, obj=None):
+        self._log(message, obj, level=LogLevelChoices.LOG_INFO)
 
+    def log_warning(self, message, obj=None):
+        self._log(message, obj, level=LogLevelChoices.LOG_WARNING)
+
+    def log_failure(self, message, obj=None):
+        self._log(message, obj, level=LogLevelChoices.LOG_FAILURE)
+        self.failed = True
+
+    #
     # Convenience functions
+    #
 
     def load_yaml(self, filename):
         """
@@ -446,6 +524,39 @@ class BaseScript:
 
         return data
 
+    #
+    # Legacy Report functionality
+    #
+
+    def run_tests(self):
+        """
+        Run the report and save its results. Each test method will be executed in order.
+        """
+        self.logger.info(f"Running report")
+
+        try:
+            for test_name in self.tests:
+                self._current_test = test_name
+                test_method = getattr(self, test_name)
+                test_method()
+                self._current_test = None
+        except Exception as e:
+            self._current_test = None
+            self.post_run()
+            raise e
+
+    def pre_run(self):
+        """
+        Legacy method for operations performed immediately prior to running a Report.
+        """
+        pass
+
+    def post_run(self):
+        """
+        Legacy method for operations performed immediately after running a Report.
+        """
+        pass
+
 
 class Script(BaseScript):
     """
@@ -500,7 +611,16 @@ def run_script(data, job, request=None, commit=True, **kwargs):
     # Add the current request as a property of the script
     script.request = request
 
-    def _run_script():
+    def set_job_data(script):
+        job.data = {
+            'log': script.messages,
+            'output': script.output,
+            'tests': script.tests,
+        }
+
+        return job
+
+    def _run_script(job):
         """
         Core script execution task. We capture this within a subfunction to allow for conditionally wrapping it with
         the event_tracking context manager (which is bypassed if commit == False).
@@ -508,25 +628,39 @@ def run_script(data, job, request=None, commit=True, **kwargs):
         try:
             try:
                 with transaction.atomic():
-                    script.output = script.run(data=data, commit=commit)
+                    script.output = script.run(data, commit)
                     if not commit:
                         raise AbortTransaction()
             except AbortTransaction:
-                script.log_info("Database changes have been reverted automatically.")
+                script.log_info(message=_("Database changes have been reverted automatically."))
                 if request:
                     clear_events.send(request)
-            job.data = ScriptOutputSerializer(script).data
-            job.terminate()
+
+            job.data = script.get_job_data()
+            if script.failed:
+                logger.warning(f"Script failed")
+                job.terminate(status=JobStatusChoices.STATUS_FAILED)
+            else:
+                job.terminate()
+
         except Exception as e:
             if type(e) is AbortScript:
-                script.log_failure(f"Script aborted with error: {e}")
+                msg = _("Script aborted with error: ") + str(e)
+                if is_report(type(script)):
+                    script.log_failure(message=msg)
+                else:
+                    script.log_failure(msg)
+
                 logger.error(f"Script aborted with error: {e}")
             else:
                 stacktrace = traceback.format_exc()
-                script.log_failure(f"An exception occurred: `{type(e).__name__}: {e}`\n```\n{stacktrace}\n```")
+                script.log_failure(
+                    message=_("An exception occurred: ") + f"`{type(e).__name__}: {e}`\n```\n{stacktrace}\n```"
+                )
                 logger.error(f"Exception raised during script execution: {e}")
-            script.log_info("Database changes have been reverted due to error.")
-            job.data = ScriptOutputSerializer(script).data
+            script.log_info(message=_("Database changes have been reverted due to error."))
+
+            job.data = script.get_job_data()
             job.terminate(status=JobStatusChoices.STATUS_ERRORED, error=repr(e))
             if request:
                 clear_events.send(request)
@@ -537,9 +671,9 @@ def run_script(data, job, request=None, commit=True, **kwargs):
     # change logging, event rules, etc.
     if commit:
         with event_tracking(request):
-            _run_script()
+            _run_script(job)
     else:
-        _run_script()
+        _run_script(job)
 
     # Schedule the next job if an interval has been set
     if job.interval:

+ 0 - 31
netbox/extras/tests/test_api.py

@@ -746,37 +746,6 @@ class ConfigTemplateTest(APIViewTestCases.APIViewTestCase):
         ConfigTemplate.objects.bulk_create(config_templates)
 
 
-class ReportTest(APITestCase):
-
-    class TestReport(Report):
-
-        def test_foo(self):
-            self.log_success(None, "Report completed")
-
-    @classmethod
-    def setUpTestData(cls):
-        ReportModule.objects.create(
-            file_root=ManagedFileRootPathChoices.REPORTS,
-            file_path='/var/tmp/report.py'
-        )
-
-    def get_test_report(self, *args):
-        return ReportModule.objects.first(), self.TestReport()
-
-    def setUp(self):
-        super().setUp()
-
-        # Monkey-patch the API viewset's _get_report() method to return our test Report above
-        from extras.api.views import ReportViewSet
-        ReportViewSet._get_report = self.get_test_report
-
-    def test_get_report(self):
-        url = reverse('extras-api:report-detail', kwargs={'pk': None})
-        response = self.client.get(url, **self.header)
-
-        self.assertEqual(response.data['name'], self.TestReport.__name__)
-
-
 class ScriptTest(APITestCase):
 
     class TestScript(Script):

+ 0 - 9
netbox/extras/urls.py

@@ -116,15 +116,6 @@ urlpatterns = [
     path('dashboard/widgets/<uuid:id>/configure/', views.DashboardWidgetConfigView.as_view(), name='dashboardwidget_config'),
     path('dashboard/widgets/<uuid:id>/delete/', views.DashboardWidgetDeleteView.as_view(), name='dashboardwidget_delete'),
 
-    # Reports
-    path('reports/', views.ReportListView.as_view(), name='report_list'),
-    path('reports/add/', views.ReportModuleCreateView.as_view(), name='reportmodule_add'),
-    path('reports/results/<int:job_pk>/', views.ReportResultView.as_view(), name='report_result'),
-    path('reports/<int:pk>/', include(get_model_urls('extras', 'reportmodule'))),
-    path('reports/<str:module>/<str:name>/', views.ReportView.as_view(), name='report'),
-    path('reports/<str:module>/<str:name>/source/', views.ReportSourceView.as_view(), name='report_source'),
-    path('reports/<str:module>/<str:name>/jobs/', views.ReportJobsView.as_view(), name='report_jobs'),
-
     # Scripts
     path('scripts/', views.ScriptListView.as_view(), name='script_list'),
     path('scripts/add/', views.ScriptModuleCreateView.as_view(), name='scriptmodule_add'),

+ 3 - 2
netbox/extras/utils.py

@@ -49,11 +49,12 @@ def register_features(model, features):
 
 def is_script(obj):
     """
-    Returns True if the object is a Script.
+    Returns True if the object is a Script or Report.
     """
+    from .reports import Report
     from .scripts import Script
     try:
-        return issubclass(obj, Script) and obj != Script
+        return (issubclass(obj, Report) and obj != Report) or (issubclass(obj, Script) and obj != Script)
     except TypeError:
         return False
 

+ 17 - 188
netbox/extras/views.py

@@ -9,7 +9,7 @@ from django.urls import reverse
 from django.utils.translation import gettext as _
 from django.views.generic import View
 
-from core.choices import JobStatusChoices, ManagedFileRootPathChoices
+from core.choices import ManagedFileRootPathChoices
 from core.forms import ManagedFileForm
 from core.models import Job
 from core.tables import JobTable
@@ -24,9 +24,7 @@ from utilities.templatetags.builtins.filters import render_markdown
 from utilities.utils import copy_safe_request, count_related, get_viewname, normalize_querydict, shallow_compare_dict
 from utilities.views import ContentTypePermissionRequiredMixin, register_model_view
 from . import filtersets, forms, tables
-from .forms.reports import ReportForm
 from .models import *
-from .reports import run_report
 from .scripts import run_script
 
 
@@ -1006,183 +1004,6 @@ class DashboardWidgetDeleteView(LoginRequiredMixin, View):
         return redirect(reverse('home'))
 
 
-#
-# Reports
-#
-
-@register_model_view(ReportModule, 'edit')
-class ReportModuleCreateView(generic.ObjectEditView):
-    queryset = ReportModule.objects.all()
-    form = ManagedFileForm
-
-    def alter_object(self, obj, *args, **kwargs):
-        obj.file_root = ManagedFileRootPathChoices.REPORTS
-        return obj
-
-
-@register_model_view(ReportModule, 'delete')
-class ReportModuleDeleteView(generic.ObjectDeleteView):
-    queryset = ReportModule.objects.all()
-    default_return_url = 'extras:report_list'
-
-
-class ReportListView(ContentTypePermissionRequiredMixin, View):
-    """
-    Retrieve all the available reports from disk and the recorded Job (if any) for each.
-    """
-    def get_required_permission(self):
-        return 'extras.view_report'
-
-    def get(self, request):
-        report_modules = ReportModule.objects.restrict(request.user)
-
-        return render(request, 'extras/report_list.html', {
-            'model': ReportModule,
-            'report_modules': report_modules,
-        })
-
-
-def get_report_module(module, request):
-    return get_object_or_404(ReportModule.objects.restrict(request.user), file_path__regex=f"^{module}\\.")
-
-
-class ReportView(ContentTypePermissionRequiredMixin, View):
-    """
-    Display a single Report and its associated Job (if any).
-    """
-    def get_required_permission(self):
-        return 'extras.view_report'
-
-    def get(self, request, module, name):
-        module = get_report_module(module, request)
-        report = module.reports[name]()
-        jobs = module.get_jobs(report.class_name)
-
-        report.result = jobs.filter(
-            status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
-        ).first()
-
-        return render(request, 'extras/report.html', {
-            'job_count': jobs.count(),
-            'module': module,
-            'report': report,
-            'form': ReportForm(scheduling_enabled=report.scheduling_enabled),
-        })
-
-    def post(self, request, module, name):
-        if not request.user.has_perm('extras.run_report'):
-            return HttpResponseForbidden()
-
-        module = get_report_module(module, request)
-        report = module.reports[name]()
-        jobs = module.get_jobs(report.class_name)
-        form = ReportForm(request.POST, scheduling_enabled=report.scheduling_enabled)
-
-        if form.is_valid():
-
-            # Allow execution only if RQ worker process is running
-            if not get_workers_for_queue('default'):
-                messages.error(request, "Unable to run report: RQ worker process not running.")
-                return render(request, 'extras/report.html', {
-                    'job_count': jobs.count(),
-                    'report': report,
-                })
-
-            # Run the Report. A new Job is created.
-            job = Job.enqueue(
-                run_report,
-                instance=module,
-                name=report.class_name,
-                user=request.user,
-                schedule_at=form.cleaned_data.get('schedule_at'),
-                interval=form.cleaned_data.get('interval'),
-                job_timeout=report.job_timeout
-            )
-
-            return redirect('extras:report_result', job_pk=job.pk)
-
-        return render(request, 'extras/report.html', {
-            'job_count': jobs.count(),
-            'module': module,
-            'report': report,
-            'form': form,
-        })
-
-
-class ReportSourceView(ContentTypePermissionRequiredMixin, View):
-
-    def get_required_permission(self):
-        return 'extras.view_report'
-
-    def get(self, request, module, name):
-        module = get_report_module(module, request)
-        report = module.reports[name]()
-        jobs = module.get_jobs(report.class_name)
-
-        return render(request, 'extras/report/source.html', {
-            'job_count': jobs.count(),
-            'module': module,
-            'report': report,
-            'tab': 'source',
-        })
-
-
-class ReportJobsView(ContentTypePermissionRequiredMixin, View):
-
-    def get_required_permission(self):
-        return 'extras.view_report'
-
-    def get(self, request, module, name):
-        module = get_report_module(module, request)
-        report = module.reports[name]()
-        jobs = module.get_jobs(report.class_name)
-
-        jobs_table = JobTable(
-            data=jobs,
-            orderable=False,
-            user=request.user
-        )
-        jobs_table.configure(request)
-
-        return render(request, 'extras/report/jobs.html', {
-            'job_count': jobs.count(),
-            'module': module,
-            'report': report,
-            'table': jobs_table,
-            'tab': 'jobs',
-        })
-
-
-class ReportResultView(ContentTypePermissionRequiredMixin, View):
-    """
-    Display a Job pertaining to the execution of a Report.
-    """
-    def get_required_permission(self):
-        return 'extras.view_report'
-
-    def get(self, request, job_pk):
-        object_type = ContentType.objects.get_by_natural_key(app_label='extras', model='reportmodule')
-        job = get_object_or_404(Job.objects.all(), pk=job_pk, object_type=object_type)
-
-        module = job.object
-        report = module.reports[job.name]
-
-        # If this is an HTMX request, return only the result HTML
-        if request.htmx:
-            response = render(request, 'extras/htmx/report_result.html', {
-                'report': report,
-                'job': job,
-            })
-            if job.completed or not job.started:
-                response.status_code = 286
-            return response
-
-        return render(request, 'extras/report_result.html', {
-            'report': report,
-            'job': job,
-        })
-
-
 #
 # Scripts
 #
@@ -1332,20 +1153,28 @@ class ScriptResultView(ContentTypePermissionRequiredMixin, View):
         module = job.object
         script = module.scripts[job.name]()
 
+        context = {
+            'script': script,
+            'job': job,
+        }
+        if job.data and 'log' in job.data:
+            # Script
+            context['tests'] = job.data.get('tests', {})
+        elif job.data:
+            # Legacy Report
+            context['tests'] = {
+                name: data for name, data in job.data.items()
+                if name.startswith('test_')
+            }
+
         # If this is an HTMX request, return only the result HTML
         if request.htmx:
-            response = render(request, 'extras/htmx/script_result.html', {
-                'script': script,
-                'job': job,
-            })
+            response = render(request, 'extras/htmx/script_result.html', context)
             if job.completed or not job.started:
                 response.status_code = 286
             return response
 
-        return render(request, 'extras/script_result.html', {
-            'script': script,
-            'job': job,
-        })
+        return render(request, 'extras/script_result.html', context)
 
 
 #

+ 1 - 7
netbox/netbox/navigation/menu.py

@@ -317,14 +317,8 @@ CUSTOMIZATION_MENU = Menu(
             ),
         ),
         MenuGroup(
-            label=_('Reports & Scripts'),
+            label=_('Scripts'),
             items=(
-                MenuItem(
-                    link='extras:report_list',
-                    link_text=_('Reports'),
-                    permissions=['extras.view_report'],
-                    buttons=get_model_buttons('extras', "reportmodule", actions=['add'])
-                ),
                 MenuItem(
                     link='extras:script_list',
                     link_text=_('Scripts'),

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


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


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


+ 30 - 0
netbox/project-static/package-lock.json

@@ -25,6 +25,7 @@
         "query-string": "^7.1.1",
         "sass": "^1.55.0",
         "slim-select": "^1.27.1",
+        "tom-select": "^2.3.1",
         "typeface-inter": "^3.18.1",
         "typeface-roboto-mono": "^1.1.13"
       },
@@ -225,6 +226,19 @@
         "node": ">= 8"
       }
     },
+    "node_modules/@orchidjs/sifter": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/@orchidjs/sifter/-/sifter-1.0.3.tgz",
+      "integrity": "sha512-zCZbwKegHytfsPm8Amcfh7v/4vHqTAaOu6xFswBYcn8nznBOuseu6COB2ON7ez0tFV0mKL0nRNnCiZZA+lU9/g==",
+      "dependencies": {
+        "@orchidjs/unicode-variants": "^1.0.4"
+      }
+    },
+    "node_modules/@orchidjs/unicode-variants": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/@orchidjs/unicode-variants/-/unicode-variants-1.0.4.tgz",
+      "integrity": "sha512-NvVBRnZNE+dugiXERFsET1JlKZfM5lJDEpSMilKW4bToYJ7pxf0Zne78xyXB2ny2c2aHfJ6WLnz1AaTNHAmQeQ=="
+    },
     "node_modules/@pkgr/utils": {
       "version": "2.3.1",
       "resolved": "https://registry.npmjs.org/@pkgr/utils/-/utils-2.3.1.tgz",
@@ -3888,6 +3902,22 @@
       "integrity": "sha1-bkWxJj8gF/oKzH2J14sVuL932jI=",
       "license": "MIT"
     },
+    "node_modules/tom-select": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmjs.org/tom-select/-/tom-select-2.3.1.tgz",
+      "integrity": "sha512-QS4vnOcB6StNGqX4sGboGXL2fkhBF2gIBB+8Hwv30FZXYPn0CyYO8kkdATRvwfCTThxiR4WcXwKJZ3cOmtI9eg==",
+      "dependencies": {
+        "@orchidjs/sifter": "^1.0.3",
+        "@orchidjs/unicode-variants": "^1.0.4"
+      },
+      "engines": {
+        "node": "*"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/tom-select"
+      }
+    },
     "node_modules/tsconfig-paths": {
       "version": "3.14.1",
       "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.1.tgz",

+ 2 - 2
netbox/project-static/package.json

@@ -31,16 +31,16 @@
     "gridstack": "^7.2.3",
     "html-entities": "^2.3.3",
     "htmx.org": "^1.8.0",
-    "just-debounce-it": "^3.1.1",
     "query-string": "^7.1.1",
     "sass": "^1.55.0",
-    "slim-select": "^1.27.1",
+    "tom-select": "^2.3.1",
     "typeface-inter": "^3.18.1",
     "typeface-roboto-mono": "^1.1.13"
   },
   "devDependencies": {
     "@types/bootstrap": "5.2.10",
     "@types/cookie": "^0.5.1",
+    "@types/node": "^20.11.16",
     "@typescript-eslint/eslint-plugin": "^5.39.0",
     "@typescript-eslint/parser": "^5.39.0",
     "esbuild": "^0.13.15",

+ 2 - 2
netbox/project-static/src/htmx.ts

@@ -1,11 +1,11 @@
 import { getElements, isTruthy } from './util';
 import { initButtons } from './buttons';
-import { initSelect } from './select';
+import { initSelects } from './select';
 import { initObjectSelector } from './objectSelector';
 import { initBootstrap } from './bs';
 
 function initDepedencies(): void {
-  for (const init of [initButtons, initSelect, initObjectSelector, initBootstrap]) {
+  for (const init of [initButtons, initSelects, initObjectSelector, initBootstrap]) {
     init();
   }
 }

+ 1 - 0
netbox/project-static/src/index.ts

@@ -1,4 +1,5 @@
 import '@popperjs/core';
 import 'bootstrap';
 import 'htmx.org';
+import 'tom-select';
 import './netbox';

+ 0 - 15
netbox/project-static/src/links.ts

@@ -1,15 +0,0 @@
-import { isTruthy, getElements } from './util';
-
-/**
- * Allow any element to be made "clickable" with the use of the `data-href` attribute.
- */
-export function initLinks(): void {
-  for (const link of getElements('*[data-href]')) {
-    const href = link.getAttribute('data-href');
-    if (isTruthy(href)) {
-      link.addEventListener('click', () => {
-        window.location.assign(href);
-      });
-    }
-  }
-}

+ 2 - 4
netbox/project-static/src/netbox.ts

@@ -1,7 +1,7 @@
 import { initForms } from './forms';
 import { initBootstrap } from './bs';
 import { initQuickSearch } from './search';
-import { initSelect } from './select';
+import { initSelects } from './select';
 import { initButtons } from './buttons';
 import { initColorMode } from './colorMode';
 import { initMessages } from './messages';
@@ -12,7 +12,6 @@ import { initInterfaceTable } from './tables';
 import { initSideNav } from './sidenav';
 import { initDashboard } from './dashboard';
 import { initRackElevation } from './racks';
-import { initLinks } from './links';
 import { initHtmx } from './htmx';
 
 function initDocument(): void {
@@ -22,7 +21,7 @@ function initDocument(): void {
     initMessages,
     initForms,
     initQuickSearch,
-    initSelect,
+    initSelects,
     initDateSelector,
     initButtons,
     initClipboard,
@@ -31,7 +30,6 @@ function initDocument(): void {
     initSideNav,
     initDashboard,
     initRackElevation,
-    initLinks,
     initHtmx,
   ]) {
     init();

+ 0 - 1002
netbox/project-static/src/select/api/apiSelect.ts

@@ -1,1002 +0,0 @@
-import { readableColor } from 'color2k';
-import debounce from 'just-debounce-it';
-import { encode } from 'html-entities';
-import queryString from 'query-string';
-import SlimSelect from 'slim-select';
-import { createToast } from '../../bs';
-import { hasUrl, hasExclusions, isTrigger } from '../util';
-import { DynamicParamsMap } from './dynamicParams';
-import { isStaticParams, isOption } from './types';
-import {
-  hasMore,
-  hasError,
-  isTruthy,
-  getApiData,
-  getElement,
-  isApiError,
-  replaceAll,
-  createElement,
-  uniqueByProperty,
-  findFirstAdjacent,
-} from '../../util';
-
-import type { Stringifiable } from 'query-string';
-import type { Option } from 'slim-select/dist/data';
-import type { Trigger, PathFilter, ApplyMethod, QueryFilter } from './types';
-
-// Empty placeholder option.
-const EMPTY_PLACEHOLDER = {
-  value: '',
-  text: '',
-  placeholder: true,
-} as Option;
-
-// Attributes which if truthy should render the option disabled.
-const DISABLED_ATTRIBUTES = ['occupied'] as string[];
-
-/**
- * Manage a single API-backed select element's state. Each API select element is likely controlled
- * or dynamically updated by one or more other API select (or static select) elements' values.
- */
-export class APISelect {
-  /**
-   * Base `<select/>` DOM element.
-   */
-  private readonly base: HTMLSelectElement;
-
-  /**
-   * Form field name.
-   */
-  public readonly name: string;
-
-  /**
-   * Form field placeholder.
-   */
-  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.
-   */
-  private readonly trigger: Trigger;
-
-  /**
-   * If `true`, a refresh button will be added next to the search/filter `<input/>` element.
-   */
-  private readonly allowRefresh: boolean = true;
-
-  /**
-   * Event to be dispatched when dependent fields' values change.
-   */
-  private readonly loadEvent: InstanceType<typeof Event>;
-
-  /**
-   * Event to be dispatched when the scroll position of this element's optinos list is at the
-   * bottom.
-   */
-  private readonly bottomEvent: InstanceType<typeof Event>;
-
-  /**
-   * SlimSelect instance for this element.
-   */
-  private readonly slim: InstanceType<typeof SlimSelect>;
-
-  /**
-   * Post-parsed URL query parameters for API queries.
-   */
-  private readonly queryParams: QueryFilter = new Map();
-
-  /**
-   * API query parameters that should be applied to API queries for this field. This will be
-   * updated as other dependent fields' values change. This is a mapping of:
-   *
-   *     Form Field Names → Object containing:
-   *                         - Query parameter key name
-   *                         - Query value
-   *
-   * This is different from `queryParams` in that it tracks all _possible_ related fields and their
-   * values, even if they are empty. Further, the keys in `queryParams` correspond to the actual
-   * query parameter keys, which are not necessarily the same as the form field names, depending on
-   * the model. For example, `tenant_group` would be the field name, but `group_id` would be the
-   * query parameter.
-   */
-  private readonly dynamicParams: DynamicParamsMap = new DynamicParamsMap();
-
-  /**
-   * API query parameters that are already known by the server and should not change.
-   */
-  private readonly staticParams: QueryFilter = new Map();
-
-  /**
-   * Mapping of URL template key/value pairs. If this element's URL contains Django template tags
-   * (e.g., `{{key}}`), `key` will be added to `pathValue` and the `id_key` form element will be
-   * tracked for changes. When the `id_key` element's value changes, the new value will be added
-   * to this map. For example, if the template key is `rack`, and the `id_rack` field's value is
-   * `1`, `pathValues` would be updated to reflect a `"rack" => 1` mapping. When the query URL is
-   * updated, the URL would change from `/dcim/racks/{{rack}}/` to `/dcim/racks/1/`.
-   */
-  private readonly pathValues: PathFilter = new Map();
-
-  /**
-   * Original API query URL passed via the `data-href` attribute from the server. This is kept so
-   * that the URL can be reconstructed as form values change.
-   */
-  private readonly url: string = '';
-
-  /**
-   * API query URL. This will be updated dynamically to include any query parameters in `queryParameters`.
-   */
-  private queryUrl: string = '';
-
-  /**
-   * Scroll position of options is at the bottom of the list, or not. Used to determine if
-   * additional options should be fetched from the API.
-   */
-  private atBottom: boolean = false;
-
-  /**
-   * API URL for additional options, if applicable. `null` indicates no options remain.
-   */
-  private more: Nullable<string> = null;
-
-  /**
-   * Array of options values which should be considered disabled or static.
-   */
-  private disabledOptions: Array<string> = [];
-
-  /**
-   * Array of properties which if truthy on an API object should be considered disabled.
-   */
-  private disabledAttributes: Array<string> = DISABLED_ATTRIBUTES;
-
-  constructor(base: HTMLSelectElement) {
-    // Initialize readonly properties.
-    this.base = base;
-    this.name = base.name;
-
-    if (hasUrl(base)) {
-      const url = base.getAttribute('data-url') as string;
-      this.url = url;
-      this.queryUrl = url;
-    }
-
-    this.loadEvent = new Event(`netbox.select.onload.${base.name}`);
-    this.bottomEvent = new Event(`netbox.select.atbottom.${base.name}`);
-
-    this.placeholder = this.getPlaceholder();
-    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,
-      deselectLabel: `<i class="mdi mdi-close-circle" style="color:currentColor;"></i>`,
-      placeholder: this.placeholder,
-      searchPlaceholder: 'Filter',
-      onChange: () => this.handleSlimChange(),
-    });
-
-    // Don't close on select if multiple select
-    if (this.base.multiple) {
-      this.slim.config.closeOnSelect = false;
-    }
-
-    // Initialize API query properties.
-    this.getStaticParams();
-    this.getDynamicParams();
-    this.getPathKeys();
-
-    // Populate static query parameters.
-    for (const [key, value] of this.staticParams.entries()) {
-      this.queryParams.set(key, value);
-    }
-
-    // Populate dynamic query parameters with any form values that are already known.
-    for (const filter of this.dynamicParams.keys()) {
-      this.updateQueryParams(filter);
-    }
-
-    // Populate dynamic path values with any form values that are already known.
-    for (const filter of this.pathValues.keys()) {
-      this.updatePathValues(filter);
-    }
-
-    this.queryParams.set('brief', [true]);
-    this.updateQueryUrl();
-
-    // Initialize element styling.
-    this.resetClasses();
-    this.setSlimStyles();
-
-    // Initialize controlling elements.
-    this.initResetButton();
-
-    // Add the refresh button to the search element.
-    this.initRefreshButton();
-
-    // Add dependency event listeners.
-    this.addEventListeners();
-
-    // Determine if the fetch trigger has been set.
-    const triggerAttr = this.base.getAttribute('data-fetch-trigger');
-
-    // Determine if this element is part of collapsible element.
-    const collapse = this.base.closest('.content-container .collapse');
-
-    if (isTrigger(triggerAttr)) {
-      this.trigger = triggerAttr;
-    } else if (collapse !== null) {
-      this.trigger = 'collapse';
-    } else {
-      this.trigger = 'open';
-    }
-
-    switch (this.trigger) {
-      case 'collapse':
-        if (collapse !== null) {
-          // If the element is collapsible but already shown, load the data immediately.
-          if (collapse.classList.contains('show')) {
-            Promise.all([this.loadData()]);
-          }
-
-          // If this element is part of a collapsible element, only load the data when the
-          // collapsible element is shown.
-          // See: https://getbootstrap.com/docs/5.0/components/collapse/#events
-          collapse.addEventListener('show.bs.collapse', () => this.loadData());
-          collapse.addEventListener('hide.bs.collapse', () => this.resetOptions());
-        }
-        break;
-      case 'open':
-        // If the trigger is 'open', only load API data when the select element is opened.
-        this.slim.beforeOpen = () => this.loadData();
-        break;
-      case 'load':
-        // Otherwise, load the data immediately.
-        Promise.all([this.loadData()]);
-        break;
-    }
-  }
-
-  /**
-   * This instance's available options.
-   */
-  private get options(): Option[] {
-    return this.slim.data.data.filter(isOption);
-  }
-
-  /**
-   * Apply new options to both the SlimSelect instance and this manager's state.
-   */
-  private set options(optionsIn: Option[]) {
-    let newOptions = optionsIn;
-    // Ensure null option is present, if it exists.
-    if (this.nullOption !== null) {
-      newOptions = [this.nullOption, ...newOptions];
-    }
-    // Deduplicate options each time they're set.
-    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 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.slim.setData(deduplicated);
-  }
-
-  /**
-   * Remove all options and reset back to the generic placeholder.
-   */
-  private resetOptions(): void {
-    this.options = [this.emptyOption];
-  }
-
-  /**
-   * Add or remove a class to the SlimSelect element to match Bootstrap .form-select:disabled styles.
-   */
-  public disable(): void {
-    if (this.slim.slim.singleSelected !== null) {
-      if (!this.slim.slim.singleSelected.container.hasAttribute('disabled')) {
-        this.slim.slim.singleSelected.container.setAttribute('disabled', '');
-      }
-    } else if (this.slim.slim.multiSelected !== null) {
-      if (!this.slim.slim.multiSelected.container.hasAttribute('disabled')) {
-        this.slim.slim.multiSelected.container.setAttribute('disabled', '');
-      }
-    }
-    this.slim.disable();
-  }
-
-  /**
-   * Add or remove a class to the SlimSelect element to match Bootstrap .form-select:disabled styles.
-   */
-  public enable(): void {
-    if (this.slim.slim.singleSelected !== null) {
-      if (this.slim.slim.singleSelected.container.hasAttribute('disabled')) {
-        this.slim.slim.singleSelected.container.removeAttribute('disabled');
-      }
-    } else if (this.slim.slim.multiSelected !== null) {
-      if (this.slim.slim.multiSelected.container.hasAttribute('disabled')) {
-        this.slim.slim.multiSelected.container.removeAttribute('disabled');
-      }
-    }
-    this.slim.enable();
-  }
-
-  /**
-   * Add event listeners to this element and its dependencies so that when dependencies change
-   * this element's options are updated.
-   */
-  private addEventListeners(): void {
-    // Create a debounced function to fetch options based on the search input value.
-    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 => {
-      // 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.
-    this.slim.slim.list.addEventListener('scroll', () => this.handleScroll());
-
-    // When the scroll position is at bottom, fetch additional options.
-    this.base.addEventListener(`netbox.select.atbottom.${this.name}`, () =>
-      this.fetchOptions(this.more, 'merge'),
-    );
-
-    // When the base select element is disabled or enabled, properly disable/enable this instance.
-    this.base.addEventListener(`netbox.select.disabled.${this.name}`, event =>
-      this.handleDisableEnable(event),
-    );
-
-    // Create a unique iterator of all possible form fields which, when changed, should cause this
-    // element to update its API query.
-    // const dependencies = new Set([...this.filterParams.keys(), ...this.pathValues.keys()]);
-    const dependencies = new Set([...this.dynamicParams.keys(), ...this.pathValues.keys()]);
-
-    for (const dep of dependencies) {
-      const filterElement = document.querySelector(`[name="${dep}"]`);
-      if (filterElement !== null) {
-        // Subscribe to dependency changes.
-        filterElement.addEventListener('change', event => this.handleEvent(event));
-      }
-      // Subscribe to changes dispatched by this state manager.
-      this.base.addEventListener(`netbox.select.onload.${dep}`, event => this.handleEvent(event));
-    }
-  }
-
-  /**
-   * Load this element's options from the NetBox API.
-   */
-  private async loadData(): Promise<void> {
-    try {
-      this.disable();
-      await this.getOptions('replace');
-    } catch (err) {
-      console.error(err);
-    } finally {
-      this.setOptionStyles();
-      this.enable();
-      this.base.dispatchEvent(this.loadEvent);
-    }
-  }
-
-  /**
-   * Get all options from the native select element that are already selected and do not contain
-   * placeholder values.
-   */
-  private getPreselectedOptions(): HTMLOptionElement[] {
-    return Array.from(this.base.options)
-      .filter(option => option.selected)
-      .filter(option => {
-        if (option.value === '---------' || option.innerText === '---------') return false;
-        return true;
-      });
-  }
-
-  /**
-   * Process a valid API response and add results to this instance's options.
-   *
-   * @param data Valid API response (not an error).
-   */
-  private async processOptions(
-    data: APIAnswer<APIObjectBase>,
-    action: ApplyMethod = 'merge',
-  ): Promise<void> {
-    // Get all already-selected options.
-    const preSelected = this.getPreselectedOptions();
-
-    // Get the values of all already-selected options.
-    const selectedValues = preSelected.map(option => option.getAttribute('value')).filter(isTruthy);
-
-    // Build SlimSelect options from all already-selected options.
-    const preSelectedOptions = preSelected.map(option => ({
-      value: option.value,
-      text: encode(option.innerText),
-      selected: true,
-      disabled: false,
-    })) as Option[];
-
-    let options = [] as Option[];
-
-    for (const result of data.results) {
-      let text = encode(result.display);
-
-      if (typeof result._depth === 'number' && result._depth > 0) {
-        // If the object has a `_depth` property, indent its display text.
-        text = `<span class="depth">${'─'.repeat(result._depth)}&nbsp;</span>${text}`;
-      }
-      const data = {} as Record<string, string>;
-      const value = result.id.toString();
-      let style, selected, disabled;
-
-      // Set any primitive k/v pairs as data attributes on each option.
-      for (const [k, v] of Object.entries(result)) {
-        if (!['id', 'slug'].includes(k) && ['string', 'number', 'boolean'].includes(typeof v)) {
-          const key = replaceAll(k, '_', '-');
-          data[key] = String(v);
-        }
-        // Set option to disabled if the result contains a matching key and is truthy.
-        if (this.disabledAttributes.some(key => key.toLowerCase() === k.toLowerCase())) {
-          if (typeof v === 'string' && v.toLowerCase() !== 'false') {
-            disabled = true;
-          } else if (typeof v === 'boolean' && v === true) {
-            disabled = true;
-          } else if (typeof v === 'number' && v > 0) {
-            disabled = true;
-          }
-        }
-      }
-
-      // Set option to disabled if it is contained within the disabled array.
-      if (selectedValues.some(option => this.disabledOptions.includes(option))) {
-        disabled = true;
-      }
-
-      // Set pre-selected options.
-      if (selectedValues.includes(value)) {
-        selected = true;
-        // If an option is selected, it can't be disabled. Otherwise, it won't be submitted with
-        // the rest of the form, resulting in that field's value being deleting from the object.
-        disabled = false;
-      }
-
-      const option = {
-        value,
-        text,
-        data,
-        style,
-        selected,
-        disabled,
-      } as Option;
-      options = [...options, option];
-    }
-
-    switch (action) {
-      case 'merge':
-        this.options = [...this.options, ...options];
-        break;
-      case 'replace':
-        this.options = [...preSelectedOptions, ...options];
-        break;
-    }
-
-    if (hasMore(data)) {
-      // If the `next` property in the API response is a URL, there are more options on the server
-      // side to be fetched.
-      this.more = data.next;
-    } else {
-      // If the `next` property in the API response is `null`, there are no more options on the
-      // server, and no additional fetching needs to occur.
-      this.more = null;
-    }
-  }
-
-  /**
-   * Fetch options from the given API URL and add them to the instance.
-   *
-   * @param url API URL
-   */
-  private async fetchOptions(url: Nullable<string>, action: ApplyMethod = 'merge'): Promise<void> {
-    if (typeof url === 'string') {
-      const data = await getApiData(url);
-
-      if (hasError(data)) {
-        if (isApiError(data)) {
-          return this.handleError(data.exception, data.error);
-        }
-        return this.handleError(`Error Fetching Options for field '${this.name}'`, data.error);
-      }
-      await this.processOptions(data, action);
-    }
-  }
-
-  /**
-   * Query the NetBox API for this element's options.
-   */
-  private async getOptions(action: ApplyMethod = 'merge'): Promise<void> {
-    if (this.queryUrl.includes(`{{`)) {
-      this.resetOptions();
-      return;
-    }
-    await this.fetchOptions(this.queryUrl, action);
-  }
-
-  /**
-   * Query the API for a specific search pattern and add the results to the available options.
-   */
-  private async handleSearch(event: Event) {
-    const { value: q } = event.target as HTMLInputElement;
-    const url = queryString.stringifyUrl({ url: this.queryUrl, query: { q } });
-    if (!url.includes(`{{`)) {
-      await this.fetchOptions(url, 'merge');
-      this.slim.data.search(q);
-      this.slim.render();
-    }
-    return;
-  }
-
-  /**
-   * Determine if the user has scrolled to the bottom of the options list. If so, try to load
-   * additional paginated options.
-   */
-  private handleScroll(): void {
-    // Floor scrollTop as chrome can return fractions on some zoom levels.
-    const atBottom =
-      Math.floor(this.slim.slim.list.scrollTop) + this.slim.slim.list.offsetHeight ===
-      this.slim.slim.list.scrollHeight;
-
-    if (this.atBottom && !atBottom) {
-      this.atBottom = false;
-      this.base.dispatchEvent(this.bottomEvent);
-    } else if (!this.atBottom && atBottom) {
-      this.atBottom = true;
-      this.base.dispatchEvent(this.bottomEvent);
-    }
-  }
-
-  /**
-   * Event handler to be dispatched any time a dependency's value changes. For example, when the
-   * value of `tenant_group` changes, `handleEvent` is called to get the current value of
-   * `tenant_group` and update the query parameters and API query URL for the `tenant` field.
-   */
-  private handleEvent(event: Event): void {
-    const target = event.target as HTMLSelectElement;
-    // Update the element's URL after any changes to a dependency.
-    this.updateQueryParams(target.name);
-    this.updatePathValues(target.name);
-    this.updateQueryUrl();
-
-    // Load new data.
-    Promise.all([this.loadData()]);
-  }
-
-  /**
-   * Event handler to be dispatched when the base select element is disabled or enabled. When that
-   * occurs, run the instance's `disable()` or `enable()` methods to synchronize UI state with
-   * desired action.
-   *
-   * @param event Dispatched event matching pattern `netbox.select.disabled.<name>`
-   */
-  private handleDisableEnable(event: Event): void {
-    const target = event.target as HTMLSelectElement;
-
-    if (target.disabled === true) {
-      this.disable();
-    } else if (target.disabled === false) {
-      this.enable();
-    }
-  }
-
-  /**
-   * When the API returns an error, show it to the user and reset this element's available options.
-   *
-   * @param title Error title
-   * @param message Error message
-   */
-  private handleError(title: string, message: string): void {
-    createToast('danger', title, message).show();
-    this.resetOptions();
-  }
-
-  /**
-   * `change` event callback to be called any time the value of a SlimSelect instance is changed.
-   */
-  private handleSlimChange(): void {
-    const element = this.slim.slim;
-    if (element) {
-      // Toggle form validation classes when form values change. For example, if the field was
-      // invalid and the value has now changed, remove the `.is-invalid` class.
-      if (
-        element.container.classList.contains('is-invalid') ||
-        this.base.classList.contains('is-invalid')
-      ) {
-        element.container.classList.remove('is-invalid');
-        this.base.classList.remove('is-invalid');
-      }
-    }
-    this.base.dispatchEvent(this.loadEvent);
-  }
-
-  /**
-   * Update the API query URL and underlying DOM element's `data-url` attribute.
-   */
-  private updateQueryUrl(): void {
-    // Create new URL query parameters based on the current state of `queryParams` and create an
-    // updated API query URL.
-    const query = {} as Dict<Stringifiable[]>;
-    for (const [key, value] of this.queryParams.entries()) {
-      query[key] = value;
-    }
-
-    let url = this.url;
-
-    // Replace any Django template variables in the URL with values from `pathValues` if set.
-    for (const [key, value] of this.pathValues.entries()) {
-      for (const result of this.url.matchAll(new RegExp(`({{${key}}})`, 'g'))) {
-        if (isTruthy(value)) {
-          url = replaceAll(url, result[1], value.toString());
-        }
-      }
-    }
-    const newUrl = queryString.stringifyUrl({ url, query });
-    if (this.queryUrl !== newUrl) {
-      // Only update the URL if it has changed.
-      this.queryUrl = newUrl;
-      this.base.setAttribute('data-url', newUrl);
-    }
-  }
-
-  /**
-   * Update an element's API URL based on the value of another element on which this element
-   * relies.
-   *
-   * @param fieldName DOM ID of the other element.
-   */
-  private updateQueryParams(fieldName: string): void {
-    // Find the element dependency.
-    const element = document.querySelector<HTMLSelectElement>(`[name="${fieldName}"]`);
-    if (element !== null) {
-      // Initialize the element value as an array, in case there are multiple values.
-      let elementValue = [] as Stringifiable[];
-
-      if (element.multiple) {
-        // If this is a multi-select (form filters, tags, etc.), use all selected options as the value.
-        elementValue = Array.from(element.options)
-          .filter(o => o.selected)
-          .map(o => o.value);
-      } else if (element.value !== '') {
-        // If this is single-select (most fields), use the element's value. This seemingly
-        // redundant/verbose check is mainly for performance, so we're not running the above three
-        // functions (`Array.from()`, `Array.filter()`, `Array.map()`) every time every select
-        // field's value changes.
-        elementValue = [element.value];
-      }
-
-      if (elementValue.length > 0) {
-        // If the field has a value, add it to the map.
-        this.dynamicParams.updateValue(fieldName, elementValue);
-        // Get the updated value.
-        const current = this.dynamicParams.get(fieldName);
-
-        if (typeof current !== 'undefined') {
-          const { queryParam, queryValue } = current;
-          let value = [] as Stringifiable[];
-
-          if (this.staticParams.has(queryParam)) {
-            // If the field is defined in `staticParams`, we should merge the dynamic value with
-            // the static value.
-            const staticValue = this.staticParams.get(queryParam);
-            if (typeof staticValue !== 'undefined') {
-              value = [...staticValue, ...queryValue];
-            }
-          } else {
-            // If the field is _not_ defined in `staticParams`, we should replace the current value
-            // with the new dynamic value.
-            value = queryValue;
-          }
-          if (value.length > 0) {
-            this.queryParams.set(queryParam, value);
-          } else {
-            this.queryParams.delete(queryParam);
-          }
-        }
-      } else {
-        // Otherwise, delete it (we don't want to send an empty query like `?site_id=`)
-        const queryParam = this.dynamicParams.queryParam(fieldName);
-        if (queryParam !== null) {
-          this.queryParams.delete(queryParam);
-        }
-      }
-    }
-  }
-
-  /**
-   * Update `pathValues` based on the form value of another element.
-   *
-   * @param id DOM ID of the other element.
-   */
-  private updatePathValues(id: string): void {
-    const key = replaceAll(id, /^id_/i, '');
-    const element = getElement<HTMLSelectElement>(`id_${key}`);
-    if (element !== null) {
-      // If this element's URL contains Django template tags ({{), replace the template tag
-      // with the the dependency's value. For example, if the dependency is the `rack` field,
-      // and the `rack` field's value is `1`, this element's URL would change from
-      // `/dcim/racks/{{rack}}/` to `/dcim/racks/1/`.
-      const hasReplacement =
-        this.url.includes(`{{`) && Boolean(this.url.match(new RegExp(`({{(${id})}})`, 'g')));
-
-      if (hasReplacement) {
-        if (isTruthy(element.value)) {
-          // If the field has a value, add it to the map.
-          this.pathValues.set(id, element.value);
-        } else {
-          // Otherwise, reset the value.
-          this.pathValues.set(id, '');
-        }
-      }
-    }
-  }
-
-  /**
-   * Find the select element's placeholder text/label.
-   */
-  private getPlaceholder(): string {
-    let placeholder = this.name;
-    if (this.base.id) {
-      const label = document.querySelector(`label[for="${this.base.id}"]`) as HTMLLabelElement;
-      // Set the placeholder text to the label value, if it exists.
-      if (label !== null) {
-        placeholder = `Select ${label.innerText.trim()}`;
-      }
-    }
-    return placeholder;
-  }
-
-  /**
-   * Get this element's disabled options by value. The `data-query-param-exclude` attribute will
-   * contain a stringified JSON array of option values.
-   */
-  private getDisabledOptions(): string[] {
-    let disabledOptions = [] as string[];
-    if (hasExclusions(this.base)) {
-      try {
-        const exclusions = JSON.parse(
-          this.base.getAttribute('data-query-param-exclude') ?? '[]',
-        ) as string[];
-        disabledOptions = [...disabledOptions, ...exclusions];
-      } catch (err) {
-        console.group(
-          `Unable to parse data-query-param-exclude value on select element '${this.name}'`,
-        );
-        console.warn(err);
-        console.groupEnd();
-      }
-    }
-    return disabledOptions;
-  }
-
-  /**
-   * Get this element's disabled attribute keys. For example, if `disabled-indicator` is set to
-   * `'_occupied'` and an API object contains `{ _occupied: true }`, the option will be disabled.
-   */
-  private getDisabledAttributes(): string[] {
-    let disabled = [...DISABLED_ATTRIBUTES] as string[];
-    const attr = this.base.getAttribute('disabled-indicator');
-    if (isTruthy(attr)) {
-      disabled = [...disabled, attr];
-    }
-    return disabled;
-  }
-
-  /**
-   * Parse the `data-url` attribute to add any Django template variables to `pathValues` as keys
-   * with empty values. As those keys' corresponding form fields' values change, `pathValues` will
-   * be updated to reflect the new value.
-   */
-  private getPathKeys() {
-    for (const result of this.url.matchAll(new RegExp(`{{(.+)}}`, 'g'))) {
-      this.pathValues.set(result[1], '');
-    }
-  }
-
-  /**
-   * Determine if a this instances' options should be filtered by the value of another select
-   * element.
-   *
-   * Looks for the DOM attribute `data-dynamic-params`, the value of which is a JSON array of
-   * objects containing information about how to handle the related field.
-   */
-  private getDynamicParams(): void {
-    const serialized = this.base.getAttribute('data-dynamic-params');
-    try {
-      this.dynamicParams.addFromJson(serialized);
-    } catch (err) {
-      console.group(`Unable to determine dynamic query parameters for select field '${this.name}'`);
-      console.warn(err);
-      console.groupEnd();
-    }
-  }
-
-  /**
-   * Determine if this instance's options should be filtered by static values passed from the
-   * server.
-   *
-   * Looks for the DOM attribute `data-static-params`, the value of which is a JSON array of
-   * objects containing key/value pairs to add to `this.staticParams`.
-   */
-  private getStaticParams(): void {
-    const serialized = this.base.getAttribute('data-static-params');
-
-    try {
-      if (isTruthy(serialized)) {
-        const deserialized = JSON.parse(serialized);
-        if (isStaticParams(deserialized)) {
-          for (const { queryParam, queryValue } of deserialized) {
-            if (Array.isArray(queryValue)) {
-              this.staticParams.set(queryParam, queryValue);
-            } else {
-              this.staticParams.set(queryParam, [queryValue]);
-            }
-          }
-        }
-      }
-    } catch (err) {
-      console.group(`Unable to determine static query parameters for select field '${this.name}'`);
-      console.warn(err);
-      console.groupEnd();
-    }
-  }
-
-  /**
-   * Set the underlying select element to the same size as the SlimSelect instance. This is
-   * primarily for built-in HTML form validation (which doesn't really work) but it also makes
-   * things feel cleaner in the DOM.
-   */
-  private setSlimStyles(): void {
-    const { width, height } = this.slim.slim.container.getBoundingClientRect();
-    this.base.style.opacity = '0';
-    this.base.style.width = `${width}px`;
-    this.base.style.height = `${height}px`;
-    this.base.style.display = 'block';
-    this.base.style.position = 'absolute';
-    this.base.style.pointerEvents = 'none';
-  }
-
-  /**
-   * Add scoped style elements specific to each SlimSelect option, if the color property exists.
-   * As of this writing, this attribute only exist on Tags. The color property is used as the
-   * background color, and a foreground color is detected based on the luminosity of the background
-   * color.
-   */
-  private setOptionStyles(): void {
-    for (const option of this.options) {
-      // Only create style elements for options that contain a color attribute.
-      if (
-        'data' in option &&
-        'id' in option &&
-        typeof option.data !== 'undefined' &&
-        typeof option.id !== 'undefined' &&
-        'color' in option.data
-      ) {
-        const id = option.id as string;
-        const data = option.data as { color: string };
-
-        // Create the style element.
-        const style = document.createElement('style');
-
-        // Append hash to color to make it a valid hex color.
-        const bg = `#${data.color}`;
-        // Detect the foreground color.
-        const fg = readableColor(bg);
-
-        // Add a unique identifier to the style element.
-        style.setAttribute('data-netbox', id);
-
-        // Scope the CSS to apply both the list item and the selected item.
-        style.innerHTML = replaceAll(
-          `
-  div.ss-values div.ss-value[data-id="${id}"],
-  div.ss-list div.ss-option:not(.ss-disabled)[data-id="${id}"]
-   {
-    background-color: ${bg} !important;
-    color: ${fg} !important;
-  }
-              `,
-          '\n',
-          '',
-        ).trim();
-
-        // Add the style element to the DOM.
-        document.head.appendChild(style);
-      }
-    }
-  }
-
-  /**
-   * Remove base element classes from SlimSelect instance.
-   */
-  private resetClasses(): void {
-    const element = this.slim.slim;
-    if (element) {
-      for (const className of this.base.classList) {
-        element.container.classList.remove(className);
-      }
-    }
-  }
-
-  /**
-   * Initialize any adjacent reset buttons so that when clicked, the page is reloaded without
-   * query parameters.
-   */
-  private initResetButton(): void {
-    const resetButton = findFirstAdjacent<HTMLButtonElement>(
-      this.base,
-      'button[data-reset-select]',
-    );
-    if (resetButton !== null) {
-      resetButton.addEventListener('click', () => {
-        window.location.assign(window.location.origin + window.location.pathname);
-      });
-    }
-  }
-
-  /**
-   * Add a refresh button to the search container element. When clicked, the API data will be
-   * reloaded.
-   */
-  private initRefreshButton(): void {
-    if (this.allowRefresh) {
-      const refreshButton = createElement(
-        'button',
-        { type: 'button' },
-        ['btn', 'btn-sm', 'btn-ghost-dark'],
-        [createElement('i', null, ['mdi', 'mdi-reload'])],
-      );
-      refreshButton.addEventListener('click', () => this.loadData());
-      refreshButton.type = 'button';
-      this.slim.slim.search.container.appendChild(refreshButton);
-    }
-  }
-}

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

@@ -1,10 +0,0 @@
-import { getElements } from '../../util';
-import { APISelect } from './apiSelect';
-
-export function initApiSelect(): void {
-  for (const select of getElements<HTMLSelectElement>('.netbox-api-select:not([data-ssid])')) {
-    new APISelect(select);
-  }
-}
-
-export type { Trigger } from './types';

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

@@ -1,199 +0,0 @@
-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
- * URL query parameter keys. Values correspond to query param values, enforced as an array
- * for easier handling. For example, a mapping of `{ site_id: [1, 2] }` is serialized by
- * `query-string` as `?site_id=1&site_id=2`. Likewise, `{ site_id: [1] }` is serialized as
- * `?site_id=1`.
- */
-export type QueryFilter = Map<string, Stringifiable[]>;
-
-/**
- * Tracked data for a related field. This is the value of `APISelect.filterFields`.
- */
-export type FilterFieldValue = {
-  /**
-   * Key to use in the query parameter itself.
-   */
-  queryParam: string;
-  /**
-   * Value to use in the query parameter for the related field.
-   */
-  queryValue: Stringifiable[];
-  /**
-   * @see `DataFilterFields.includeNull`
-   */
-  includeNull: boolean;
-};
-
-/**
- * JSON data structure from `data-dynamic-params` attribute.
- */
-export type DataDynamicParam = {
-  /**
-   * Name of form field to track.
-   *
-   * @example [name="tenant_group"]
-   */
-  fieldName: string;
-  /**
-   * Query param key.
-   *
-   * @example group_id
-   */
-  queryParam: string;
-};
-
-/**
- * `queryParams` Map value.
- */
-export type QueryParam = {
-  queryParam: string;
-  queryValue: Stringifiable[];
-};
-
-/**
- * JSON data structure from `data-static-params` attribute.
- */
-export type DataStaticParam = {
-  queryParam: string;
-  queryValue: Stringifiable | Stringifiable[];
-};
-
-/**
- * JSON data passed from Django on the `data-filter-fields` attribute.
- */
-export type DataFilterFields = {
-  /**
-   * Related field form name (`[name="<fieldName>"]`)
-   *
-   * @example tenant_group
-   */
-  fieldName: string;
-  /**
-   * Key to use in the query parameter itself.
-   *
-   * @example group_id
-   */
-  queryParam: string;
-  /**
-   * Optional default value. If set, value will be added to the query parameters prior to the
-   * initial API call and will be maintained until the field `fieldName` references (if one exists)
-   * is updated with a new value.
-   *
-   * @example 1
-   */
-  defaultValue: Nullable<Stringifiable | Stringifiable[]>;
-  /**
-   * Include `null` on queries for the related field. For example, if `true`, `?<fieldName>=null`
-   * will be added to all API queries for this field.
-   */
-  includeNull: boolean;
-};
-
-/**
- * Map of string keys to primitive values. Used to track variables within URLs from the server. For
- * example, `/api/$key/thing`. `PathFilter` tracks `$key` as `{ key: '' }` in the map, and when the
- * value is later known, the value is set — `{ key: 'value' }`, and the URL is transformed to
- * `/api/value/thing`.
- */
-export type PathFilter = Map<string, Stringifiable>;
-
-/**
- * Merge or replace incoming options with current options.
- */
-export type ApplyMethod = 'merge' | 'replace';
-
-/**
- * Trigger for which the select instance should fetch its data from the NetBox API.
- */
-export type Trigger =
-  /**
-   * Load data when the select element is opened.
-   */
-  | 'open'
-  /**
-   * Load data when the element is loaded.
-   */
-  | 'load'
-  /**
-   * Load data when a parent element is uncollapsed.
-   */
-  | 'collapse';
-
-/**
- * Strict Type Guard to determine if a deserialized value from the `data-filter-fields` attribute
- * is of type `DataFilterFields`.
- *
- * @param value Deserialized value from `data-filter-fields` attribute.
- */
-export function isDataFilterFields(value: unknown): value is DataFilterFields[] {
-  if (Array.isArray(value)) {
-    for (const item of value) {
-      if (typeof item === 'object' && item !== null) {
-        if ('fieldName' in item && 'queryParam' in item) {
-          return (
-            typeof (item as DataFilterFields).fieldName === 'string' &&
-            typeof (item as DataFilterFields).queryParam === 'string'
-          );
-        }
-      }
-    }
-  }
-  return false;
-}
-
-/**
- * Strict Type Guard to determine if a deserialized value from the `data-dynamic-params` attribute
- * is of type `DataDynamicParam[]`.
- *
- * @param value Deserialized value from `data-dynamic-params` attribute.
- */
-export function isDataDynamicParams(value: unknown): value is DataDynamicParam[] {
-  if (Array.isArray(value)) {
-    for (const item of value) {
-      if (typeof item === 'object' && item !== null) {
-        if ('fieldName' in item && 'queryParam' in item) {
-          return (
-            typeof (item as DataDynamicParam).fieldName === 'string' &&
-            typeof (item as DataDynamicParam).queryParam === 'string'
-          );
-        }
-      }
-    }
-  }
-  return false;
-}
-
-/**
- * Strict Type Guard to determine if a deserialized value from the `data-static-params` attribute
- * is of type `DataStaticParam[]`.
- *
- * @param value Deserialized value from `data-static-params` attribute.
- */
-export function isStaticParams(value: unknown): value is DataStaticParam[] {
-  if (Array.isArray(value)) {
-    for (const item of value) {
-      if (typeof item === 'object' && item !== null) {
-        if ('queryParam' in item && 'queryValue' in item) {
-          return (
-            typeof (item as DataStaticParam).queryParam === 'string' &&
-            typeof (item as DataStaticParam).queryValue !== 'undefined'
-          );
-        }
-      }
-    }
-  }
-  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);
-}

+ 2 - 2
netbox/project-static/src/select/api/dynamicParams.ts → netbox/project-static/src/select/classes/dynamicParamsMap.ts

@@ -1,7 +1,7 @@
 import { isTruthy } from '../../util';
-import { isDataDynamicParams } from './types';
+import { isDataDynamicParams } from '../types';
 
-import type { QueryParam } from './types';
+import type { QueryParam } from '../types';
 
 /**
  * Extension of built-in `Map` to add convenience functions.

+ 305 - 0
netbox/project-static/src/select/classes/dynamicTomSelect.ts

@@ -0,0 +1,305 @@
+import { RecursivePartial, TomInput, TomOption, TomSettings } from 'tom-select/dist/types/types';
+import { addClasses } from 'tom-select/src/vanilla'
+import queryString from 'query-string';
+import TomSelect from 'tom-select';
+import type { Stringifiable } from 'query-string';
+import { DynamicParamsMap } from './dynamicParamsMap';
+
+// Transitional
+import { QueryFilter, PathFilter } from '../types'
+import { getElement, replaceAll } from '../../util';
+
+
+// Extends TomSelect to provide enhanced fetching of options via the REST API
+export class DynamicTomSelect extends TomSelect {
+
+  public readonly nullOption: Nullable<TomOption> = null;
+
+  // Transitional code from APISelect
+  private readonly queryParams: QueryFilter = new Map();
+  private readonly staticParams: QueryFilter = new Map();
+  private readonly dynamicParams: DynamicParamsMap = new DynamicParamsMap();
+  private readonly pathValues: PathFilter = new Map();
+
+  /**
+   * Overrides
+   */
+
+  constructor( input_arg: string|TomInput, user_settings: RecursivePartial<TomSettings> ) {
+    super(input_arg, user_settings);
+
+    // Glean the REST API endpoint URL from the <select> element
+    this.api_url = this.input.getAttribute('data-url') as string;
+
+    // Set the null option (if any)
+    const nullOption = this.input.getAttribute('data-null-option');
+    if (nullOption) {
+      let valueField = this.settings.valueField;
+      let labelField = this.settings.labelField;
+      this.nullOption = {}
+      this.nullOption[valueField] = 'null';
+      this.nullOption[labelField] = nullOption;
+    }
+
+    // Populate static query parameters.
+    this.getStaticParams();
+    for (const [key, value] of this.staticParams.entries()) {
+      this.queryParams.set(key, value);
+    }
+
+    // Populate dynamic query parameters
+    this.getDynamicParams();
+    for (const filter of this.dynamicParams.keys()) {
+      this.updateQueryParams(filter);
+    }
+
+    // Path values
+    this.getPathKeys();
+    for (const filter of this.pathValues.keys()) {
+      this.updatePathValues(filter);
+    }
+
+    // Add dependency event listeners.
+    this.addEventListeners();
+  }
+
+  load(value: string) {
+    const self = this;
+    const url = self.getRequestUrl(value);
+
+    // Automatically clear any cached options. (Only options included
+    // in the API response should be present.)
+    self.clearOptions();
+
+    addClasses(self.wrapper, self.settings.loadingClass);
+    self.loading++;
+
+    // Populate the null option (if any) if not searching
+    if (self.nullOption && !value) {
+      self.addOption(self.nullOption);
+    }
+
+    // Make the API request
+    fetch(url)
+      .then(response => response.json())
+      .then(json => {
+          self.loadCallback(json.results, []);
+      }).catch(()=>{
+          self.loadCallback([], []);
+      });
+
+  }
+
+  /**
+   * Custom methods
+   */
+
+  // Formulate and return the complete URL for an API request, including any query parameters.
+  getRequestUrl(search: string): string {
+    let url = this.api_url;
+
+    // Create new URL query parameters based on the current state of `queryParams` and create an
+    // updated API query URL.
+    const query = {} as Dict<Stringifiable[]>;
+    for (const [key, value] of this.queryParams.entries()) {
+      query[key] = value;
+    }
+
+    // Replace any variables in the URL with values from `pathValues` if set.
+    for (const [key, value] of this.pathValues.entries()) {
+      for (const result of this.api_url.matchAll(new RegExp(`({{${key}}})`, 'g'))) {
+        if (value) {
+          url = replaceAll(url, result[1], value.toString());
+        }
+      }
+    }
+
+    // Append the search query, if any
+    if (search) {
+      query['q'] = [search];
+    }
+
+    // Add standard parameters
+    query['brief'] = [true];
+    query['limit'] = [this.settings.maxOptions];
+
+    return queryString.stringifyUrl({ url, query });
+  }
+
+  /**
+   * Transitional methods
+   */
+
+  // Determine if this instance's options should be filtered by static values passed from the
+  // server. Looks for the DOM attribute `data-static-params`, the value of which is a JSON
+  // array of objects containing key/value pairs to add to `this.staticParams`.
+  private getStaticParams(): void {
+    const serialized = this.input.getAttribute('data-static-params');
+
+    try {
+      if (serialized) {
+        const deserialized = JSON.parse(serialized);
+        if (deserialized) {
+          for (const { queryParam, queryValue } of deserialized) {
+            if (Array.isArray(queryValue)) {
+              this.staticParams.set(queryParam, queryValue);
+            } else {
+              this.staticParams.set(queryParam, [queryValue]);
+            }
+          }
+        }
+      }
+    } catch (err) {
+      console.group(`Unable to determine static query parameters for select field '${this.name}'`);
+      console.warn(err);
+      console.groupEnd();
+    }
+  }
+
+  // Determine if this instances' options should be filtered by the value of another select
+  // element. Looks for the DOM attribute `data-dynamic-params`, the value of which is a JSON
+  // array of objects containing information about how to handle the related field.
+  private getDynamicParams(): void {
+    const serialized = this.input.getAttribute('data-dynamic-params');
+    try {
+      this.dynamicParams.addFromJson(serialized);
+    } catch (err) {
+      console.group(`Unable to determine dynamic query parameters for select field '${this.name}'`);
+      console.warn(err);
+      console.groupEnd();
+    }
+  }
+
+
+  // Parse the `data-url` attribute to add any variables to `pathValues` as keys with empty
+  // values. As those keys' corresponding form fields' values change, `pathValues` will be
+  // updated to reflect the new value.
+  private getPathKeys() {
+    for (const result of this.api_url.matchAll(new RegExp(`{{(.+)}}`, 'g'))) {
+      this.pathValues.set(result[1], '');
+    }
+  }
+
+  // Update an element's API URL based on the value of another element on which this element
+  // relies.
+  private updateQueryParams(fieldName: string): void {
+    // Find the element dependency.
+    const element = document.querySelector<HTMLSelectElement>(`[name="${fieldName}"]`);
+    if (element !== null) {
+      // Initialize the element value as an array, in case there are multiple values.
+      let elementValue = [] as Stringifiable[];
+
+      if (element.multiple) {
+        // If this is a multi-select (form filters, tags, etc.), use all selected options as the value.
+        elementValue = Array.from(element.options)
+          .filter(o => o.selected)
+          .map(o => o.value);
+      } else if (element.value !== '') {
+        // If this is single-select (most fields), use the element's value. This seemingly
+        // redundant/verbose check is mainly for performance, so we're not running the above three
+        // functions (`Array.from()`, `Array.filter()`, `Array.map()`) every time every select
+        // field's value changes.
+        elementValue = [element.value];
+      }
+
+      if (elementValue.length > 0) {
+        // If the field has a value, add it to the map.
+        this.dynamicParams.updateValue(fieldName, elementValue);
+        // Get the updated value.
+        const current = this.dynamicParams.get(fieldName);
+
+        if (typeof current !== 'undefined') {
+          const { queryParam, queryValue } = current;
+          let value = [] as Stringifiable[];
+
+          if (this.staticParams.has(queryParam)) {
+            // If the field is defined in `staticParams`, we should merge the dynamic value with
+            // the static value.
+            const staticValue = this.staticParams.get(queryParam);
+            if (typeof staticValue !== 'undefined') {
+              value = [...staticValue, ...queryValue];
+            }
+          } else {
+            // If the field is _not_ defined in `staticParams`, we should replace the current value
+            // with the new dynamic value.
+            value = queryValue;
+          }
+          if (value.length > 0) {
+            this.queryParams.set(queryParam, value);
+          } else {
+            this.queryParams.delete(queryParam);
+          }
+        }
+      } else {
+        // Otherwise, delete it (we don't want to send an empty query like `?site_id=`)
+        const queryParam = this.dynamicParams.queryParam(fieldName);
+        if (queryParam !== null) {
+          this.queryParams.delete(queryParam);
+        }
+      }
+    }
+  }
+
+  // Update `pathValues` based on the form value of another element.
+  private updatePathValues(id: string): void {
+    const key = replaceAll(id, /^id_/i, '');
+    const element = getElement<HTMLSelectElement>(`id_${key}`);
+    if (element !== null) {
+      // If this element's URL contains variable tags ({{), replace the tag with the dependency's
+      // value. For example, if the dependency is the `rack` field, and the `rack` field's value
+      // is `1`, this element's URL would change from `/dcim/racks/{{rack}}/` to `/dcim/racks/1/`.
+      const hasReplacement =
+        this.api_url.includes(`{{`) && Boolean(this.api_url.match(new RegExp(`({{(${id})}})`, 'g')));
+
+      if (hasReplacement) {
+        if (element.value) {
+          // If the field has a value, add it to the map.
+          this.pathValues.set(id, element.value);
+        } else {
+          // Otherwise, reset the value.
+          this.pathValues.set(id, '');
+        }
+      }
+    }
+  }
+
+  /**
+   * Events
+   */
+
+  // Add event listeners to this element and its dependencies so that when dependencies change
+  //this element's options are updated.
+  private addEventListeners(): void {
+    // Create a unique iterator of all possible form fields which, when changed, should cause this
+    // element to update its API query.
+    const dependencies = new Set([...this.dynamicParams.keys(), ...this.pathValues.keys()]);
+
+    for (const dep of dependencies) {
+      const filterElement = document.querySelector(`[name="${dep}"]`);
+      if (filterElement !== null) {
+        // Subscribe to dependency changes.
+        filterElement.addEventListener('change', event => this.handleEvent(event));
+      }
+      // Subscribe to changes dispatched by this state manager.
+      this.input.addEventListener(`netbox.select.onload.${dep}`, event => this.handleEvent(event));
+    }
+  }
+
+  // Event handler to be dispatched any time a dependency's value changes. For example, when the
+  // value of `tenant_group` changes, `handleEvent` is called to get the current value of
+  // `tenant_group` and update the query parameters and API query URL for the `tenant` field.
+  private handleEvent(event: Event): void {
+    const target = event.target as HTMLSelectElement;
+
+    // Update the element's URL after any changes to a dependency.
+    this.updateQueryParams(target.name);
+    this.updatePathValues(target.name);
+
+    // Clear any previous selection(s) as the parent filter has changed
+    this.clear();
+
+    // Load new data.
+    this.load(this.lastValue);
+  }
+
+}

+ 0 - 82
netbox/project-static/src/select/color.ts

@@ -1,82 +0,0 @@
-import SlimSelect from 'slim-select';
-import { readableColor } from 'color2k';
-import { getElements } from '../util';
-
-import type { Option } from 'slim-select/dist/data';
-
-/**
- * Determine if the option has a valid value (i.e., is not the placeholder).
- */
-function canChangeColor(option: Option | HTMLOptionElement): boolean {
-  return typeof option.value === 'string' && option.value !== '';
-}
-
-/**
- * Style the container element based on the selected option value.
- */
-function styleContainer(
-  instance: InstanceType<typeof SlimSelect>,
-  option: Option | HTMLOptionElement,
-): void {
-  if (instance.slim.singleSelected !== null) {
-    if (canChangeColor(option)) {
-      // Get the background color from the selected option's value.
-      const bg = `#${option.value}`;
-      // Determine an accessible foreground color based on the background color.
-      const fg = readableColor(bg);
-
-      // Set the container's style attributes.
-      instance.slim.singleSelected.container.style.backgroundColor = bg;
-      instance.slim.singleSelected.container.style.color = fg;
-    } else {
-      // If the color cannot be set (i.e., the placeholder), remove any inline styles.
-      instance.slim.singleSelected.container.removeAttribute('style');
-    }
-  }
-}
-
-/**
- * Initialize color selection widget. Dynamically change the style of the select container to match
- * the selected option.
- */
-export function initColorSelect(): void {
-  for (const select of getElements<HTMLSelectElement>(
-    'select.netbox-color-select:not([data-ssid])',
-  )) {
-    for (const option of select.options) {
-      if (canChangeColor(option)) {
-        // Get the background color from the option's value.
-        const bg = `#${option.value}`;
-        // Determine an accessible foreground color based on the background color.
-        const fg = readableColor(bg);
-
-        // Set the option's style attributes.
-        option.style.backgroundColor = bg;
-        option.style.color = fg;
-      }
-    }
-
-    const instance = new SlimSelect({
-      select,
-      allowDeselect: true,
-      // Inherit the calculated color on the deselect icon.
-      deselectLabel: `<i class="mdi mdi-close-circle" style="color: currentColor;"></i>`,
-    });
-
-    // Style the select container to match any pre-selectd options.
-    for (const option of instance.data.data) {
-      if ('selected' in option && option.selected) {
-        styleContainer(instance, option);
-        break;
-      }
-    }
-
-    // Don't inherit the select element's classes.
-    for (const className of select.classList) {
-      instance.slim.container.classList.remove(className);
-    }
-
-    // Change the SlimSelect container's style based on the selected option.
-    instance.onChange = option => styleContainer(instance, option);
-  }
-}

+ 9 - 0
netbox/project-static/src/select/config.ts

@@ -0,0 +1,9 @@
+export const config = {
+  plugins: {
+    // Provides the "clear" button on the widget
+    clear_button: {
+      html: (data: Dict) =>
+        `<i class="mdi mdi-close-circle ${data.className}" title="${data.title}"></i>`,
+    },
+  },
+};

+ 51 - 0
netbox/project-static/src/select/dynamic.ts

@@ -0,0 +1,51 @@
+import { TomOption } from 'tom-select/src/types';
+import { escape_html } from 'tom-select/src/utils';
+import { DynamicTomSelect } from './classes/dynamicTomSelect';
+import { config } from './config';
+import { getElements } from '../util';
+
+const VALUE_FIELD = 'id';
+const LABEL_FIELD = 'display';
+const MAX_OPTIONS = 100;
+
+// Render the HTML for a dropdown option
+function renderOption(data: TomOption, escape: typeof escape_html) {
+  // If the option has a `_depth` property, indent its label
+  if (typeof data._depth === 'number' && data._depth > 0) {
+    return `<div>${'─'.repeat(data._depth)} ${escape(data[LABEL_FIELD])}</div>`;
+  }
+
+  return `<div>${escape(data[LABEL_FIELD])}</div>`;
+}
+
+// Initialize <select> elements which are populated via a REST API call
+export function initDynamicSelects(): void {
+  for (const select of getElements<HTMLSelectElement>('select.api-select')) {
+    new DynamicTomSelect(select, {
+      ...config,
+      valueField: VALUE_FIELD,
+      labelField: LABEL_FIELD,
+      maxOptions: MAX_OPTIONS,
+
+      // Disable local search (search is performed on the backend)
+      searchField: [],
+
+      // Reference the disabled-indicator attr on the <select> element to determine
+      // the name of the attribute which indicates whether an option should be disabled
+      disabledField: select.getAttribute('disabled-indicator') || undefined,
+
+      // Load options from API immediately on focus
+      preload: 'focus',
+
+      // Define custom rendering functions
+      render: {
+        option: renderOption,
+      },
+
+      // By default, load() will be called only if query.length > 0
+      shouldLoad: function (): boolean {
+        return true;
+      },
+    });
+  }
+}

+ 6 - 7
netbox/project-static/src/select/index.ts

@@ -1,9 +1,8 @@
-import { initApiSelect } from './api';
-import { initColorSelect } from './color';
-import { initStaticSelect } from './static';
+import { initColorSelects, initStaticSelects } from './static';
+import { initDynamicSelects } from './dynamic';
 
-export function initSelect(): void {
-  for (const func of [initApiSelect, initColorSelect, initStaticSelect]) {
-    func();
-  }
+export function initSelects(): void {
+  initStaticSelects();
+  initDynamicSelects();
+  initColorSelects();
 }

+ 25 - 22
netbox/project-static/src/select/static.ts

@@ -1,27 +1,30 @@
-import SlimSelect from 'slim-select';
+import { TomOption } from 'tom-select/src/types';
+import TomSelect from 'tom-select';
+import { escape_html } from 'tom-select/src/utils';
+import { config } from './config';
 import { getElements } from '../util';
 
-export function initStaticSelect(): void {
-  for (const select of getElements<HTMLSelectElement>('.netbox-static-select:not([data-ssid])')) {
-    if (select !== null) {
-      const label = document.querySelector(`label[for="${select.id}"]`) as HTMLLabelElement;
-
-      let placeholder;
-      if (label !== null) {
-        placeholder = `Select ${label.innerText.trim()}`;
-      }
-
-      const instance = new SlimSelect({
-        select,
-        allowDeselect: true,
-        deselectLabel: `<i class="mdi mdi-close-circle"></i>`,
-        placeholder,
-      });
+// Initialize <select> elements with statically-defined options
+export function initStaticSelects(): void {
+  for (const select of getElements<HTMLSelectElement>(
+    'select:not(.api-select):not(.color-select)',
+  )) {
+    new TomSelect(select, {
+      ...config,
+    });
+  }
+}
 
-      // Don't copy classes from select element to SlimSelect instance.
-      for (const className of select.classList) {
-        instance.slim.container.classList.remove(className);
-      }
-    }
+// Initialize color selection fields
+export function initColorSelects(): void {
+  for (const select of getElements<HTMLSelectElement>('select.color-select')) {
+    new TomSelect(select, {
+      ...config,
+      render: {
+        option: function (item: TomOption, escape: typeof escape_html) {
+          return `<div style="background-color: #${escape(item.value)}">${escape(item.text)}</div>`;
+        },
+      },
+    });
   }
 }

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

@@ -0,0 +1,66 @@
+import type { Stringifiable } from 'query-string';
+
+/**
+ * Map of string keys to primitive array values accepted by `query-string`. Keys are used as
+ * URL query parameter keys. Values correspond to query param values, enforced as an array
+ * for easier handling. For example, a mapping of `{ site_id: [1, 2] }` is serialized by
+ * `query-string` as `?site_id=1&site_id=2`. Likewise, `{ site_id: [1] }` is serialized as
+ * `?site_id=1`.
+ */
+export type QueryFilter = Map<string, Stringifiable[]>;
+
+/**
+ * JSON data structure from `data-dynamic-params` attribute.
+ */
+export type DataDynamicParam = {
+  /**
+   * Name of form field to track.
+   *
+   * @example [name="tenant_group"]
+   */
+  fieldName: string;
+  /**
+   * Query param key.
+   *
+   * @example group_id
+   */
+  queryParam: string;
+};
+
+/**
+ * `queryParams` Map value.
+ */
+export type QueryParam = {
+  queryParam: string;
+  queryValue: Stringifiable[];
+};
+
+/**
+ * Map of string keys to primitive values. Used to track variables within URLs from the server. For
+ * example, `/api/$key/thing`. `PathFilter` tracks `$key` as `{ key: '' }` in the map, and when the
+ * value is later known, the value is set — `{ key: 'value' }`, and the URL is transformed to
+ * `/api/value/thing`.
+ */
+export type PathFilter = Map<string, Stringifiable>;
+
+/**
+ * Strict Type Guard to determine if a deserialized value from the `data-dynamic-params` attribute
+ * is of type `DataDynamicParam[]`.
+ *
+ * @param value Deserialized value from `data-dynamic-params` attribute.
+ */
+export function isDataDynamicParams(value: unknown): value is DataDynamicParam[] {
+  if (Array.isArray(value)) {
+    for (const item of value) {
+      if (typeof item === 'object' && item !== null) {
+        if ('fieldName' in item && 'queryParam' in item) {
+          return (
+            typeof (item as DataDynamicParam).fieldName === 'string' &&
+            typeof (item as DataDynamicParam).queryParam === 'string'
+          );
+        }
+      }
+    }
+  }
+  return false;
+}

+ 0 - 26
netbox/project-static/src/select/util.ts

@@ -1,26 +0,0 @@
-import type { Trigger } from './api';
-
-/**
- * Determine if an element has the `data-url` attribute set.
- */
-export function hasUrl(el: HTMLSelectElement): el is HTMLSelectElement & { 'data-url': string } {
-  const value = el.getAttribute('data-url');
-  return typeof value === 'string' && value !== '';
-}
-
-/**
- * Determine if an element has the `data-query-param-exclude` attribute set.
- */
-export function hasExclusions(
-  el: HTMLSelectElement,
-): el is HTMLSelectElement & { 'data-query-param-exclude': string } {
-  const exclude = el.getAttribute('data-query-param-exclude');
-  return typeof exclude === 'string' && exclude !== '';
-}
-
-/**
- * Determine if a trigger value is valid.
- */
-export function isTrigger(value: unknown): value is Trigger {
-  return typeof value === 'string' && ['load', 'open', 'collapse'].includes(value);
-}

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

@@ -1,10 +1,10 @@
 @import 'variables';
 
-// Tabler
+// Tabler & vendors
 @import '../node_modules/@tabler/core/src/scss/_core.scss';
+@import '../node_modules/@tabler/core/src/scss/vendor/tom-select';
 
 // Overrides of external libraries
-@import 'overrides/slim-select';
 @import 'overrides/tabler';
 
 // Transitional styling to ease migration of templates from NetBox v3.x

+ 0 - 195
netbox/project-static/styles/overrides/_slim-select.scss

@@ -1,195 +0,0 @@
-// SlimSelect Style Overrides.
-
-$height: $input-height;
-$white: $white;
-$font-color: $input-color;
-$font-placeholder-color: $input-placeholder-color;
-$font-disabled-color: $form-select-disabled-color;
-$primary-color: $primary;
-$border-color: $form-select-border-color;
-$search-highlight-color: $yellow;
-$border-radius: $form-select-border-radius;
-$spacing-l: $input-padding-x;
-$spacing-m: $input-padding-x;
-$spacing-s: $input-padding-x;
-
-:root {
-  // Light Mode Variables.
-  --nbx-select-content-bg: #{$form-select-bg};
-  --nbx-select-option-selected-bg: #{$gray-300};
-  --nbx-select-option-hover-bg: #{$blue};
-  --nbx-select-option-hover-color: #{$white};
-  --nbx-select-placeholder-color: #{$gray-500};
-  --nbx-select-value-color: #{$white};
-  &[data-netbox-color-mode='dark'] {
-    // Dark Mode Variables.
-    --nbx-select-content-bg: #{$gray-900};
-    --nbx-select-option-selected-bg: #{$gray-500};
-    --nbx-select-option-hover-bg: #{$blue-200};
-    --nbx-select-option-hover-color: #{color-contrast($blue-200)};
-    --nbx-select-placeholder-color: #{$gray-700};
-    --nbx-select-value-color: #{$black};
-  }
-}
-
-@import '../node_modules/slim-select/src/slim-select/slimselect';
-
-.ss-main {
-  color: $form-select-color;
-
-  .ss-single-selected,
-  .ss-multi-selected {
-    padding: $form-select-padding-y $input-padding-x $form-select-padding-y $form-select-padding-x;
-    background-color: $form-select-bg;
-    border: $form-select-border-width solid $input-border-color;
-    &[disabled] {
-      color: $form-select-disabled-color;
-      background-color: $form-select-disabled-bg;
-      border-color: $form-select-disabled-border-color;
-    }
-  }
-
-  div.ss-multi-selected .ss-values .ss-disabled,
-  div.ss-single-selected span.placeholder .ss-disabled {
-    color: var(--nbx-select-placeholder-color);
-  }
-
-  .ss-single-selected {
-    span.ss-arrow {
-      // Inherit the arrow color from the parent (see color selector).
-      span.arrow-down,
-      span.arrow-up {
-        border-color: currentColor;
-        color: $text-muted;
-      }
-    }
-    // Don't show the depth indicator outside of the menu.
-    .placeholder .depth {
-      display: none;
-    }
-    span.placeholder > *,
-    span.placeholder {
-      line-height: $input-line-height;
-    }
-  }
-
-  .ss-multi-selected {
-    align-items: center;
-    padding-right: $input-padding-x;
-    padding-left: $input-padding-x;
-
-    .ss-values {
-      .ss-disabled {
-        padding: 4px 0;
-      }
-      .ss-value {
-        color: var(--nbx-select-value-color);
-        border-radius: $badge-border-radius;
-
-        // Don't show the depth indicator outside of the menu.
-        .depth {
-          display: none;
-        }
-      }
-    }
-    .ss-add {
-      margin: 0 0.75rem;
-    }
-  }
-
-  .ss-content {
-    background-color: var(--nbx-select-content-bg);
-    .ss-list {
-      .ss-option {
-        &.ss-option-selected {
-          color: $body-color;
-          background-color: var(--nbx-select-option-selected-bg);
-        }
-
-        &:hover {
-          color: var(--nbx-select-option-hover-color);
-          background-color: var(--nbx-select-option-hover-bg);
-        }
-
-        &:last-child {
-          border-bottom-right-radius: $form-select-border-radius;
-          border-bottom-left-radius: $form-select-border-radius;
-        }
-
-        &.ss-disabled {
-          background-color: unset;
-          &:hover {
-            color: $form-select-disabled-color;
-          }
-        }
-
-        .depth {
-          // Lighten the dash prefix on nested options.
-          opacity: 0.3;
-        }
-      }
-
-      &::-webkit-scrollbar {
-        right: 0;
-        width: 4px;
-        &:hover {
-          opacity: 0.8;
-        }
-      }
-
-      &::-webkit-scrollbar-track {
-        background: transparent;
-      }
-
-      &::-webkit-scrollbar-thumb {
-        right: 0;
-        width: 2px;
-        background-color: var(--nbx-sidebar-scroll);
-      }
-    }
-    border-bottom-right-radius: $form-select-border-radius;
-    border-bottom-left-radius: $form-select-border-radius;
-    .ss-search {
-      padding-right: $spacer * 0.5;
-
-      button {
-        margin-left: $spacer * 0.75;
-      }
-
-      input[type='search'] {
-        color: $input-color;
-        background-color: $form-select-bg;
-        border: $input-border-width solid $input-border-color;
-        &:focus {
-          border-color: $form-select-focus-border-color;
-          outline: 0;
-          @if $enable-shadows {
-            @include box-shadow($form-select-box-shadow, $form-select-focus-box-shadow);
-          } @else {
-            // Avoid using mixin so we can pass custom focus shadow properly
-            box-shadow: $form-select-focus-box-shadow;
-          }
-        }
-      }
-    }
-  }
-}
-
-// Fix slim-select 1.x placeholder styling
-.ss-main {
-	.ss-single-selected {
-		.placeholder {
-			cursor: pointer;
-			opacity: 1;
-			background-color: transparent !important;
-		}
-	}
-}
-
-// Apply red border for fields inside a row with .has-errors
-.has-errors {
-  .ss-single-selected,
-  .ss-multi-selected {
-    border-color: $red;
-  }
-}

+ 2 - 1
netbox/project-static/tsconfig.json

@@ -3,7 +3,8 @@
     "forceConsistentCasingInFileNames": true,
     "allowSyntheticDefaultImports": true,
     "moduleResolution": "node",
-    "noUnusedParameters": true,
+    // tom-select v2.3.1 raises several TS6133 errors with noUnusedParameters
+    "noUnusedParameters": false,
     "esModuleInterop": true,
     "isolatedModules": true,
     "noUnusedLocals": true,

+ 237 - 65
netbox/project-static/yarn.lock

@@ -2,6 +2,11 @@
 # yarn lockfile v1
 
 
+"@esbuild/linux-loong64@0.14.54":
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.14.54.tgz#de2a4be678bd4d0d1ffbb86e6de779cde5999028"
+  integrity sha512-bZBrLAIX1kpWelV0XemxBZllyRmM6vgFQQG2GdNb+r3Fkp0FOh1NJSvekXDs7jq70k4euu1cryLMfU+mTXlEpw==
+
 "@eslint/eslintrc@^1.3.2":
   version "1.3.2"
   resolved "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-1.3.2.tgz"
@@ -67,7 +72,7 @@
     "@nodelib/fs.stat" "2.0.5"
     run-parallel "^1.1.9"
 
-"@nodelib/fs.stat@^2.0.2", "@nodelib/fs.stat@2.0.5":
+"@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2":
   version "2.0.5"
   resolved "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz"
   integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==
@@ -80,6 +85,18 @@
     "@nodelib/fs.scandir" "2.1.5"
     fastq "^1.6.0"
 
+"@orchidjs/sifter@^1.0.3":
+  version "1.0.3"
+  resolved "https://registry.npmjs.org/@orchidjs/sifter/-/sifter-1.0.3.tgz"
+  integrity sha512-zCZbwKegHytfsPm8Amcfh7v/4vHqTAaOu6xFswBYcn8nznBOuseu6COB2ON7ez0tFV0mKL0nRNnCiZZA+lU9/g==
+  dependencies:
+    "@orchidjs/unicode-variants" "^1.0.4"
+
+"@orchidjs/unicode-variants@^1.0.4":
+  version "1.0.4"
+  resolved "https://registry.npmjs.org/@orchidjs/unicode-variants/-/unicode-variants-1.0.4.tgz"
+  integrity sha512-NvVBRnZNE+dugiXERFsET1JlKZfM5lJDEpSMilKW4bToYJ7pxf0Zne78xyXB2ny2c2aHfJ6WLnz1AaTNHAmQeQ==
+
 "@pkgr/utils@^2.3.1":
   version "2.3.1"
   resolved "https://registry.npmjs.org/@pkgr/utils/-/utils-2.3.1.tgz"
@@ -92,16 +109,11 @@
     tiny-glob "^0.2.9"
     tslib "^2.4.0"
 
-"@popperjs/core@^2.11.8":
+"@popperjs/core@^2.11.6", "@popperjs/core@^2.11.8", "@popperjs/core@^2.9.2":
   version "2.11.8"
-  resolved "https://registry.npmjs.org/@popperjs/core/-/core-2.11.8.tgz"
+  resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.8.tgz#6b79032e760a0899cd4204710beede972a3a185f"
   integrity sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==
 
-"@popperjs/core@^2.9.2":
-  version "2.11.6"
-  resolved "https://registry.npmjs.org/@popperjs/core/-/core-2.11.6.tgz"
-  integrity sha512-50/17A98tWUfQ176raKiOGXuYpLyyVMkxxG6oylzL3BPOlA6ADGdK7EYunSa4I064xerltq9TGXs8HmOk5E+vw==
-
 "@tabler/core@1.0.0-beta20":
   version "1.0.0-beta20"
   resolved "https://registry.npmjs.org/@tabler/core/-/core-1.0.0-beta20.tgz"
@@ -138,6 +150,13 @@
   resolved "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz"
   integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4=
 
+"@types/node@^20.11.16":
+  version "20.11.16"
+  resolved "https://registry.yarnpkg.com/@types/node/-/node-20.11.16.tgz#4411f79411514eb8e2926f036c86c9f0e4ec6708"
+  integrity sha512-gKb0enTmRCzXSSUJDq6/sPcqrfCv2mkkG6Jt/clpn5eiCbKTY+SgZUxo+p8ZKMof5dCp9vHQUAB7wOUTod22wQ==
+  dependencies:
+    undici-types "~5.26.4"
+
 "@typescript-eslint/eslint-plugin@^5.39.0":
   version "5.39.0"
   resolved "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-5.39.0.tgz"
@@ -152,7 +171,7 @@
     semver "^7.3.7"
     tsutils "^3.21.0"
 
-"@typescript-eslint/parser@^5.0.0", "@typescript-eslint/parser@^5.39.0":
+"@typescript-eslint/parser@^5.39.0":
   version "5.39.0"
   resolved "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-5.39.0.tgz"
   integrity sha512-PhxLjrZnHShe431sBAGHaNe6BDdxAASDySgsBCGxcBecVCi8NQWxQZMcizNA4g0pN51bBAn/FUfkWG3SDVcGlA==
@@ -223,7 +242,7 @@ acorn-jsx@^5.3.2:
   resolved "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz"
   integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==
 
-"acorn@^6.0.0 || ^7.0.0 || ^8.0.0", acorn@^8.8.0:
+acorn@^8.8.0:
   version "8.8.0"
   resolved "https://registry.npmjs.org/acorn/-/acorn-8.8.0.tgz"
   integrity sha512-QOxyigPVrpZ2GXT+PFyZTl6TtOFc5egxHIP9IlQ+RbupQuX4RkT/Bee4/kQuC02Xkzg84JcT7oLYtDIQxp+v7w==
@@ -577,6 +596,71 @@ es-to-primitive@^1.2.1:
     is-date-object "^1.0.1"
     is-symbol "^1.0.2"
 
+esbuild-android-64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-android-64/-/esbuild-android-64-0.14.54.tgz#505f41832884313bbaffb27704b8bcaa2d8616be"
+  integrity sha512-Tz2++Aqqz0rJ7kYBfz+iqyE3QMycD4vk7LBRyWaAVFgFtQ/O8EJOnVmTOiDWYZ/uYzB4kvP+bqejYdVKzE5lAQ==
+
+esbuild-android-arm64@0.13.15:
+  version "0.13.15"
+  resolved "https://registry.yarnpkg.com/esbuild-android-arm64/-/esbuild-android-arm64-0.13.15.tgz#3fc3ff0bab76fe35dd237476b5d2b32bb20a3d44"
+  integrity sha512-m602nft/XXeO8YQPUDVoHfjyRVPdPgjyyXOxZ44MK/agewFFkPa8tUo6lAzSWh5Ui5PB4KR9UIFTSBKh/RrCmg==
+
+esbuild-android-arm64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-android-arm64/-/esbuild-android-arm64-0.14.54.tgz#8ce69d7caba49646e009968fe5754a21a9871771"
+  integrity sha512-F9E+/QDi9sSkLaClO8SOV6etqPd+5DgJje1F9lOWoNncDdOBL2YF59IhsWATSt0TLZbYCf3pNlTHvVV5VfHdvg==
+
+esbuild-darwin-64@0.13.15:
+  version "0.13.15"
+  resolved "https://registry.yarnpkg.com/esbuild-darwin-64/-/esbuild-darwin-64-0.13.15.tgz#8e9169c16baf444eacec60d09b24d11b255a8e72"
+  integrity sha512-ihOQRGs2yyp7t5bArCwnvn2Atr6X4axqPpEdCFPVp7iUj4cVSdisgvEKdNR7yH3JDjW6aQDw40iQFoTqejqxvQ==
+
+esbuild-darwin-64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-darwin-64/-/esbuild-darwin-64-0.14.54.tgz#24ba67b9a8cb890a3c08d9018f887cc221cdda25"
+  integrity sha512-jtdKWV3nBviOd5v4hOpkVmpxsBy90CGzebpbO9beiqUYVMBtSc0AL9zGftFuBon7PNDcdvNCEuQqw2x0wP9yug==
+
+esbuild-darwin-arm64@0.13.15:
+  version "0.13.15"
+  resolved "https://registry.yarnpkg.com/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.13.15.tgz#1b07f893b632114f805e188ddfca41b2b778229a"
+  integrity sha512-i1FZssTVxUqNlJ6cBTj5YQj4imWy3m49RZRnHhLpefFIh0To05ow9DTrXROTE1urGTQCloFUXTX8QfGJy1P8dQ==
+
+esbuild-darwin-arm64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.14.54.tgz#3f7cdb78888ee05e488d250a2bdaab1fa671bf73"
+  integrity sha512-OPafJHD2oUPyvJMrsCvDGkRrVCar5aVyHfWGQzY1dWnzErjrDuSETxwA2HSsyg2jORLY8yBfzc1MIpUkXlctmw==
+
+esbuild-freebsd-64@0.13.15:
+  version "0.13.15"
+  resolved "https://registry.yarnpkg.com/esbuild-freebsd-64/-/esbuild-freebsd-64-0.13.15.tgz#0b8b7eca1690c8ec94c75680c38c07269c1f4a85"
+  integrity sha512-G3dLBXUI6lC6Z09/x+WtXBXbOYQZ0E8TDBqvn7aMaOCzryJs8LyVXKY4CPnHFXZAbSwkCbqiPuSQ1+HhrNk7EA==
+
+esbuild-freebsd-64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-freebsd-64/-/esbuild-freebsd-64-0.14.54.tgz#09250f997a56ed4650f3e1979c905ffc40bbe94d"
+  integrity sha512-OKwd4gmwHqOTp4mOGZKe/XUlbDJ4Q9TjX0hMPIDBUWWu/kwhBAudJdBoxnjNf9ocIB6GN6CPowYpR/hRCbSYAg==
+
+esbuild-freebsd-arm64@0.13.15:
+  version "0.13.15"
+  resolved "https://registry.yarnpkg.com/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.13.15.tgz#2e1a6c696bfdcd20a99578b76350b41db1934e52"
+  integrity sha512-KJx0fzEDf1uhNOZQStV4ujg30WlnwqUASaGSFPhznLM/bbheu9HhqZ6mJJZM32lkyfGJikw0jg7v3S0oAvtvQQ==
+
+esbuild-freebsd-arm64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.14.54.tgz#bafb46ed04fc5f97cbdb016d86947a79579f8e48"
+  integrity sha512-sFwueGr7OvIFiQT6WeG0jRLjkjdqWWSrfbVwZp8iMP+8UHEHRBvlaxL6IuKNDwAozNUmbb8nIMXa7oAOARGs1Q==
+
+esbuild-linux-32@0.13.15:
+  version "0.13.15"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-32/-/esbuild-linux-32-0.13.15.tgz#6fd39f36fc66dd45b6b5f515728c7bbebc342a69"
+  integrity sha512-ZvTBPk0YWCLMCXiFmD5EUtB30zIPvC5Itxz0mdTu/xZBbbHJftQgLWY49wEPSn2T/TxahYCRDWun5smRa0Tu+g==
+
+esbuild-linux-32@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-32/-/esbuild-linux-32-0.14.54.tgz#e2a8c4a8efdc355405325033fcebeb941f781fe5"
+  integrity sha512-1ZuY+JDI//WmklKlBgJnglpUL1owm2OX+8E1syCD6UAxcMM/XoWd76OHSjl/0MR0LisSAXDqgjT3uJqT67O3qw==
+
 esbuild-linux-64@0.13.15:
   version "0.13.15"
   resolved "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.13.15.tgz"
@@ -587,6 +671,76 @@ esbuild-linux-64@0.14.54:
   resolved "https://registry.npmjs.org/esbuild-linux-64/-/esbuild-linux-64-0.14.54.tgz"
   integrity sha512-EgjAgH5HwTbtNsTqQOXWApBaPVdDn7XcK+/PtJwZLT1UmpLoznPd8c5CxqsH2dQK3j05YsB3L17T8vE7cp4cCg==
 
+esbuild-linux-arm64@0.13.15:
+  version "0.13.15"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-arm64/-/esbuild-linux-arm64-0.13.15.tgz#3891aa3704ec579a1b92d2a586122e5b6a2bfba1"
+  integrity sha512-bYpuUlN6qYU9slzr/ltyLTR9YTBS7qUDymO8SV7kjeNext61OdmqFAzuVZom+OLW1HPHseBfJ/JfdSlx8oTUoA==
+
+esbuild-linux-arm64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-arm64/-/esbuild-linux-arm64-0.14.54.tgz#dae4cd42ae9787468b6a5c158da4c84e83b0ce8b"
+  integrity sha512-WL71L+0Rwv+Gv/HTmxTEmpv0UgmxYa5ftZILVi2QmZBgX3q7+tDeOQNqGtdXSdsL8TQi1vIaVFHUPDe0O0kdig==
+
+esbuild-linux-arm@0.13.15:
+  version "0.13.15"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-arm/-/esbuild-linux-arm-0.13.15.tgz#8a00e99e6a0c6c9a6b7f334841364d8a2b4aecfe"
+  integrity sha512-wUHttDi/ol0tD8ZgUMDH8Ef7IbDX+/UsWJOXaAyTdkT7Yy9ZBqPg8bgB/Dn3CZ9SBpNieozrPRHm0BGww7W/jA==
+
+esbuild-linux-arm@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-arm/-/esbuild-linux-arm-0.14.54.tgz#a2c1dff6d0f21dbe8fc6998a122675533ddfcd59"
+  integrity sha512-qqz/SjemQhVMTnvcLGoLOdFpCYbz4v4fUo+TfsWG+1aOu70/80RV6bgNpR2JCrppV2moUQkww+6bWxXRL9YMGw==
+
+esbuild-linux-mips64le@0.13.15:
+  version "0.13.15"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.13.15.tgz#36b07cc47c3d21e48db3bb1f4d9ef8f46aead4f7"
+  integrity sha512-KlVjIG828uFPyJkO/8gKwy9RbXhCEUeFsCGOJBepUlpa7G8/SeZgncUEz/tOOUJTcWMTmFMtdd3GElGyAtbSWg==
+
+esbuild-linux-mips64le@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.14.54.tgz#d9918e9e4cb972f8d6dae8e8655bf9ee131eda34"
+  integrity sha512-qTHGQB8D1etd0u1+sB6p0ikLKRVuCWhYQhAHRPkO+OF3I/iSlTKNNS0Lh2Oc0g0UFGguaFZZiPJdJey3AGpAlw==
+
+esbuild-linux-ppc64le@0.13.15:
+  version "0.13.15"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.13.15.tgz#f7e6bba40b9a11eb9dcae5b01550ea04670edad2"
+  integrity sha512-h6gYF+OsaqEuBjeesTBtUPw0bmiDu7eAeuc2OEH9S6mV9/jPhPdhOWzdeshb0BskRZxPhxPOjqZ+/OqLcxQwEQ==
+
+esbuild-linux-ppc64le@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.14.54.tgz#3f9a0f6d41073fb1a640680845c7de52995f137e"
+  integrity sha512-j3OMlzHiqwZBDPRCDFKcx595XVfOfOnv68Ax3U4UKZ3MTYQB5Yz3X1mn5GnodEVYzhtZgxEBidLWeIs8FDSfrQ==
+
+esbuild-linux-riscv64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.14.54.tgz#618853c028178a61837bc799d2013d4695e451c8"
+  integrity sha512-y7Vt7Wl9dkOGZjxQZnDAqqn+XOqFD7IMWiewY5SPlNlzMX39ocPQlOaoxvT4FllA5viyV26/QzHtvTjVNOxHZg==
+
+esbuild-linux-s390x@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-linux-s390x/-/esbuild-linux-s390x-0.14.54.tgz#d1885c4c5a76bbb5a0fe182e2c8c60eb9e29f2a6"
+  integrity sha512-zaHpW9dziAsi7lRcyV4r8dhfG1qBidQWUXweUjnw+lliChJqQr+6XD71K41oEIC3Mx1KStovEmlzm+MkGZHnHA==
+
+esbuild-netbsd-64@0.13.15:
+  version "0.13.15"
+  resolved "https://registry.yarnpkg.com/esbuild-netbsd-64/-/esbuild-netbsd-64-0.13.15.tgz#a2fedc549c2b629d580a732d840712b08d440038"
+  integrity sha512-3+yE9emwoevLMyvu+iR3rsa+Xwhie7ZEHMGDQ6dkqP/ndFzRHkobHUKTe+NCApSqG5ce2z4rFu+NX/UHnxlh3w==
+
+esbuild-netbsd-64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-netbsd-64/-/esbuild-netbsd-64-0.14.54.tgz#69ae917a2ff241b7df1dbf22baf04bd330349e81"
+  integrity sha512-PR01lmIMnfJTgeU9VJTDY9ZerDWVFIUzAtJuDHwwceppW7cQWjBBqP48NdeRtoP04/AtO9a7w3viI+PIDr6d+w==
+
+esbuild-openbsd-64@0.13.15:
+  version "0.13.15"
+  resolved "https://registry.yarnpkg.com/esbuild-openbsd-64/-/esbuild-openbsd-64-0.13.15.tgz#b22c0e5806d3a1fbf0325872037f885306b05cd7"
+  integrity sha512-wTfvtwYJYAFL1fSs8yHIdf5GEE4NkbtbXtjLWjM3Cw8mmQKqsg8kTiqJ9NJQe5NX/5Qlo7Xd9r1yKMMkHllp5g==
+
+esbuild-openbsd-64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-openbsd-64/-/esbuild-openbsd-64-0.14.54.tgz#db4c8495287a350a6790de22edea247a57c5d47b"
+  integrity sha512-Qyk7ikT2o7Wu76UsvvDS5q0amJvmRzDyVlL0qf5VLsLchjCa1+IAvd8kTBgUxD7VBUUVgItLkk609ZHUc1oCaw==
+
 esbuild-sass-plugin@^2.3.3:
   version "2.3.3"
   resolved "https://registry.npmjs.org/esbuild-sass-plugin/-/esbuild-sass-plugin-2.3.3.tgz"
@@ -596,6 +750,46 @@ esbuild-sass-plugin@^2.3.3:
     resolve "^1.22.1"
     sass "^1.49.0"
 
+esbuild-sunos-64@0.13.15:
+  version "0.13.15"
+  resolved "https://registry.yarnpkg.com/esbuild-sunos-64/-/esbuild-sunos-64-0.13.15.tgz#d0b6454a88375ee8d3964daeff55c85c91c7cef4"
+  integrity sha512-lbivT9Bx3t1iWWrSnGyBP9ODriEvWDRiweAs69vI+miJoeKwHWOComSRukttbuzjZ8r1q0mQJ8Z7yUsDJ3hKdw==
+
+esbuild-sunos-64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-sunos-64/-/esbuild-sunos-64-0.14.54.tgz#54287ee3da73d3844b721c21bc80c1dc7e1bf7da"
+  integrity sha512-28GZ24KmMSeKi5ueWzMcco6EBHStL3B6ubM7M51RmPwXQGLe0teBGJocmWhgwccA1GeFXqxzILIxXpHbl9Q/Kw==
+
+esbuild-windows-32@0.13.15:
+  version "0.13.15"
+  resolved "https://registry.yarnpkg.com/esbuild-windows-32/-/esbuild-windows-32-0.13.15.tgz#c96d0b9bbb52f3303322582ef8e4847c5ad375a7"
+  integrity sha512-fDMEf2g3SsJ599MBr50cY5ve5lP1wyVwTe6aLJsM01KtxyKkB4UT+fc5MXQFn3RLrAIAZOG+tHC+yXObpSn7Nw==
+
+esbuild-windows-32@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-windows-32/-/esbuild-windows-32-0.14.54.tgz#f8aaf9a5667630b40f0fb3aa37bf01bbd340ce31"
+  integrity sha512-T+rdZW19ql9MjS7pixmZYVObd9G7kcaZo+sETqNH4RCkuuYSuv9AGHUVnPoP9hhuE1WM1ZimHz1CIBHBboLU7w==
+
+esbuild-windows-64@0.13.15:
+  version "0.13.15"
+  resolved "https://registry.yarnpkg.com/esbuild-windows-64/-/esbuild-windows-64-0.13.15.tgz#1f79cb9b1e1bb02fb25cd414cb90d4ea2892c294"
+  integrity sha512-9aMsPRGDWCd3bGjUIKG/ZOJPKsiztlxl/Q3C1XDswO6eNX/Jtwu4M+jb6YDH9hRSUflQWX0XKAfWzgy5Wk54JQ==
+
+esbuild-windows-64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-windows-64/-/esbuild-windows-64-0.14.54.tgz#bf54b51bd3e9b0f1886ffdb224a4176031ea0af4"
+  integrity sha512-AoHTRBUuYwXtZhjXZbA1pGfTo8cJo3vZIcWGLiUcTNgHpJJMC1rVA44ZereBHMJtotyN71S8Qw0npiCIkW96cQ==
+
+esbuild-windows-arm64@0.13.15:
+  version "0.13.15"
+  resolved "https://registry.yarnpkg.com/esbuild-windows-arm64/-/esbuild-windows-arm64-0.13.15.tgz#482173070810df22a752c686509c370c3be3b3c3"
+  integrity sha512-zzvyCVVpbwQQATaf3IG8mu1IwGEiDxKkYUdA4FpoCHi1KtPa13jeScYDjlW0Qh+ebWzpKfR2ZwvqAQkSWNcKjA==
+
+esbuild-windows-arm64@0.14.54:
+  version "0.14.54"
+  resolved "https://registry.yarnpkg.com/esbuild-windows-arm64/-/esbuild-windows-arm64-0.14.54.tgz#937d15675a15e4b0e4fafdbaa3a01a776a2be982"
+  integrity sha512-M0kuUvXhot1zOISQGXwWn6YtS+Y/1RT9WrVIOywZnJHo3jCDyewAc79aKNQWFCQm+xNHVTq9h8dZKvygoXQQRg==
+
 esbuild@^0.13.15:
   version "0.13.15"
   resolved "https://registry.npmjs.org/esbuild/-/esbuild-0.13.15.tgz"
@@ -689,7 +883,7 @@ eslint-module-utils@^2.7.3:
   dependencies:
     debug "^3.2.7"
 
-eslint-plugin-import@*, eslint-plugin-import@^2.26.0:
+eslint-plugin-import@^2.26.0:
   version "2.26.0"
   resolved "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.26.0.tgz"
   integrity sha512-hYfi3FXaM8WPLf4S1cikh/r4IxnO6zrhZbEGz2b660EJRbuxgpDS5gkCuYgGWg2xxh2rBuIr4Pvhve/7c31koA==
@@ -748,7 +942,7 @@ eslint-visitor-keys@^3.3.0:
   resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz"
   integrity sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA==
 
-eslint@*, "eslint@^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8", "eslint@^6.0.0 || ^7.0.0 || ^8.0.0", eslint@^8.24.0, eslint@>=5, eslint@>=7.0.0, eslint@>=7.28.0:
+eslint@^8.24.0:
   version "8.24.0"
   resolved "https://registry.npmjs.org/eslint/-/eslint-8.24.0.tgz"
   integrity sha512-dWFaPhGhTAiPcCgm3f6LI2MBWbogMnTJzFBbhXVRQDJPkr9pGZvVjlVfXd+vyDcWPA2Ic9L2AXPIQM0+vk/cSQ==
@@ -909,7 +1103,7 @@ flat-cache@^3.0.4:
     flatted "^3.1.0"
     rimraf "^3.0.2"
 
-flatpickr@^4.6.13, flatpickr@4.6.13:
+flatpickr@4.6.13:
   version "4.6.13"
   resolved "https://registry.npmjs.org/flatpickr/-/flatpickr-4.6.13.tgz"
   integrity sha512-97PMG/aywoYpB4IvbvUJi0RQi8vearvU0oov1WW3k0WZPBMrTQVqekSX5CjSG/M4Q3i6A/0FKXC7RyAoAUUSPw==
@@ -924,6 +1118,11 @@ fs.realpath@^1.0.0:
   resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz"
   integrity sha1-FQStJSMVjKpA20onh8sBQRmU6k8=
 
+fsevents@~2.3.2:
+  version "2.3.3"
+  resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6"
+  integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==
+
 function-bind@^1.1.1:
   version "1.1.1"
   resolved "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz"
@@ -1089,7 +1288,7 @@ graphql-language-service@^5.0.6:
     nullthrows "^1.0.0"
     vscode-languageserver-types "^3.15.1"
 
-"graphql@^15.5.0 || ^16.0.0", "graphql@>= v14.5.0 <= 15.5.0", graphql@>=0.10.0:
+"graphql@>= v14.5.0 <= 15.5.0":
   version "15.5.0"
   resolved "https://registry.npmjs.org/graphql/-/graphql-15.5.0.tgz"
   integrity sha512-OmaM7y0kaK31NKG31q4YbD2beNYa6jBBKtMFT6gLYJljHLJr42IqJ8KX08u3Li/0ifzTU5HjmoOOrwa5BRLeDA==
@@ -1121,12 +1320,7 @@ has-property-descriptors@^1.0.0:
   dependencies:
     get-intrinsic "^1.1.1"
 
-has-symbols@^1.0.1:
-  version "1.0.2"
-  resolved "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz"
-  integrity sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==
-
-has-symbols@^1.0.2:
+has-symbols@^1.0.1, has-symbols@^1.0.2:
   version "1.0.2"
   resolved "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.2.tgz"
   integrity sha512-chXa79rL/UC2KlX17jo3vRGz0azaWEx5tGqZg5pO3NUyEJVB17dMruQlzCCOfUvElghKcm5194+BCRvi2Rv/Gw==
@@ -1268,14 +1462,7 @@ is-extglob@^2.1.1:
   resolved "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz"
   integrity sha1-qIwCU1eR8C7TfHahueqXc8gz+MI=
 
-is-glob@^4.0.0:
-  version "4.0.1"
-  resolved "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz"
-  integrity sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==
-  dependencies:
-    is-extglob "^2.1.1"
-
-is-glob@^4.0.1:
+is-glob@^4.0.0, is-glob@^4.0.1, is-glob@~4.0.1:
   version "4.0.1"
   resolved "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz"
   integrity sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==
@@ -1289,13 +1476,6 @@ is-glob@^4.0.3:
   dependencies:
     is-extglob "^2.1.1"
 
-is-glob@~4.0.1:
-  version "4.0.1"
-  resolved "https://registry.npmjs.org/is-glob/-/is-glob-4.0.1.tgz"
-  integrity sha512-5G0tKtBTFImOqDnLB2hG6Bp2qcKEFduo4tZu9MT/H6NQv/ghhy30o55ufafxJ/LdH79LLs2Kfrn85TLKyA7BUg==
-  dependencies:
-    is-extglob "^2.1.1"
-
 is-negative-zero@^2.0.2:
   version "2.0.2"
   resolved "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz"
@@ -1417,11 +1597,6 @@ json5@^1.0.1:
   dependencies:
     minimist "^1.2.0"
 
-just-debounce-it@^3.1.1:
-  version "3.1.1"
-  resolved "https://registry.npmjs.org/just-debounce-it/-/just-debounce-it-3.1.1.tgz"
-  integrity sha512-oPsuRyWp99LJaQ4KXC3A42tQNqkRTcPy0A8BCkRZ5cPCgsx81upB2KUrmHZvDUNhnCDKe7MshfTuWFQB9iXwDg==
-
 levn@^0.4.1:
   version "0.4.1"
   resolved "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz"
@@ -1521,11 +1696,6 @@ minimist@^1.2.6:
   resolved "https://registry.npmjs.org/minimist/-/minimist-1.2.6.tgz"
   integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==
 
-ms@^2.1.1:
-  version "2.1.3"
-  resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz"
-  integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
-
 ms@2.0.0:
   version "2.0.0"
   resolved "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz"
@@ -1536,22 +1706,16 @@ ms@2.1.2:
   resolved "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz"
   integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==
 
+ms@^2.1.1:
+  version "2.1.3"
+  resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz"
+  integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
+
 natural-compare@^1.4.0:
   version "1.4.0"
   resolved "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz"
   integrity sha1-Sr6/7tdUHywnrPspvbvRXI1bpPc=
 
-"netbox-graphiql@file:/home/jstretch/projects/netbox/netbox/project-static/netbox-graphiql":
-  version "0.1.0"
-  resolved "file:netbox-graphiql"
-  dependencies:
-    graphiql "1.8.9"
-    graphql ">= v14.5.0 <= 15.5.0"
-    react "17.0.2"
-    react-dom "17.0.2"
-    subscriptions-transport-ws "0.9.18"
-    whatwg-fetch "3.6.2"
-
 normalize-path@^3.0.0, normalize-path@~3.0.0:
   version "3.0.0"
   resolved "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz"
@@ -1697,7 +1861,7 @@ prettier-linter-helpers@^1.0.0:
   dependencies:
     fast-diff "^1.1.2"
 
-prettier@^2.7.1, prettier@>=2.0.0:
+prettier@^2.7.1:
   version "2.7.1"
   resolved "https://registry.npmjs.org/prettier/-/prettier-2.7.1.tgz"
   integrity sha512-ujppO+MkdPqoVINuDFDRLClm7D78qbDt0/NR+wp5FqEZOoTNAjPHWj17QRhu7geIHJfcNhRk1XVQmF8Bp3ye+g==
@@ -1722,7 +1886,7 @@ queue-microtask@^1.2.2:
   resolved "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz"
   integrity sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==
 
-"react-dom@^16.8.0 || ^17.0.0 || ^18.0.0", react-dom@17.0.2:
+react-dom@17.0.2:
   version "17.0.2"
   resolved "https://registry.npmjs.org/react-dom/-/react-dom-17.0.2.tgz"
   integrity sha512-s4h96KtLDUQlsENhMn1ar8t2bEa+q/YAtj8pPPdIjPDGBDIVNsrD9aXNWqspUe6AzKCIG0C1HZZLqLV7qpOBGA==
@@ -1731,7 +1895,7 @@ queue-microtask@^1.2.2:
     object-assign "^4.1.1"
     scheduler "^0.20.2"
 
-"react@^16.8.0 || ^17.0.0 || ^18.0.0", react@17.0.2:
+react@17.0.2:
   version "17.0.2"
   resolved "https://registry.npmjs.org/react/-/react-17.0.2.tgz"
   integrity sha512-gnhPt75i/dq/z3/6q/0asP78D0u592D5L1pd7M8P+dck6Fu/jJeL6iVVK23fptSUZj8Vjf++7wXA8UNclGQcbA==
@@ -1878,11 +2042,6 @@ slash@^4.0.0:
   resolved "https://registry.npmjs.org/slash/-/slash-4.0.0.tgz"
   integrity sha512-3dOsAHXXUkQTpOYcoAxLIorMTp4gIQr5IW3iVb7A7lFIp0VHhnynm9izx6TssdrIcVIESAlVjtnO2K8bg+Coew==
 
-slim-select@^1.27.1:
-  version "1.27.1"
-  resolved "https://registry.npmjs.org/slim-select/-/slim-select-1.27.1.tgz"
-  integrity sha512-LvJ02cKKk6/jSHIcQv7dZwkQSXHLCVQR3v3lo8RJUssUUcmKPkpBmTpQ8au8KSMkxwca9+yeg+dO0iHAaVr5Aw==
-
 "source-map-js@>=0.6.2 <2.0.0":
   version "1.0.2"
   resolved "https://registry.npmjs.org/source-map-js/-/source-map-js-1.0.2.tgz"
@@ -2004,6 +2163,14 @@ toggle-selection@^1.0.6:
   resolved "https://registry.npmjs.org/toggle-selection/-/toggle-selection-1.0.6.tgz"
   integrity sha1-bkWxJj8gF/oKzH2J14sVuL932jI=
 
+tom-select@^2.3.1:
+  version "2.3.1"
+  resolved "https://registry.npmjs.org/tom-select/-/tom-select-2.3.1.tgz"
+  integrity sha512-QS4vnOcB6StNGqX4sGboGXL2fkhBF2gIBB+8Hwv30FZXYPn0CyYO8kkdATRvwfCTThxiR4WcXwKJZ3cOmtI9eg==
+  dependencies:
+    "@orchidjs/sifter" "^1.0.3"
+    "@orchidjs/unicode-variants" "^1.0.4"
+
 tsconfig-paths@^3.14.1:
   version "3.14.1"
   resolved "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.14.1.tgz"
@@ -2053,7 +2220,7 @@ typeface-roboto-mono@^1.1.13:
   resolved "https://registry.npmjs.org/typeface-roboto-mono/-/typeface-roboto-mono-1.1.13.tgz"
   integrity sha512-pnzDc70b7ywJHin/BUFL7HZX8DyOTBLT2qxlJ92eH1UJOFcENIBXa9IZrxsJX/gEKjbEDKhW5vz/TKRBNk/ufQ==
 
-"typescript@>=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta", typescript@~4.8.4:
+typescript@~4.8.4:
   version "4.8.4"
   resolved "https://registry.npmjs.org/typescript/-/typescript-4.8.4.tgz"
   integrity sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ==
@@ -2073,6 +2240,11 @@ unbox-primitive@^1.0.2:
     has-symbols "^1.0.3"
     which-boxed-primitive "^1.0.2"
 
+undici-types@~5.26.4:
+  version "5.26.5"
+  resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-5.26.5.tgz#bcd539893d00b56e964fd2657a4866b221a65617"
+  integrity sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==
+
 uri-js@^4.2.2:
   version "4.4.1"
   resolved "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz"

+ 1 - 1
netbox/templates/django/forms/widgets/select.html

@@ -1,4 +1,4 @@
-<select name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %} class="{% if 'size' in widget.attrs %}form-select form-select-sm{% else %}netbox-static-select{% endif %}{% if 'class' in widget.attrs %} {{ widget.attrs.class }}{% endif %}">{% for group_name, group_choices, group_index in widget.optgroups %}{% if group_name %}
+<select name="{{ widget.name }}"{% include "django/forms/widgets/attrs.html" %} class="form-select {% if 'class' in widget.attrs %} {{ widget.attrs.class }}{% endif %}">{% for group_name, group_choices, group_index in widget.optgroups %}{% if group_name %}
   <optgroup label="{{ group_name }}">{% endif %}{% for option in group_choices %}
   {% include option.template_name with widget=option %}{% endfor %}{% if group_name %}
   </optgroup>{% endif %}{% endfor %}

+ 0 - 77
netbox/templates/extras/htmx/report_result.html

@@ -1,77 +0,0 @@
-{% load humanize %}
-{% load helpers %}
-{% load i18n %}
-
-<p>
-  {% if job.started %}
-    {% trans "Started" %}: <strong>{{ job.started|annotated_date }}</strong>
-  {% elif job.scheduled %}
-    {% trans "Scheduled for" %}: <strong>{{ job.scheduled|annotated_date }}</strong> ({{ job.scheduled|naturaltime }})
-  {% else %}
-    {% trans "Created" %}: <strong>{{ job.created|annotated_date }}</strong>
-  {% endif %}
-  {% if job.completed %}
-    {% trans "Duration" %}: <strong>{{ job.duration }}</strong>
-  {% endif %}
-  <span id="pending-result-label">{% badge job.get_status_display job.get_status_color %}</span>
-</p>
-{% if job.completed %}
-  <div class="card">
-    <h5 class="card-header">{% trans "Report Methods" %}</h5>
-    <table class="table table-hover">
-      {% for method, data in job.data.items %}
-        <tr>
-          <td class="font-monospace"><a href="#{{ method }}">{{ method }}</a></td>
-          <td class="text-end report-stats">
-            <span class="badge text-bg-success">{{ data.success }}</span>
-            <span class="badge text-bg-info">{{ data.info }}</span>
-            <span class="badge text-bg-warning">{{ data.warning }}</span>
-            <span class="badge text-bg-danger">{{ data.failure }}</span>
-          </td>
-        </tr>
-      {% endfor %}
-    </table>
-  </div>
-  <div class="card">
-    <h5 class="card-header">{% trans "Report Results" %}</h5>
-    <table class="table table-hover report">
-      <thead>
-        <tr>
-          <th>{% trans "Time" %}</th>
-          <th>{% trans "Level" %}</th>
-          <th>{% trans "Object" %}</th>
-          <th>{% trans "Message" %}</th>
-        </tr>
-      </thead>
-      <tbody>
-        {% for method, data in job.data.items %}
-          <tr>
-            <th colspan="4" style="font-family: monospace">
-              <a name="{{ method }}"></a>{{ method }}
-            </th>
-          </tr>
-          {% for time, level, obj, url, message in data.log %}
-            <tr class="{% if level == 'failure' %}danger{% elif level %}{{ level }}{% endif %}">
-              <td>{{ time }}</td>
-              <td>
-                <label class="badge text-bg-{% if level == 'failure' %}danger{% else %}{{ level }}{% endif %}">{{ level|title }}</label>
-              </td>
-              <td>
-                {% if obj and url %}
-                  <a href="{{ url }}">{{ obj }}</a>
-                {% elif obj %}
-                  {{ obj }}
-                {% else %}
-                  {{ ''|placeholder }}
-                {% endif %}
-              </td>
-              <td class="rendered-markdown">{{ message|markdown }}</td>
-            </tr>
-          {% endfor %}
-        {% endfor %}
-      </tbody>
-    </table>
-  </div>
-{% elif job.started %}
-  {% include 'extras/inc/result_pending.html' %}
-{% endif %}

+ 103 - 33
netbox/templates/extras/htmx/script_result.html

@@ -17,39 +17,109 @@
   <span id="pending-result-label">{% badge job.get_status_display job.get_status_color %}</span>
 </p>
 {% if job.completed %}
-  <div class="card mb-3">
-    <h5 class="card-header">{% trans "Script Log" %}</h5>
-    <table class="table table-hover">
-      <tr>
-        <th>{% trans "Line" %}</th>
-        <th>{% trans "Level" %}</th>
-        <th>{% trans "Message" %}</th>
-      </tr>
-      {% for log in job.data.log %}
-        <tr>
-          <td>{{ forloop.counter }}</td>
-          <td>{% log_level log.status %}</td>
-          <td class="rendered-markdown">{{ log.message|markdown }}</td>
-        </tr>
-      {% empty %}
-        <tr>
-          <td colspan="3" class="text-center text-muted">
-            {% trans "No log output" %}
-          </td>
-        </tr>
-      {% endfor %}
-    </table>
-    {% if execution_time %}
-      <div class="card-footer text-end text-muted">
-        <small>{% trans "Exec Time" %}: {{ execution_time|floatformat:3 }} {% trans "seconds" context "Unit of time" %}</small>
-      </div>
-    {% endif %}
-  </div>
-  <h4>{% trans "Output" %}</h4>
-  {% if job.data.output %}
-    <pre class="block">{{ job.data.output }}</pre>
-  {% else %}
-    <p class="text-muted">{% trans "None" %}</p>
+
+  {# Script log. Legacy reports will not have this. #}
+  {% if 'log' in job.data %}
+    <div class="card mb-3">
+      <h5 class="card-header">{% trans "Log" %}</h5>
+      {% if job.data.log %}
+        <table class="table table-hover panel-body">
+          <tr>
+            <th>{% trans "Line" %}</th>
+            <th>{% trans "Time" %}</th>
+            <th>{% trans "Level" %}</th>
+            <th>{% trans "Message" %}</th>
+          </tr>
+          {% for log in job.data.log %}
+            <tr>
+              <td>{{ forloop.counter }}</td>
+              <td>{{ log.time|placeholder }}</td>
+              <td>{% log_level log.status %}</td>
+              <td>{{ log.message|markdown }}</td>
+            </tr>
+          {% endfor %}
+        </table>
+      {% else %}
+        <div class="card-body text-muted">{% trans "None" %}</div>
+      {% endif %}
+    </div>
+  {% endif %}
+
+  {# Script output. Legacy reports will not have this. #}
+  {% if 'output' in job.data %}
+    <div class="card mb-3">
+    <h5 class="card-header">{% trans "Output" %}</h5>
+      {% if job.data.output %}
+        <pre class="card-body font-monospace">{{ job.data.output }}</pre>
+      {% else %}
+        <div class="card-body text-muted">{% trans "None" %}</div>
+      {% endif %}
+    </div>
+  {% endif %}
+
+  {# Test method logs (for legacy Reports) #}
+  {% if tests %}
+
+    {# Summary of test methods #}
+    <div class="card">
+      <h5 class="card-header">{% trans "Test Summary" %}</h5>
+      <table class="table table-hover">
+        {% for test, data in tests.items %}
+          <tr>
+            <td class="font-monospace"><a href="#{{ test }}">{{ test }}</a></td>
+            <td class="text-end report-stats">
+              <span class="badge text-bg-success">{{ data.success }}</span>
+              <span class="badge text-bg-info">{{ data.info }}</span>
+              <span class="badge text-bg-warning">{{ data.warning }}</span>
+              <span class="badge text-bg-danger">{{ data.failure }}</span>
+            </td>
+          </tr>
+        {% endfor %}
+      </table>
+    </div>
+
+    {# Detailed results for individual tests #}
+    <div class="card">
+      <h5 class="card-header">{% trans "Test Details" %}</h5>
+      <table class="table table-hover report">
+        <thead>
+          <tr class="table-headings">
+            <th>{% trans "Time" %}</th>
+            <th>{% trans "Level" %}</th>
+            <th>{% trans "Object" %}</th>
+            <th>{% trans "Message" %}</th>
+          </tr>
+        </thead>
+        <tbody>
+          {% for test, data in tests.items %}
+            <tr>
+              <th colspan="4" style="font-family: monospace">
+                <a name="{{ test }}"></a>{{ test }}
+              </th>
+            </tr>
+            {% for time, level, obj, url, message in data.log %}
+              <tr class="{% if level == 'failure' %}danger{% elif level %}{{ level }}{% endif %}">
+                <td>{{ time }}</td>
+                <td>
+                  <label class="badge text-bg-{% if level == 'failure' %}danger{% else %}{{ level }}{% endif %}">{{ level|title }}</label>
+                </td>
+                <td>
+                  {% if obj and url %}
+                    <a href="{{ url }}">{{ obj }}</a>
+                  {% elif obj %}
+                    {{ obj }}
+                  {% else %}
+                    {{ ''|placeholder }}
+                  {% endif %}
+                </td>
+                <td class="rendered-markdown">{{ message|markdown }}</td>
+              </tr>
+            {% endfor %}
+          {% endfor %}
+        </tbody>
+      </table>
+    </div>
+
   {% endif %}
 {% elif job.started %}
   {% include 'extras/inc/result_pending.html' %}

+ 0 - 43
netbox/templates/extras/report.html

@@ -1,43 +0,0 @@
-{% extends 'extras/report/base.html' %}
-{% load helpers %}
-{% load form_helpers %}
-{% load i18n %}
-
-{% block content %}
-  <div role="tabpanel" class="tab-pane active" id="report">
-    {% if perms.extras.run_report %}
-      <div class="row">
-        <div class="col">
-          {% if not report.is_valid %}
-            <div class="alert alert-warning">
-              <i class="mdi mdi-alert"></i>
-              {% trans "This report is invalid and cannot be run." %}
-            </div>
-          {% endif %}
-          <form action="{% url 'extras:report' module=report.module name=report.class_name %}" method="post" class="object-edit">
-            {% csrf_token %}
-            {% render_form form %}
-            <div class="float-end">
-              <button type="submit" name="_run" class="btn btn-primary"{% if not report.is_valid %} disabled{% endif %}>
-                {% if report.result %}
-                  <i class="mdi mdi-replay"></i> {% trans "Run Again" %}
-                {% else %}
-                  <i class="mdi mdi-play"></i> {% trans "Run Report" %}
-                {% endif %}
-              </button>
-            </div>
-          </form>
-        </div>
-      </div>
-    {% endif %}
-    <div class="row">
-      <div class="col col-md-12">
-        {% if report.result %}
-          {% trans "Last run" %}: <a href="{% url 'extras:report_result' job_pk=report.result.pk %}">
-            <strong>{{ report.result.created|annotated_date }}</strong>
-          </a>
-        {% endif %}
-      </div>
-    </div>
-  </div>
-{% endblock content %}

+ 0 - 128
netbox/templates/extras/report_list.html

@@ -1,128 +0,0 @@
-{% extends 'generic/_base.html' %}
-{% load buttons %}
-{% load helpers %}
-{% load perms %}
-{% load i18n %}
-
-{% block title %}{% trans "Reports" %}{% endblock %}
-
-{% block tabs %}
-  <ul class="nav nav-tabs">
-    <li class="nav-item" role="presentation">
-      <a class="nav-link active" role="tab">{% trans "Reports" %}</a>
-    </li>
-  </ul>
-{% endblock tabs %}
-
-{% block controls %}
-  {% add_button model %}
-{% endblock controls %}
-
-{% block content %}
-  {% for module in report_modules %}
-    <div class="card">
-      <h5 class="card-header justify-content-between" id="module{{ module.pk }}">
-        <div>
-          <i class="mdi mdi-file-document-outline"></i> {{ module }}
-        </div>
-        {% if perms.extras.delete_reportmodule %}
-          <a href="{% url 'extras:reportmodule_delete' pk=module.pk %}" class="btn btn-danger btn-sm">
-            <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> {% trans "Delete" %}
-          </a>
-        {% endif %}
-      </h5>
-      <div class="card-body">
-        {% include 'inc/sync_warning.html' with object=module %}
-        {% if module.reports %}
-          <table class="table table-hover reports">
-            <thead>
-              <tr>
-                <th width="250">{% trans "Name" %}</th>
-                <th>{% trans "Description" %}</th>
-                <th>{% trans "Last Run" %}</th>
-                <th>{% trans "Status" %}</th>
-                <th width="120"></th>
-              </tr>
-            </thead>
-            <tbody>
-              {% with jobs=module.get_latest_jobs %}
-                {% for report_name, report in module.reports.items %}
-                  {% with last_job=jobs|get_key:report.class_name %}
-                    <tr>
-                      <td>
-                        <a href="{% url 'extras:report' module=module.python_name name=report.class_name %}" id="{{ report.module }}.{{ report.class_name }}">{{ report.name }}</a>
-                      </td>
-                      <td>{{ report.description|markdown|placeholder }}</td>
-                      {% if last_job %}
-                        <td>
-                          <a href="{% url 'extras:report_result' job_pk=last_job.pk %}">{{ last_job.created|annotated_date }}</a>
-                        </td>
-                        <td>
-                          {% badge last_job.get_status_display last_job.get_status_color %}
-                        </td>
-                      {% else %}
-                        <td class="text-muted">{% trans "Never" %}</td>
-                        <td>
-                          {% if report.is_valid %}
-                            {{ ''|placeholder }}
-                          {% else %}
-                            <span class="badge text-bg-danger" title="{% trans "Report has no test methods" %}">
-                              {% trans "Invalid" %}
-                            </span>
-                          {% endif %}
-                        </td>
-                      {% endif %}
-                      <td>
-                        {% if perms.extras.run_report and report.is_valid %}
-                          <div class="float-end d-print-none">
-                            <form action="{% url 'extras:report' module=report.module name=report.class_name %}" method="post">
-                              {% csrf_token %}
-                              <button type="submit" name="_run" class="btn btn-primary" style="width: 110px">
-                                {% if last_job %}
-                                  <i class="mdi mdi-replay"></i> {% trans "Run Again" %}
-                                {% else %}
-                                  <i class="mdi mdi-play"></i> {% trans "Run Report" %}
-                                {% endif %}
-                              </button>
-                            </form>
-                          </div>
-                        {% endif %}
-                      </td>
-                    </tr>
-                    {% for method, stats in last_job.data.items %}
-                      <tr>
-                        <td colspan="4" class="method">
-                          <span class="ps-3">{{ method }}</span>
-                        </td>
-                        <td class="text-end text-nowrap report-stats">
-                          <span class="badge text-bg-success">{{ stats.success }}</span>
-                          <span class="badge text-bg-info">{{ stats.info }}</span>
-                          <span class="badge text-bg-warning">{{ stats.warning }}</span>
-                          <span class="badge text-bg-danger">{{ stats.failure }}</span>
-                        </td>
-                      </tr>
-                    {% endfor %}
-                  {% endwith %}
-                {% endfor %}
-              {% endwith %}
-            </tbody>
-          </table>
-        {% else %}
-          <div class="alert alert-warning" role="alert">
-            <i class="mdi mdi-alert"></i> Could not load reports from {{ module.name }}
-          </div>
-        {% endif %}
-      </div>
-    </div>
-  {% empty %}
-    <div class="alert alert-info" role="alert">
-      <h4 class="alert-heading">{% trans "No Reports Found" %}</h4>
-      {% if perms.extras.add_reportmodule %}
-        {% url 'extras:reportmodule_add' as create_report_url %}
-        {% blocktrans trimmed %}
-          Get started by <a href="{{ create_report_url }}">creating a report</a> from an uploaded file or data source.
-        {% endblocktrans %}
-      {% endif %}
-    </div>
-  {% endfor %}
-{% endblock content %}

+ 0 - 17
netbox/templates/extras/report_result.html

@@ -1,17 +0,0 @@
-{% extends 'extras/report.html' %}
-{% load buttons %}
-{% load perms %}
-
-{% block controls %}
-  {% if request.user|can_delete:job %}
-    {% delete_button job %}
-  {% endif %}
-{% endblock controls %}
-
-{% block content %}
-  <div class="row">
-    <div class="col col-md-12"{% if not job.completed %} hx-get="{% url 'extras:report_result' job_pk=job.pk %}" hx-trigger="load delay:0.5s, every 5s"{% endif %}>
-      {% include 'extras/htmx/report_result.html' %}
-    </div>
-  </div>
-{% endblock content %}

+ 93 - 52
netbox/templates/extras/script_list.html

@@ -1,14 +1,11 @@
 {% extends 'generic/_base.html' %}
 {% load buttons %}
 {% load helpers %}
+{% load perms %}
 {% load i18n %}
 
 {% block title %}{% trans "Scripts" %}{% endblock %}
 
-{% block controls %}
-  {% add_button model %}
-{% endblock controls %}
-
 {% block tabs %}
   <ul class="nav nav-tabs">
     <li class="nav-item" role="presentation">
@@ -17,73 +14,117 @@
   </ul>
 {% endblock tabs %}
 
+{% block controls %}
+  {% add_button model %}
+{% endblock controls %}
+
 {% block content %}
   {% for module in script_modules %}
+    {% include 'inc/sync_warning.html' with object=module %}
     <div class="card">
       <h5 class="card-header justify-content-between" id="module{{ module.pk }}">
         <div>
           <i class="mdi mdi-file-document-outline"></i> {{ module }}
         </div>
         {% if perms.extras.delete_scriptmodule %}
-          <div class="float-end">
-            <a href="{% url 'extras:scriptmodule_delete' pk=module.pk %}" class="btn btn-danger btn-sm">
-              <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> {% trans "Delete" %}
-            </a>
-          </div>
+          <a href="{% url 'extras:scriptmodule_delete' pk=module.pk %}" class="btn btn-danger btn-sm">
+            <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> {% trans "Delete" %}
+          </a>
         {% endif %}
       </h5>
-      <div class="card-body">
-        {% include 'inc/sync_warning.html' with object=module %}
-        {% if not module.scripts %}
-          <div class="alert alert-warning d-flex align-items-center" role="alert">
-            <i class="mdi mdi-alert"></i>
-            {% blocktrans trimmed with file_path=module.full_path %}
-              Script file at <code class="mx-1">{{ file_path }}</code> could not be loaded.
-            {% endblocktrans %}
-          </div>
-        {% else %}
-          <table class="table table-hover reports">
-            <thead>
-              <tr>
-                <th width="250">{% trans "Name" %}</th>
-                <th>{% trans "Description" %}</th>
-                <th>{% trans "Last Run" %}</th>
-                <th class="text-end">{% trans "Status" %}</th>
-              </tr>
-            </thead>
-            <tbody>
-              {% with jobs=module.get_latest_jobs %}
-                {% for script_name, script_class in module.scripts.items %}
+      {% if module.scripts %}
+        <table class="table table-hover scripts">
+          <thead>
+            <tr>
+              <th>{% trans "Name" %}</th>
+              <th>{% trans "Description" %}</th>
+              <th>{% trans "Last Run" %}</th>
+              <th>{% trans "Status" %}</th>
+              <th></th>
+            </tr>
+          </thead>
+          <tbody>
+            {% with jobs=module.get_latest_jobs %}
+              {% for script_name, script in module.scripts.items %}
+                {% with last_job=jobs|get_key:script.class_name %}
                   <tr>
                     <td>
-                      <a href="{% url 'extras:script' module=module.python_name name=script_name %}" name="script.{{ script_name }}">{{ script_class.name }}</a>
+                      <a href="{% url 'extras:script' module=module.python_name name=script.class_name %}" id="{{ script.module }}.{{ script.class_name }}">{{ script.name }}</a>
                     </td>
+                    <td>{{ script.description|markdown|placeholder }}</td>
+                    {% if last_job %}
+                      <td>
+                        <a href="{% url 'extras:script_result' job_pk=last_job.pk %}">{{ last_job.created|annotated_date }}</a>
+                      </td>
+                      <td>
+                        {% badge last_job.get_status_display last_job.get_status_color %}
+                      </td>
+                    {% else %}
+                      <td class="text-muted">{% trans "Never" %}</td>
+                      <td>{{ ''|placeholder }}</td>
+                    {% endif %}
                     <td>
-                      {{ script_class.Meta.description|markdown|placeholder }}
+                      {% if perms.extras.run_script %}
+                        <div class="float-end d-print-none">
+                          <form action="{% url 'extras:script' module=script.module name=script.class_name %}" method="post">
+                            {% csrf_token %}
+                            <button type="submit" name="_run" class="btn btn-primary btn-sm">
+                              {% if last_job %}
+                                <i class="mdi mdi-replay"></i> {% trans "Run Again" %}
+                              {% else %}
+                                <i class="mdi mdi-play"></i> {% trans "Run Script" %}
+                              {% endif %}
+                            </button>
+                          </form>
+                        </div>
+                      {% endif %}
                     </td>
-                    {% with last_result=jobs|get_key:script_class.class_name %}
-                      {% if last_result %}
-                        <td>
-                          <a href="{% url 'extras:script_result' job_pk=last_result.pk %}">{{ last_result.created|annotated_date }}</a>
+                  </tr>
+                  {% if last_job %}
+                    {% for test_name, data in last_job.data.tests.items %}
+                      <tr>
+                        <td colspan="4" class="method">
+                          <span class="ps-3">{{ test_name }}</span>
                         </td>
-                        <td class="text-end">
-                          {% badge last_result.get_status_display last_result.get_status_color %}
+                        <td class="text-end text-nowrap script-stats">
+                          <span class="badge text-bg-success">{{ data.success }}</span>
+                          <span class="badge text-bg-info">{{ data.info }}</span>
+                          <span class="badge text-bg-warning">{{ data.warning }}</span>
+                          <span class="badge text-bg-danger">{{ data.failure }}</span>
                         </td>
-                      {% else %}
-                        <td class="text-muted">{% trans "Never" %}</td>
-                        <td class="text-end">{{ ''|placeholder }}</td>
-                      {% endif %}
-                    {% endwith %}
-                  </tr>
-                {% endfor %}
-              {% endwith %}
-            </tbody>
-          </table>
-        {% endif %}
-      </div>
+                      </tr>
+                    {% endfor %}
+                  {% elif not last_job.data.log %}
+                    {# legacy #}
+                    {% for method, stats in last_job.data.items %}
+                      <tr>
+                        <td colspan="4" class="method">
+                          <span class="ps-3">{{ method }}</span>
+                        </td>
+                        <td class="text-end text-nowrap report-stats">
+                          <span class="badge bg-success">{{ stats.success }}</span>
+                          <span class="badge bg-info">{{ stats.info }}</span>
+                          <span class="badge bg-warning">{{ stats.warning }}</span>
+                          <span class="badge bg-danger">{{ stats.failure }}</span>
+                        </td>
+                      </tr>
+                    {% endfor %}
+                  {% endif %}
+                {% endwith %}
+              {% endfor %}
+            {% endwith %}
+          </tbody>
+        </table>
+      {% else %}
+        <div class="card-body">
+          <div class="alert alert-warning" role="alert">
+            <i class="mdi mdi-alert"></i> Could not load scripts from {{ module.name }}
+          </div>
+        </div>
+      {% endif %}
     </div>
   {% empty %}
-    <div class="alert alert-info">
+    <div class="alert alert-info" role="alert">
       <h4 class="alert-heading">{% trans "No Scripts Found" %}</h4>
       {% if perms.extras.add_scriptmodule %}
         {% url 'extras:scriptmodule_add' as create_script_url %}

+ 2 - 4
netbox/users/forms/model_forms.py

@@ -186,8 +186,7 @@ class UserForm(forms.ModelForm):
     object_permissions = DynamicModelMultipleChoiceField(
         required=False,
         label=_('Permissions'),
-        queryset=ObjectPermission.objects.all(),
-        to_field_name='pk',
+        queryset=ObjectPermission.objects.all()
     )
 
     fieldsets = (
@@ -244,8 +243,7 @@ class GroupForm(forms.ModelForm):
     object_permissions = DynamicModelMultipleChoiceField(
         required=False,
         label=_('Permissions'),
-        queryset=ObjectPermission.objects.all(),
-        to_field_name='pk',
+        queryset=ObjectPermission.objects.all()
     )
 
     fieldsets = (

+ 0 - 16
netbox/utilities/fields.py

@@ -12,26 +12,10 @@ __all__ = (
     'ColorField',
     'CounterCacheField',
     'NaturalOrderingField',
-    'NullableCharField',
     'RestrictedGenericForeignKey',
 )
 
 
-# Deprecated: Retained only to ensure successful migration from early releases
-# Use models.CharField(null=True) instead
-# TODO: Remove in v4.0
-class NullableCharField(models.CharField):
-    description = "Stores empty values as NULL rather than ''"
-
-    def to_python(self, value):
-        if isinstance(value, models.CharField):
-            return value
-        return value or ''
-
-    def get_prep_value(self, value):
-        return value or None
-
-
 class ColorField(models.CharField):
     default_validators = [ColorValidator]
     description = "A hexadecimal RGB color code"

+ 1 - 21
netbox/utilities/forms/fields/dynamic.py

@@ -64,8 +64,6 @@ class DynamicModelChoiceMixin:
         null_option: The string used to represent a null selection (if any)
         disabled_indicator: The name of the field which, if populated, will disable selection of the
             choice (optional)
-        fetch_trigger: The event type which will cause the select element to
-            fetch data from the API. Must be 'load', 'open', or 'collapse'. (optional)
         selector: Include an advanced object selection widget to assist the user in identifying the desired object
     """
     filter = django_filters.ModelChoiceFilter
@@ -79,8 +77,6 @@ class DynamicModelChoiceMixin:
             initial_params=None,
             null_option=None,
             disabled_indicator=None,
-            fetch_trigger=None,
-            empty_label=None,
             selector=False,
             **kwargs
     ):
@@ -89,24 +85,12 @@ class DynamicModelChoiceMixin:
         self.initial_params = initial_params or {}
         self.null_option = null_option
         self.disabled_indicator = disabled_indicator
-        self.fetch_trigger = fetch_trigger
         self.selector = selector
 
-        # 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__(queryset, **kwargs)
 
     def widget_attrs(self, widget):
-        attrs = {
-            'data-empty-option': self.empty_option
-        }
-
-        # Set value-field attribute if the field specifies to_field_name
-        if self.to_field_name:
-            attrs['value-field'] = self.to_field_name
+        attrs = {}
 
         # Set the string used to represent a null option
         if self.null_option is not None:
@@ -116,10 +100,6 @@ class DynamicModelChoiceMixin:
         if self.disabled_indicator is not None:
             attrs['disabled-indicator'] = self.disabled_indicator
 
-        # Set the fetch trigger, if any.
-        if self.fetch_trigger is not None:
-            attrs['data-fetch-trigger'] = self.fetch_trigger
-
         # Attach any static query parameters
         if (len(self.query_params) > 0):
             widget.add_query_params(self.query_params)

+ 2 - 6
netbox/utilities/forms/widgets/apiselect.py

@@ -24,7 +24,7 @@ class APISelect(forms.Select):
     def __init__(self, api_url=None, full=False, *args, **kwargs):
         super().__init__(*args, **kwargs)
 
-        self.attrs['class'] = 'netbox-api-select'
+        self.attrs['class'] = 'api-select'
         self.dynamic_params: Dict[str, List[str]] = {}
         self.static_params: Dict[str, List[str]] = {}
 
@@ -153,8 +153,4 @@ class APISelect(forms.Select):
 
 
 class APISelectMultiple(APISelect, forms.SelectMultiple):
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        self.attrs['data-multiple'] = 1
+    pass

+ 1 - 2
netbox/utilities/forms/widgets/select.py

@@ -25,7 +25,6 @@ class BulkEditNullBooleanSelect(forms.NullBooleanSelect):
             ('2', 'Yes'),
             ('3', 'No'),
         )
-        self.attrs['class'] = 'netbox-static-select'
 
 
 class ColorSelect(forms.Select):
@@ -37,7 +36,7 @@ class ColorSelect(forms.Select):
     def __init__(self, *args, **kwargs):
         kwargs['choices'] = add_blank_choice(ColorChoices)
         super().__init__(*args, **kwargs)
-        self.attrs['class'] = 'netbox-color-select'
+        self.attrs['class'] = 'color-select'
 
 
 class HTMXSelect(forms.Select):

+ 1 - 2
netbox/vpn/forms/model_forms.py

@@ -423,8 +423,7 @@ class L2VPNTerminationForm(NetBoxModelForm):
         queryset=L2VPN.objects.all(),
         required=True,
         query_params={},
-        label=_('L2VPN'),
-        fetch_trigger='open'
+        label=_('L2VPN')
     )
     vlan = DynamicModelChoiceField(
         queryset=VLAN.objects.all(),

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