diff --git a/.github/ISSUE_TEMPLATE/01-feature_request.yaml b/.github/ISSUE_TEMPLATE/01-feature_request.yaml index 557cb3d63..1a7c49682 100644 --- a/.github/ISSUE_TEMPLATE/01-feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/01-feature_request.yaml @@ -15,7 +15,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v4.3.3 + placeholder: v4.3.5 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/02-bug_report.yaml b/.github/ISSUE_TEMPLATE/02-bug_report.yaml index 77ca1ecc9..c6d28fbe6 100644 --- a/.github/ISSUE_TEMPLATE/02-bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/02-bug_report.yaml @@ -27,7 +27,7 @@ body: attributes: label: NetBox Version description: What version of NetBox are you currently running? - placeholder: v4.3.3 + placeholder: v4.3.5 validations: required: true - type: dropdown diff --git a/.gitignore b/.gitignore index e04e44a30..eb1eccbef 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,7 @@ yarn-error.log* /netbox/netbox/configuration.py /netbox/netbox/ldap_config.py /netbox/local/* +/netbox/media /netbox/reports/* !/netbox/reports/__init__.py /netbox/scripts/* diff --git a/README.md b/README.md index 745205a24..66560dd2a 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Contributors GitHub stars Languages supported - CI status + CI status

NetBox Community | NetBox Cloud | diff --git a/base_requirements.txt b/base_requirements.txt index e52ff042e..8d74ede6c 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -1,3 +1,7 @@ +# Shell text coloring +# https://github.com/tartley/colorama/blob/master/CHANGELOG.rst +colorama + # The Python web framework on which NetBox is built # https://docs.djangoproject.com/en/stable/releases/ Django==5.2.* @@ -8,12 +12,18 @@ django-cors-headers # Runtime UI tool for debugging Django # https://github.com/jazzband/django-debug-toolbar/blob/main/docs/changes.rst -django-debug-toolbar +# django-debug-toolbar v6.0.0 raises "Attribute Error at /: 'function' object has no attribute 'set'" +# see https://github.com/netbox-community/netbox/issues/19974 +django-debug-toolbar==5.2.0 # Library for writing reusable URL query filters # https://github.com/carltongibson/django-filter/blob/main/CHANGES.rst django-filter +# Django Debug Toolbar extension for GraphiQL +# https://github.com/flavors/django-graphiql-debug-toolbar/blob/main/CHANGES.rst +django-graphiql-debug-toolbar + # HTMX utilities for Django # https://django-htmx.readthedocs.io/en/latest/changelog.html django-htmx @@ -108,6 +118,7 @@ nh3 # Fork of PIL (Python Imaging Library) for image processing # https://github.com/python-pillow/Pillow/releases +# https://pillow.readthedocs.io/en/stable/releasenotes/ Pillow # PostgreSQL database adapter for Python @@ -126,13 +137,17 @@ requests # https://github.com/rq/rq/blob/master/CHANGES.md rq +# Django app for social-auth-core +# https://github.com/python-social-auth/social-app-django/blob/master/CHANGELOG.md +social-auth-app-django + # Social authentication framework # https://github.com/python-social-auth/social-core/blob/master/CHANGELOG.md social-auth-core -# Django app for social-auth-core -# https://github.com/python-social-auth/social-app-django/blob/master/CHANGELOG.md -social-auth-app-django +# Image thumbnail generation +# https://github.com/jazzband/sorl-thumbnail/blob/master/CHANGES.rst +sorl-thumbnail # Strawberry GraphQL # https://github.com/strawberry-graphql/strawberry/blob/main/CHANGELOG.md @@ -140,8 +155,7 @@ strawberry-graphql # Strawberry GraphQL Django extension # https://github.com/strawberry-graphql/strawberry-django/releases -# See #19771 -strawberry-graphql-django==0.60.0 +strawberry-graphql-django # SVG image rendering (used for rack elevations) # https://github.com/mozman/svgwrite/blob/master/NEWS.rst diff --git a/contrib/netbox-housekeeping.service b/contrib/netbox-housekeeping.service deleted file mode 100644 index 4b0361fcb..000000000 --- a/contrib/netbox-housekeeping.service +++ /dev/null @@ -1,17 +0,0 @@ -[Unit] -Description=NetBox Housekeeping Service -Documentation=https://docs.netbox.dev/ -After=network-online.target -Wants=network-online.target - -[Service] -Type=simple - -User=netbox -Group=netbox -WorkingDirectory=/opt/netbox - -ExecStart=/opt/netbox/venv/bin/python /opt/netbox/netbox/manage.py housekeeping - -[Install] -WantedBy=multi-user.target diff --git a/contrib/netbox-housekeeping.sh b/contrib/netbox-housekeeping.sh deleted file mode 100755 index 5b1c46c5e..000000000 --- a/contrib/netbox-housekeeping.sh +++ /dev/null @@ -1,9 +0,0 @@ -#!/bin/sh -# This shell script invokes NetBox's housekeeping management command, which -# intended to be run nightly. This script can be copied into your system's -# daily cron directory (e.g. /etc/cron.daily), or referenced directly from -# within the cron configuration file. -# -# If NetBox has been installed into a nonstandard location, update the paths -# below. -/opt/netbox/venv/bin/python /opt/netbox/netbox/manage.py housekeeping diff --git a/contrib/netbox-housekeeping.timer b/contrib/netbox-housekeeping.timer deleted file mode 100644 index 16facb05c..000000000 --- a/contrib/netbox-housekeeping.timer +++ /dev/null @@ -1,13 +0,0 @@ -[Unit] -Description=NetBox Housekeeping Timer -Documentation=https://docs.netbox.dev/ -After=network-online.target -Wants=network-online.target - -[Timer] -OnCalendar=daily -AccuracySec=1h -Persistent=true - -[Install] -WantedBy=multi-user.target diff --git a/docs/administration/housekeeping.md b/docs/administration/housekeeping.md deleted file mode 100644 index 674ceb312..000000000 --- a/docs/administration/housekeeping.md +++ /dev/null @@ -1,49 +0,0 @@ -# Housekeeping - -NetBox includes a `housekeeping` management command that should be run nightly. This command handles: - -* Clearing expired authentication sessions from the database -* Deleting changelog records older than the configured [retention time](../configuration/miscellaneous.md#changelog_retention) -* Deleting job result records older than the configured [retention time](../configuration/miscellaneous.md#job_retention) -* Check for new NetBox releases (if [`RELEASE_CHECK_URL`](../configuration/miscellaneous.md#release_check_url) is set) - -This command can be invoked directly, or by using the shell script provided at `/opt/netbox/contrib/netbox-housekeeping.sh`. - -## Scheduling - -### Using Cron - -This script can be linked from your cron scheduler's daily jobs directory (e.g. `/etc/cron.daily`) or referenced directly within the cron configuration file. - -```shell -sudo ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping -``` - -!!! note - On Debian-based systems, be sure to omit the `.sh` file extension when linking to the script from within a cron directory. Otherwise, the task may not run. - -### Using Systemd - -First, create symbolic links for the systemd service and timer files. Link the existing service and timer files from the `/opt/netbox/contrib/` directory to the `/etc/systemd/system/` directory: - -```bash -sudo ln -s /opt/netbox/contrib/netbox-housekeeping.service /etc/systemd/system/netbox-housekeeping.service -sudo ln -s /opt/netbox/contrib/netbox-housekeeping.timer /etc/systemd/system/netbox-housekeeping.timer -``` - -Then, reload the systemd configuration and enable the timer to start automatically at boot: - -```bash -sudo systemctl daemon-reload -sudo systemctl enable --now netbox-housekeeping.timer -``` - -Check the status of your timer by running: - -```bash -sudo systemctl list-timers --all -``` - -This command will show a list of all timers, including your `netbox-housekeeping.timer`. Make sure the timer is active and properly scheduled. - -That's it! Your NetBox housekeeping service is now configured to run daily using systemd. diff --git a/docs/administration/replicating-netbox.md b/docs/administration/replicating-netbox.md index f702c3ffd..8aa423ef6 100644 --- a/docs/administration/replicating-netbox.md +++ b/docs/administration/replicating-netbox.md @@ -18,10 +18,10 @@ pg_dump --username netbox --password --host localhost netbox > netbox.sql !!! note You may need to change the username, host, and/or database in the command above to match your installation. -When replicating a production database for development purposes, you may find it convenient to exclude changelog data, which can easily account for the bulk of a database's size. To do this, exclude the `extras_objectchange` table data from the export. The table will still be included in the output file, but will not be populated with any data. +When replicating a production database for development purposes, you may find it convenient to exclude changelog data, which can easily account for the bulk of a database's size. To do this, exclude the `core_objectchange` table data from the export. The table will still be included in the output file, but will not be populated with any data. ```no-highlight -pg_dump ... --exclude-table-data=extras_objectchange netbox > netbox.sql +pg_dump ... --exclude-table-data=core_objectchange netbox > netbox.sql ``` ### Load an Exported Database diff --git a/docs/configuration/miscellaneous.md b/docs/configuration/miscellaneous.md index 1fbd0ee35..18de6458d 100644 --- a/docs/configuration/miscellaneous.md +++ b/docs/configuration/miscellaneous.md @@ -108,8 +108,6 @@ By default, NetBox will prevent the creation of duplicate prefixes and IP addres ## EVENTS_PIPELINE -!!! info "This parameter was introduced in NetBox v4.2." - Default: `['extras.events.process_event_queue',]` NetBox will call dotted paths to the functions listed here for events (create, update, delete) on models as well as when custom EventRules are fired. diff --git a/docs/configuration/required-parameters.md b/docs/configuration/required-parameters.md index 25aa7978a..19222740d 100644 --- a/docs/configuration/required-parameters.md +++ b/docs/configuration/required-parameters.md @@ -34,8 +34,6 @@ See the [`DATABASES`](#databases) configuration below for usage. ## DATABASES -!!! info "This parameter was introduced in NetBox v4.3." - NetBox requires access to a PostgreSQL 14 or later database service to store data. This service can run locally on the NetBox server or on a remote system. Databases are defined as named dictionaries: ```python diff --git a/docs/configuration/system.md b/docs/configuration/system.md index 20143276c..89e7d8d8e 100644 --- a/docs/configuration/system.md +++ b/docs/configuration/system.md @@ -14,8 +14,6 @@ BASE_PATH = 'netbox/' ## DATABASE_ROUTERS -!!! info "This parameter was introduced in NetBox v4.3." - Default: `[]` (empty list) An iterable of [database routers](https://docs.djangoproject.com/en/stable/topics/db/multi-db/) to use for automatically selecting the appropriate database(s) for a query. This is useful only when [multiple databases](./required-parameters.md#databases) have been configured. @@ -72,6 +70,16 @@ Email is sent from NetBox only for critical events or if configured for [logging --- +## HOSTNAME + +!!! info "This parameter was introduced in NetBox v4.4." + +Default: System hostname + +The hostname displayed in the user interface identifying the system on which NetBox is running. If not defined, this defaults to the system hostname as reported by Python's `platform.node()`. + +--- + ## HTTP_PROXIES Default: `None` @@ -158,6 +166,8 @@ LOGGING = { * `netbox..` - Generic form for model-specific log messages * `netbox.auth.*` - Authentication events * `netbox.api.views.*` - Views which handle business logic for the REST API +* `netbox.event_rules` - Event rules +* `netbox.jobs.*` - Background jobs * `netbox.reports.*` - Report execution (`module.name`) * `netbox.scripts.*` - Custom script execution (`module.name`) * `netbox.views.*` - Views which handle business logic for the web UI @@ -174,8 +184,6 @@ The file path to the location where media files (such as image attachments) are ## PROXY_ROUTERS -!!! info "This parameter was introduced in NetBox v4.3." - Default: `["utilities.proxy.DefaultProxyRouter"]` A list of Python classes responsible for determining which proxy server(s) to use for outbound HTTP requests. Each item in the list can be the class itself or the dotted path to the class. diff --git a/docs/customization/custom-scripts.md b/docs/customization/custom-scripts.md index e7536a654..df9437634 100644 --- a/docs/customization/custom-scripts.md +++ b/docs/customization/custom-scripts.md @@ -275,6 +275,15 @@ Stores a numeric integer. Options include: * `min_value` - Minimum value * `max_value` - Maximum value +### DecimalVar + +Stores a numeric decimal. Options include: + +* `min_value` - Minimum value +* `max_value` - Maximum value +* `max_digits` - Maximum number of digits, including decimal places +* `decimal_places` - Number of decimal places + ### BooleanVar A true/false flag. This field has no options beyond the defaults listed above. diff --git a/docs/development/getting-started.md b/docs/development/getting-started.md index 129bf2d4b..8add82c90 100644 --- a/docs/development/getting-started.md +++ b/docs/development/getting-started.md @@ -147,7 +147,7 @@ For UI development you will need to review the [Web UI Development Guide](web-ui ## Populating Demo Data -Once you have your development environment up and running, it might be helpful to populate some "dummy" data to make interacting with the UI and APIs more convenient. Check out the [netbox-demo-data](https://github.com/netbox-community/netbox-demo-data) repo on GitHub, which houses a collection of sample data that can be easily imported to any new NetBox deployment. (This sample data is used to populate the public demo instance at .) +Once you have your development environment up and running, it might be helpful to populate some "dummy" data to make interacting with the UI and APIs more convenient. Check out the [netbox-demo-data](https://github.com/netbox-community/netbox-demo-data) repo on GitHub, which houses a collection of sample data that can be easily imported to any new NetBox deployment. This sample data is used to populate the [public demo instance](https://demo.netbox.dev). The demo data is provided in JSON format and loaded into an empty database using Django's `loaddata` management command. Consult the demo data repo's `README` file for complete instructions on populating the data. diff --git a/docs/features/background-jobs.md b/docs/features/background-jobs.md index 204951ba7..fe4a6337a 100644 --- a/docs/features/background-jobs.md +++ b/docs/features/background-jobs.md @@ -2,9 +2,9 @@ NetBox includes the ability to execute certain functions as background tasks. These include: -* [Report](../customization/reports.md) execution * [Custom script](../customization/custom-scripts.md) execution * Synchronization of [remote data sources](../integrations/synchronized-data.md) +* Housekeeping tasks Additionally, NetBox plugins can enqueue their own background tasks. This is accomplished using the [Job model](../models/core/job.md). Background tasks are executed by the `rqworker` process(es). diff --git a/docs/features/change-logging.md b/docs/features/change-logging.md index 919f59110..73e23709c 100644 --- a/docs/features/change-logging.md +++ b/docs/features/change-logging.md @@ -8,6 +8,12 @@ When a request is made, a UUID is generated and attached to any change records r Change records are exposed in the API via the read-only endpoint `/api/extras/object-changes/`. They may also be exported via the web UI in CSV format. +## User Messages + +!!! info "This feature was introduced in NetBox v4.4." + +When creating, modifying, or deleting an object in NetBox, a user has the option of recording an arbitrary message that will appear in the change record. This can be helpful to capture additional context, such as the reason for the change. + ## Correlating Changes by Request Every request made to NetBox is assigned a random unique ID that can be used to correlate change records. For example, if you change the status of three sites using the UI's bulk edit feature, you will see three new change records (one for each site) all referencing the same request ID. This shows that all three changes were made as part of the same request. diff --git a/docs/features/ipam.md b/docs/features/ipam.md index 3cbe4319d..5879a5fde 100644 --- a/docs/features/ipam.md +++ b/docs/features/ipam.md @@ -62,8 +62,8 @@ VRF modeling in NetBox very closely follows what you find in real-world network An often overlooked component of IPAM, NetBox also tracks autonomous system (AS) numbers and their assignment to sites. Both 16- and 32-bit AS numbers are supported, and like aggregates each ASN is assigned to an authoritative RIR. -## Service Mapping +## Application Service Mapping NetBox models network applications as discrete service objects associated with devices and/or virtual machines, and optionally with specific IP addresses attached to those parent objects. These can be used to catalog the applications running on your network for reference by other objects or integrated tools. -To model services in NetBox, begin by creating a service template defining the name, protocol, and port number(s) on which the service listens. This template can then be easily instantiated to "attach" new services to a device or virtual machine. It's also possible to create new services by hand, without a template, however this approach can be tedious. +To model application services in NetBox, begin by creating an application service template defining the name, protocol, and port number(s) on which the service listens. This template can then be easily instantiated to "attach" new services to a device or virtual machine. It's also possible to create new application services by hand, without a template, however this approach can be tedious. diff --git a/docs/installation/3-netbox.md b/docs/installation/3-netbox.md index 60a118977..acf04dc2a 100644 --- a/docs/installation/3-netbox.md +++ b/docs/installation/3-netbox.md @@ -264,18 +264,6 @@ cd /opt/netbox/netbox python3 manage.py createsuperuser ``` -## Schedule the Housekeeping Task - -NetBox includes a `housekeeping` management command that handles some recurring cleanup tasks, such as clearing out old sessions and expired change records. Although this command may be run manually, it is recommended to configure a scheduled job using the system's `cron` daemon or a similar utility. - -A shell script which invokes this command is included at `contrib/netbox-housekeeping.sh`. It can be copied to or linked from your system's daily cron task directory, or included within the crontab directly. (If installing NetBox into a nonstandard path, be sure to update the system paths within this script first.) - -```shell -sudo ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping -``` - -See the [housekeeping documentation](../administration/housekeeping.md) for further details. - ## Test the Application At this point, we should be able to run NetBox's development server for testing. We can check by starting a development instance locally. @@ -302,13 +290,6 @@ Quit the server with CONTROL-C. Next, connect to the name or IP of the server (as defined in `ALLOWED_HOSTS`) on port 8000; for example, . You should be greeted with the NetBox home page. Try logging in using the username and password specified when creating a superuser. -!!! note - By default RHEL based distros will likely block your testing attempts with firewalld. The development server port can be opened with `firewall-cmd` (add `--permanent` if you want the rule to survive server restarts): - - ```no-highlight - firewall-cmd --zone=public --add-port=8000/tcp - ``` - !!! danger "Not for production use" The development server is for development and testing purposes only. It is neither performant nor secure enough for production use. **Do not use it in production.** diff --git a/docs/installation/upgrading.md b/docs/installation/upgrading.md index 21ffa9766..20bb962f5 100644 --- a/docs/installation/upgrading.md +++ b/docs/installation/upgrading.md @@ -135,7 +135,7 @@ Check out the desired release by specifying its tag. For example: ``` cd /opt/netbox && \ -sudo git fetch && \ +sudo git fetch --tags && \ sudo git checkout v4.2.7 ``` @@ -183,13 +183,3 @@ Finally, restart the gunicorn and RQ services: ```no-highlight sudo systemctl restart netbox netbox-rq ``` - -## 6. Verify Housekeeping Scheduling - -If upgrading from a release prior to NetBox v3.0, check that a cron task (or similar scheduled process) has been configured to run NetBox's nightly housekeeping command. A shell script which invokes this command is included at `contrib/netbox-housekeeping.sh`. It can be linked from your system's daily cron task directory, or included within the crontab directly. (If NetBox has been installed in a nonstandard path, be sure to update the system paths within this script first.) - -```shell -sudo ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping -``` - -See the [housekeeping documentation](../administration/housekeeping.md) for further details. diff --git a/docs/integrations/prometheus-metrics.md b/docs/integrations/prometheus-metrics.md index 006ff16a4..3f0e0ebda 100644 --- a/docs/integrations/prometheus-metrics.md +++ b/docs/integrations/prometheus-metrics.md @@ -11,6 +11,8 @@ NetBox makes use of the [django-prometheus](https://github.com/korfuri/django-pr - Per model insert, update, and delete counters - Per view request counters - Per view request latency histograms +- REST API requests (by endpoint & method) +- GraphQL API requests - Request body size histograms - Response body size histograms - Response code counters diff --git a/docs/integrations/rest-api.md b/docs/integrations/rest-api.md index 7a0d3e176..47fb65494 100644 --- a/docs/integrations/rest-api.md +++ b/docs/integrations/rest-api.md @@ -608,6 +608,28 @@ http://netbox/api/dcim/sites/ \ !!! note The bulk deletion of objects is an all-or-none operation, meaning that if NetBox fails to delete any of the specified objects (e.g. due a dependency by a related object), the entire operation will be aborted and none of the objects will be deleted. +## Changelog Messages + +!!! info "This feature was introduced in NetBox v4.4." + +Most objects in NetBox support [change logging](../features/change-logging.md), which generates a detailed record each time an object is created, modified, or deleted. Beginning in NetBox v4.4, users can attach a message to the change record as well. This is accomplished via the REST API by including a `changelog_message` field in the object representation. + +For example, the following API request will create a new site and record a message in the resulting changelog entry: + +```no-highlight +curl -s -X POST \ +-H "Authorization: Token $TOKEN" \ +-H "Content-Type: application/json" \ +http://netbox/api/dcim/sites/ \ +--data '{ + "name": "Site A", + "slug": "site-a", + "changelog_message": "Adding a site for ticket #4137" +}' +``` + +This approach works when creating, modifying, or deleting objects, either individually or in bulk. + ## Uploading Files As JSON does not support the inclusion of binary data, files cannot be uploaded using JSON-formatted API requests. Instead, we can use form data encoding to attach a local file. diff --git a/docs/media/installation/upgrade_paths.png b/docs/media/installation/upgrade_paths.png index c0be17b39..576c6d190 100644 Binary files a/docs/media/installation/upgrade_paths.png and b/docs/media/installation/upgrade_paths.png differ diff --git a/docs/models/circuits/circuit.md b/docs/models/circuits/circuit.md index c75e20322..6e999f28d 100644 --- a/docs/models/circuits/circuit.md +++ b/docs/models/circuits/circuit.md @@ -38,8 +38,6 @@ The operational status of the circuit. By default, the following statuses are av ### Distance -!!! info "This field was introduced in NetBox v4.2." - The distance between the circuit's two endpoints, including a unit designation (e.g. 100 meters or 25 feet). ### Description diff --git a/docs/models/circuits/virtualcircuit.md b/docs/models/circuits/virtualcircuit.md index 17328b87a..51dfd1c20 100644 --- a/docs/models/circuits/virtualcircuit.md +++ b/docs/models/circuits/virtualcircuit.md @@ -1,7 +1,5 @@ # Virtual Circuits -!!! info "This feature was introduced in NetBox v4.2." - A virtual circuit can connect two or more interfaces atop a set of decoupled physical connections. For example, it's very common to form a virtual connection between two virtual interfaces, each of which is bound to a physical interface on its respective device and physically connected to a [provider network](./providernetwork.md) via an independent [physical circuit](./circuit.md). ## Fields diff --git a/docs/models/circuits/virtualcircuittermination.md b/docs/models/circuits/virtualcircuittermination.md index a7833e13c..82ea43eef 100644 --- a/docs/models/circuits/virtualcircuittermination.md +++ b/docs/models/circuits/virtualcircuittermination.md @@ -1,7 +1,5 @@ # Virtual Circuit Terminations -!!! info "This feature was introduced in NetBox v4.2." - This model represents the connection of a virtual [interface](../dcim/interface.md) to a [virtual circuit](./virtualcircuit.md). ## Fields diff --git a/docs/models/core/datasource.md b/docs/models/core/datasource.md index 527d93939..64a087cb6 100644 --- a/docs/models/core/datasource.md +++ b/docs/models/core/datasource.md @@ -46,8 +46,6 @@ A set of rules (one per line) identifying filenames to ignore during synchroniza ### Sync Interval -!!! info "This field was introduced in NetBox v4.3." - The interval at which the data source should automatically synchronize. If not set, the data source must be synchronized manually. ### Last Synced diff --git a/docs/models/dcim/devicerole.md b/docs/models/dcim/devicerole.md index abff149d6..e58373565 100644 --- a/docs/models/dcim/devicerole.md +++ b/docs/models/dcim/devicerole.md @@ -6,8 +6,6 @@ Devices can be organized by functional roles, which are fully customizable by th ### Parent -!!! info "This field was introduced in NetBox v4.3." - The parent role of which this role is a child (optional). ### Name diff --git a/docs/models/dcim/interface.md b/docs/models/dcim/interface.md index b7115050f..7f67e4e7a 100644 --- a/docs/models/dcim/interface.md +++ b/docs/models/dcim/interface.md @@ -126,8 +126,6 @@ The tagged VLANs which are configured to be carried by this interface. Valid onl ### Q-in-Q SVLAN -!!! info "This field was introduced in NetBox v4.2." - The assigned service VLAN (for Q-in-Q/802.1ad interfaces). ### Wireless Role @@ -155,6 +153,4 @@ The [wireless LANs](../wireless/wirelesslan.md) for which this interface carries ### VLAN Translation Policy -!!! info "This field was introduced in NetBox v4.2." - The [VLAN translation policy](../ipam/vlantranslationpolicy.md) that applies to this interface (optional). diff --git a/docs/models/dcim/inventoryitem.md b/docs/models/dcim/inventoryitem.md index 6aed0fc86..22be2c5aa 100644 --- a/docs/models/dcim/inventoryitem.md +++ b/docs/models/dcim/inventoryitem.md @@ -30,8 +30,6 @@ An alternative physical label identifying the inventory item. ### Status -!!! info "This field was introduced in NetBox v4.2." - The inventory item's operational status. ### Role diff --git a/docs/models/dcim/macaddress.md b/docs/models/dcim/macaddress.md index 5b1dd93be..fe3d1f0e3 100644 --- a/docs/models/dcim/macaddress.md +++ b/docs/models/dcim/macaddress.md @@ -1,7 +1,5 @@ # MAC Addresses -!!! info "This feature was introduced in NetBox v4.2." - A MAC address object in NetBox comprises a single Ethernet link layer address, and represents a MAC address as reported by or assigned to a network interface. MAC addresses can be assigned to [device](../dcim/device.md) and [virtual machine](../virtualization/virtualmachine.md) interfaces. A MAC address can be specified as the primary MAC address for a given device or VM interface. Most interfaces have only a single MAC address, hard-coded at the factory. However, on some devices (particularly virtual interfaces) it is possible to assign additional MAC addresses or change existing ones. For this reason NetBox allows multiple MACAddress objects to be assigned to a single interface. diff --git a/docs/models/dcim/moduletypeprofile.md b/docs/models/dcim/moduletypeprofile.md index 80345c82b..8e9879398 100644 --- a/docs/models/dcim/moduletypeprofile.md +++ b/docs/models/dcim/moduletypeprofile.md @@ -1,7 +1,5 @@ # Module Type Profiles -!!! info "This model was introduced in NetBox v4.3." - Each [module type](./moduletype.md) may optionally be assigned a profile according to its classification. A profile can extend module types with user-configured attributes. For example, you might want to specify the input current and voltage of a power supply, or the clock speed and number of cores for a processor. Module type attributes are managed via the configuration of a [JSON schema](https://json-schema.org/) on the profile. For example, the following schema introduces three module type attributes, two of which are designated as required attributes. diff --git a/docs/models/dcim/poweroutlet.md b/docs/models/dcim/poweroutlet.md index 22a7ec63e..2aa95737a 100644 --- a/docs/models/dcim/poweroutlet.md +++ b/docs/models/dcim/poweroutlet.md @@ -40,12 +40,8 @@ The operational status of the power outlet. By default, the following statuses a !!! tip "Custom power outlet statuses" Additional power outlet statuses may be defined by setting `PowerOutlet.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter. -!!! info "This field was introduced in NetBox v4.3." - ### Color -!!! info "This field was introduced in NetBox v4.2." - The power outlet's color (optional). ### Power Port diff --git a/docs/models/dcim/racktype.md b/docs/models/dcim/racktype.md index 5298e8b26..ecaf539c9 100644 --- a/docs/models/dcim/racktype.md +++ b/docs/models/dcim/racktype.md @@ -42,8 +42,6 @@ The number of the numerically lowest unit in the rack. This value defaults to on The external width, height and depth of the rack can be tracked to aid in floorplan calculations. These measurements must be designated in either millimeters or inches. -!!! info "The `outer_height` field was introduced in NetBox v4.3." - ### Mounting Depth The maximum depth of a mounted device that the rack can accommodate, in millimeters. For four-post frames or cabinets, this is the horizontal distance between the front and rear vertical rails. (Note that this measurement does _not_ include space between the rails and the cabinet doors.) diff --git a/docs/models/extras/configtemplate.md b/docs/models/extras/configtemplate.md index 6b245e5e9..7fe3e865f 100644 --- a/docs/models/extras/configtemplate.md +++ b/docs/models/extras/configtemplate.md @@ -24,26 +24,25 @@ Jinja2 template code, if being defined locally rather than replicated from a dat A dictionary of any additional parameters to pass when instantiating the [Jinja2 environment](https://jinja.palletsprojects.com/en/3.1.x/api/#jinja2.Environment). Jinja2 supports various optional parameters which can be used to modify its default behavior. -### MIME Type +The `undefined` and `finalize` Jinja environment parameters, which must reference a Python class or function, can define a dotted path to the desired resource. For example: -!!! info "This field was introduced in NetBox v4.3." +```json +{ + "undefined": "jinja2.StrictUndefined" +} +``` + +### MIME Type The MIME type to indicate in the response when rendering the configuration template (optional). Defaults to `text/plain`. ### File Name -!!! info "This field was introduced in NetBox v4.3." - The file name to give to the rendered export file (optional). ### File Extension -!!! info "This field was introduced in NetBox v4.3." - The file extension to append to the file name in the response (optional). ### As Attachment - -!!! info "This field was introduced in NetBox v4.3." - If selected, the rendered content will be returned as a file attachment, rather than displayed directly in-browser (where supported). \ No newline at end of file diff --git a/docs/models/extras/exporttemplate.md b/docs/models/extras/exporttemplate.md index 86e1ae04a..3d80ffd69 100644 --- a/docs/models/extras/exporttemplate.md +++ b/docs/models/extras/exporttemplate.md @@ -22,10 +22,16 @@ Jinja2 template code for rendering the exported data. ### Environment Parameters -!!! info "This field was introduced in NetBox v4.3." - A dictionary of any additional parameters to pass when instantiating the [Jinja2 environment](https://jinja.palletsprojects.com/en/3.1.x/api/#jinja2.Environment). Jinja2 supports various optional parameters which can be used to modify its default behavior. +The `undefined` and `finalize` Jinja environment parameters, which must reference a Python class or function, can define a dotted path to the desired resource. For example: + +```json +{ + "undefined": "jinja2.StrictUndefined" +} +``` + ### MIME Type The MIME type to indicate in the response when rendering the export template (optional). Defaults to `text/plain`. diff --git a/docs/models/extras/tag.md b/docs/models/extras/tag.md index c4bc91b5a..b8a50b2eb 100644 --- a/docs/models/extras/tag.md +++ b/docs/models/extras/tag.md @@ -20,8 +20,6 @@ The color to use when displaying the tag in the NetBox UI. A numeric weight employed to influence the ordering of tags. Tags with a lower weight will be listed before those with higher weights. Values must be within the range **0** to **32767**. -!!! info "This field was introduced in NetBox v4.3." - ### Object Types The assignment of a tag may be limited to a prescribed set of objects. For example, it may be desirable to limit the application of a specific tag to only devices and virtual machines. diff --git a/docs/models/ipam/service.md b/docs/models/ipam/service.md index 0d5f12a17..fc6ab73d2 100644 --- a/docs/models/ipam/service.md +++ b/docs/models/ipam/service.md @@ -1,14 +1,18 @@ -# Services +# Application Services -A service represents a layer seven application available on a device or virtual machine. For example, a service might be created in NetBox to represent an HTTP server running on TCP/8000. Each service may optionally be further bound to one or more specific interfaces assigned to the selected device or virtual machine. +An application service represents a layer seven application available on a device or virtual machine. For example, a service might be created in NetBox to represent an HTTP server running on TCP/8000. Each service may optionally be further bound to one or more specific interfaces assigned to the selected device or virtual machine. -To aid in the efficient creation of services, users may opt to first create a [service template](./servicetemplate.md) from which service definitions can be quickly replicated. +To aid in the efficient creation of application services, users may opt to first create an [application service template](./servicetemplate.md) from which service definitions can be quickly replicated. + +!!! note "Changed in NetBox v4.4" + + Previously, application services were referred to simply as "services". The name has been changed in the UI to better reflect their intended use. There is no change to the name of the model or in any programmatic NetBox APIs. ## Fields ### Parent -The parent object to which the service is assigned. This must be one of [Device](../dcim/device.md), +The parent object to which the application service is assigned. This must be one of [Device](../dcim/device.md), [VirtualMachine](../virtualization/virtualmachine.md), or [FHRP Group](./fhrpgroup.md). !!! note "Changed in NetBox v4.3" diff --git a/docs/models/ipam/servicetemplate.md b/docs/models/ipam/servicetemplate.md index 28c66b648..9dd69b3c4 100644 --- a/docs/models/ipam/servicetemplate.md +++ b/docs/models/ipam/servicetemplate.md @@ -1,6 +1,10 @@ -# Service Templates +# Application Service Templates -Service templates can be used to instantiate [services](./service.md) on [devices](../dcim/device.md) and [virtual machines](../virtualization/virtualmachine.md). +Application service templates can be used to instantiate [application services](./service.md) on [devices](../dcim/device.md) and [virtual machines](../virtualization/virtualmachine.md). + +!!! note "Changed in NetBox v4.4" + + Previously, application service templates were referred to simply as "service templates". The name has been changed in the UI to better reflect their intended use. There is no change to the name of the model or in any programmatic NetBox APIs. ## Fields diff --git a/docs/models/ipam/vlan.md b/docs/models/ipam/vlan.md index 3c90d8cc9..58fc9f551 100644 --- a/docs/models/ipam/vlan.md +++ b/docs/models/ipam/vlan.md @@ -25,16 +25,15 @@ The user-defined functional [role](./role.md) assigned to the VLAN. ### VLAN Group or Site +!!! warning "Site assignment is deprecated" + The assignment of individual VLANs directly to a site has been deprecated. This ability will be removed in a future NetBox release. Users are strongly encouraged to utilize VLAN groups, which have the added benefit of supporting the assignment of a VLAN to multiple sites. + The [VLAN group](./vlangroup.md) or [site](../dcim/site.md) to which the VLAN is assigned. ### Q-in-Q Role -!!! info "This field was introduced in NetBox v4.2." - For VLANs which comprise a Q-in-Q/IEEE 802.1ad topology, this field indicates whether the VLAN is treated as a service or customer VLAN. ### Q-in-Q Service VLAN -!!! info "This field was introduced in NetBox v4.2." - The designated parent service VLAN for a Q-in-Q customer VLAN. This may be set only for Q-in-Q custom VLANs. diff --git a/docs/models/ipam/vlantranslationpolicy.md b/docs/models/ipam/vlantranslationpolicy.md index 9e3e8de98..59541931e 100644 --- a/docs/models/ipam/vlantranslationpolicy.md +++ b/docs/models/ipam/vlantranslationpolicy.md @@ -1,7 +1,5 @@ # VLAN Translation Policies -!!! info "This feature was introduced in NetBox v4.2." - VLAN translation is a feature that consists of VLAN translation policies and [VLAN translation rules](./vlantranslationrule.md). Many rules can belong to a policy, and each rule defines a mapping of a local to remote VLAN ID (VID). A policy can then be assigned to an [Interface](../dcim/interface.md) or [VMInterface](../virtualization/vminterface.md), and all VLAN translation rules associated with that policy will be visible in the interface details. There are uniqueness constraints on `(policy, local_vid)` and on `(policy, remote_vid)` in the `VLANTranslationRule` model. Thus, you cannot have multiple rules linked to the same policy that have the same local VID or the same remote VID. A set of policies and rules might look like this: diff --git a/docs/models/ipam/vlantranslationrule.md b/docs/models/ipam/vlantranslationrule.md index eb356d0d0..bffc030ed 100644 --- a/docs/models/ipam/vlantranslationrule.md +++ b/docs/models/ipam/vlantranslationrule.md @@ -1,7 +1,5 @@ # VLAN Translation Rules -!!! info "This feature was introduced in NetBox v4.2." - A VLAN translation rule represents a one-to-one mapping of a local VLAN ID (VID) to a remote VID. Many rules can belong to a single policy. See [VLAN translation policies](./vlantranslationpolicy.md) for an overview of the VLAN Translation feature. diff --git a/docs/models/virtualization/vminterface.md b/docs/models/virtualization/vminterface.md index ba0c68b15..726060c05 100644 --- a/docs/models/virtualization/vminterface.md +++ b/docs/models/virtualization/vminterface.md @@ -1,6 +1,6 @@ ## Interfaces -[Virtual machine](./virtualmachine.md) interfaces behave similarly to device [interfaces](../dcim/interface.md): They can be assigned to VRFs, may have IP addresses, VLANs, and services attached to them, and so on. However, given their virtual nature, they lack properties pertaining to physical attributes. For example, VM interfaces do not have a physical type and cannot have cables attached to them. +[Virtual machine](./virtualmachine.md) interfaces behave similarly to device [interfaces](../dcim/interface.md): They can be assigned to VRFs, may have IP addresses, VLANs, and so on. However, given their virtual nature, they lack properties pertaining to physical attributes. For example, VM interfaces do not have a physical type and cannot have cables attached to them. ## Fields @@ -59,8 +59,6 @@ The tagged VLANs which are configured to be carried by this interface. Valid onl ### Q-in-Q SVLAN -!!! info "This field was introduced in NetBox v4.2." - The assigned service VLAN (for Q-in-Q/802.1ad interfaces). ### VRF @@ -69,6 +67,4 @@ The [virtual routing and forwarding](../ipam/vrf.md) instance to which this inte ### VLAN Translation Policy -!!! info "This field was introduced in NetBox v4.2." - The [VLAN translation policy](../ipam/vlantranslationpolicy.md) that applies to this interface (optional). diff --git a/docs/models/vpn/l2vpn.md b/docs/models/vpn/l2vpn.md index 983095ef8..0bf17fa1b 100644 --- a/docs/models/vpn/l2vpn.md +++ b/docs/models/vpn/l2vpn.md @@ -44,8 +44,6 @@ The operational status of the L2VPN. By default, the following statuses are avai !!! tip "Custom L2VPN statuses" Additional L2VPN statuses may be defined by setting `L2VPN.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter. -!!! info "This field was introduced in NetBox v4.3." - ### Identifier An optional numeric identifier. This can be used to track a pseudowire ID, for example. diff --git a/docs/models/wireless/wirelesslan.md b/docs/models/wireless/wirelesslan.md index 2ce673086..a448c42a2 100644 --- a/docs/models/wireless/wirelesslan.md +++ b/docs/models/wireless/wirelesslan.md @@ -46,6 +46,4 @@ The security key configured on each client to grant access to the secured wirele ### Scope -!!! info "This field was introduced in NetBox v4.2." - The [region](../dcim/region.md), [site](../dcim/site.md), [site group](../dcim/sitegroup.md) or [location](../dcim/location.md) with which this wireless LAN is associated. diff --git a/docs/plugins/development/background-jobs.md b/docs/plugins/development/background-jobs.md index 9be52c3ca..98229b78d 100644 --- a/docs/plugins/development/background-jobs.md +++ b/docs/plugins/development/background-jobs.md @@ -15,7 +15,6 @@ A background job implements a basic [Job](../../models/core/job.md) executor for ```python title="jobs.py" from netbox.jobs import JobRunner - class MyTestJob(JobRunner): class Meta: name = "My Test Job" @@ -25,6 +24,8 @@ class MyTestJob(JobRunner): # your logic goes here ``` +Completed jobs will have their status updated to "completed" by default, or "errored" if an unhandled exception was raised by the `run()` method. To intentionally mark a job as failed, raise the `core.exceptions.JobFailed` exception. (Note that "failed" differs from "errored" in that a failure may be expected under certain conditions, whereas an error is not.) + You can schedule the background job from within your code (e.g. from a model's `save()` method or a view) by calling `MyTestJob.enqueue()`. This method passes through all arguments to `Job.enqueue()`. However, no `name` argument must be passed, as the background job name will be used instead. !!! tip @@ -38,6 +39,27 @@ You can schedule the background job from within your code (e.g. from a model's ` This is the human-friendly names of your background job. If omitted, the class name will be used. +### Logging + +!!! info "This feature was introduced in NetBox v4.4." + +A Python logger is instantiated by the runner for each job. It can be utilized within a job's `run()` method as needed: + +```python +def run(self, *args, **kwargs): + obj = MyModel.objects.get(pk=kwargs.get('pk')) + self.logger.info("Retrieved object {obj}") +``` + +Four of the standard Python logging levels are supported: + +* `debug()` +* `info()` +* `warning()` +* `error()` + +Log entries recorded using the runner's logger will be saved in the job's log in the database in addition to being processed by other [system logging handlers](../../configuration/system.md#logging). + ### Scheduled Jobs As described above, jobs can be scheduled for immediate execution or at any later time using the `enqueue()` method. However, for management purposes, the `enqueue_once()` method allows a job to be scheduled exactly once avoiding duplicates. If a job is already scheduled for a particular instance, a second one won't be scheduled, respecting thread safety. An example use case would be to schedule a periodic task that is bound to an instance in general, but not to any event of that instance (such as updates). The parameters of the `enqueue_once()` method are identical to those of `enqueue()`. @@ -67,8 +89,6 @@ class MyModel(NetBoxModel): ### System Jobs -!!! info "This feature was introduced in NetBox v4.2." - Some plugins may implement background jobs that are decoupled from the request/response cycle. Typical use cases would be housekeeping tasks or synchronization jobs. These can be registered as _system jobs_ using the `system_job()` decorator. The job interval must be passed as an integer (in minutes) when registering a system job. System jobs are scheduled automatically when the RQ worker (`manage.py rqworker`) is run. #### Example diff --git a/docs/plugins/development/models.md b/docs/plugins/development/models.md index 508c4ce89..eff17ac23 100644 --- a/docs/plugins/development/models.md +++ b/docs/plugins/development/models.md @@ -119,8 +119,6 @@ For more information about database migrations, see the [Django documentation](h ::: netbox.models.features.ContactsMixin -!!! info "Plugin support for ContactsMixin was introduced in NetBox v4.3." - ::: netbox.models.features.CustomLinksMixin ::: netbox.models.features.CustomFieldsMixin diff --git a/docs/plugins/development/user-interface.md b/docs/plugins/development/user-interface.md new file mode 100644 index 000000000..a918eb185 --- /dev/null +++ b/docs/plugins/development/user-interface.md @@ -0,0 +1,14 @@ +# User Interface + +## Light & Dark Mode + +The NetBox user interface supports toggling between light and dark versions of the theme. If needed, a plugin can determine the currently active color theme by inspecting `window.localStorage['netbox-color-mode']`, which will indicate either `light` or `dark`. + +Additionally, when the color scheme is toggled by the user, a custom event `netbox.colorModeChanged` indicating the new scheme is dispatched. A plugin can listen for this event if needed to react to the change: + +```typescript +window.addEventListener('netbox.colorModeChanged', e => { + const customEvent = e as CustomEvent; + console.log('New color mode:', customEvent.detail.netboxColorMode); +}); +``` diff --git a/docs/plugins/development/webhooks.md b/docs/plugins/development/webhooks.md new file mode 100644 index 000000000..755d19d42 --- /dev/null +++ b/docs/plugins/development/webhooks.md @@ -0,0 +1,75 @@ +# Webhooks + +NetBox supports the configuration of outbound [webhooks](../../integrations/webhooks.md) which can be triggered by custom [event rules](../../features/event-rules.md). By default, a webhook's payload will contain a serialized representation of the object, before & after snapshots (if applicable), and some metadata. + +## Callback Registration + +Plugins can register callback functions to supplement a webhook's payload with their own data. For example, it might be desirable for a plugin to attach information about the status of some objects at the time a change was made. + +This can be accomplished by defining a function which accepts a defined set of keyword arguments and registering it as a webhook callback. Whenever a new webhook is generated, the function will be called, and any data it returns will be attached to the webhook's payload under the `context` key. + +### Example + +```python +from extras.webhooks import register_webhook_callback +from my_plugin.utilities import get_foo_status + +@register_webhook_callback +def set_foo_status(object_type, event_type, data, request): + if status := get_foo_status(): + return { + 'foo': status + } +``` + +The resulting webhook payload will look like the following: + +```json +{ + "event": "updated", + "timestamp": "2025-08-07T14:24:30.627321+00:00", + "object_type": "dcim.site", + "username": "admin", + "request_id": "49e3e39e-7333-4b9c-a9af-19f0dc1e7dc9", + "data": { + "id": 2, + "url": "/api/dcim/sites/2/", + ... + }, + "snapshots": {...}, + "context": { + "foo": 123 + } +} +``` + +!!! note "Consider namespacing webhook data" + The data returned from all webhook callbacks will be compiled into a single `context` dictionary. Any existing keys within this dictionary will be overwritten by subsequent callbacks which include those keys. To avoid collisions with webhook data provided by other plugins, consider namespacing your plugin's data within a nested dictionary as such: + + ```python + return { + 'my_plugin': { + 'foo': 123, + 'bar': 456, + } + } + ``` + +### Callback Function Arguments + +| Name | Type | Description | +|---------------|-------------------|-------------------------------------------------------------------| +| `object_type` | ObjectType | The ObjectType which represents the triggering object | +| `event_type` | String | The type of event which triggered the webhook (see `core.events`) | +| `data` | Dictionary | The serialized representation of the object | +| `request` | NetBoxFakeRequest | A copy of the request (if any) which resulted in the change | + +## Where to Define Callbacks + +Webhook callbacks can be defined anywhere within a plugin, but must be imported during plugin initialization. If you wish to keep them in a separate module, you can import that module under the PluginConfig's `ready()` method: + +```python +def ready(self): + super().ready() + from my_plugin import webhook_callbacks +``` diff --git a/docs/reference/filtering.md b/docs/reference/filtering.md index 5a672ed11..eb752b7dd 100644 --- a/docs/reference/filtering.md +++ b/docs/reference/filtering.md @@ -80,18 +80,20 @@ GET /api/ipam/vlans/?vid__gt=900 String based (char) fields (Name, Address, etc) support these lookup expressions: -| Filter | Description | -|---------|----------------------------------------| -| `n` | Not equal to | -| `ic` | Contains (case-insensitive) | -| `nic` | Does not contain (case-insensitive) | -| `isw` | Starts with (case-insensitive) | -| `nisw` | Does not start with (case-insensitive) | -| `iew` | Ends with (case-insensitive) | -| `niew` | Does not end with (case-insensitive) | -| `ie` | Exact match (case-insensitive) | -| `nie` | Inverse exact match (case-insensitive) | -| `empty` | Is empty/null (boolean) | +| Filter | Description | +|----------|----------------------------------------| +| `n` | Not equal to | +| `ic` | Contains (case-insensitive) | +| `nic` | Does not contain (case-insensitive) | +| `isw` | Starts with (case-insensitive) | +| `nisw` | Does not start with (case-insensitive) | +| `iew` | Ends with (case-insensitive) | +| `niew` | Does not end with (case-insensitive) | +| `ie` | Exact match (case-insensitive) | +| `nie` | Inverse exact match (case-insensitive) | +| `empty` | Is empty/null (boolean) | +| `regex` | Regexp matching | +| `iregex` | Regexp matching (case-insensitive) | Here is an example of a lookup expression on a string field that will return all devices with `switch` in the name: diff --git a/docs/release-notes/index.md b/docs/release-notes/index.md index 0d0b10092..14cd5502d 100644 --- a/docs/release-notes/index.md +++ b/docs/release-notes/index.md @@ -10,6 +10,12 @@ Minor releases are published in April, August, and December of each calendar yea This page contains a history of all major and minor releases since NetBox v2.0. For more detail on a specific patch release, please see the release notes page for that specific minor release. +#### [Version 4.4](./version-4.4.md) (September 2025) + +* Background Jobs for Bulk Operations ([#19589](https://github.com/netbox-community/netbox/issues/19589), [#19891](https://github.com/netbox-community/netbox/issues/19891)) +* Logging Mechanism for Background Jobs ([#19891](https://github.com/netbox-community/netbox/issues/19816)) +* Changelog Comments ([#19713](https://github.com/netbox-community/netbox/issues/19713)) + #### [Version 4.3](./version-4.3.md) (May 2025) * Module Type Profiles & Custom Attributes ([#19002](https://github.com/netbox-community/netbox/issues/19002)) diff --git a/docs/release-notes/version-3.0.md b/docs/release-notes/version-3.0.md index 06b889c22..a7003eedf 100644 --- a/docs/release-notes/version-3.0.md +++ b/docs/release-notes/version-3.0.md @@ -434,7 +434,7 @@ A new management command has been added: `manage.py housekeeping`. This command * Delete change log records which have surpassed the configured retention period (if configured) * Check for new NetBox releases (if enabled) -A convenience script for calling this command via an automated scheduler has been included at `/contrib/netbox-housekeeping.sh`. Please see the [housekeeping documentation](../administration/housekeeping.md) for further details. +A convenience script for calling this command via an automated scheduler has been included at `/contrib/netbox-housekeeping.sh`. Please see the housekeeping documentation for further details. #### Custom Queue Support for Plugins ([#6651](https://github.com/netbox-community/netbox/issues/6651)) diff --git a/docs/release-notes/version-4.3.md b/docs/release-notes/version-4.3.md index d36698c00..a217725db 100644 --- a/docs/release-notes/version-4.3.md +++ b/docs/release-notes/version-4.3.md @@ -1,5 +1,43 @@ # NetBox v4.3 +## v4.3.5 (2025-07-29) + +### Enhancements +* [#18797](https://github.com/netbox-community/netbox/issues/18797) - Added jinja2.StrictUndefined option for config template rendering to catch undefined variables +* [#18936](https://github.com/netbox-community/netbox/issues/18936) - Cable imports now accept color names (e.g. "red", "blue") in addition to hex color codes +* [#19840](https://github.com/netbox-community/netbox/issues/19840) - Cable imports now support specifying site information for better organization +* [#19902](https://github.com/netbox-community/netbox/issues/19902) - Device names in rack elevation SVG exports are automatically truncated to prevent overflow beyond rack unit boundaries +* [#19903](https://github.com/netbox-community/netbox/issues/19903) - String field filters now support `regex` and `iregex` lookups for advanced pattern matching +* [#19910](https://github.com/netbox-community/netbox/issues/19910) - Internet-dependent links are no longer visible when running in air-gapped environments + +### Bug Fixes +* [#18900](https://github.com/netbox-community/netbox/issues/18900) - REST API paginator now raises proper exceptions when attempting to paginate unordered querysets +* [#19916](https://github.com/netbox-community/netbox/issues/19916) - Rack elevation image/label dropdown functionality restored +* [#19934](https://github.com/netbox-community/netbox/issues/19934) - Added missing description field to tenant bulk edit form +* [#19956](https://github.com/netbox-community/netbox/issues/19956) - Prevent duplicate deletion records in changelog from cascading deletions + +## v4.3.4 (2025-07-15) + +### Enhancements + +* [#18811](https://github.com/netbox-community/netbox/issues/18811) - Match expanded form IPv6 addresses in global search +* [#19550](https://github.com/netbox-community/netbox/issues/19550) - Enable lazy loading for rack elevations +* [#19571](https://github.com/netbox-community/netbox/issues/19571) - Add a default module type profile for expansion cards +* [#19793](https://github.com/netbox-community/netbox/issues/19793) - Support custom dynamic navigation menu links +* [#19828](https://github.com/netbox-community/netbox/issues/19828) - Expose L2VPN termination in interface GraphQL response + +### Bug Fixes + +* [#19413](https://github.com/netbox-community/netbox/issues/19413) - Custom fields should be grouped in filter forms +* [#19633](https://github.com/netbox-community/netbox/issues/19633) - Introduce InvalidCondition exception and log all evaluations of invalid event rule conditions +* [#19800](https://github.com/netbox-community/netbox/issues/19800) - Module type bulk import should support profile assignment +* [#19806](https://github.com/netbox-community/netbox/issues/19806) - Introduce JobFailed exception to allow marking background jobs as failed +* [#19827](https://github.com/netbox-community/netbox/issues/19827) - Enforce uniqueness for device role names & slugs +* [#19839](https://github.com/netbox-community/netbox/issues/19839) - Enable export of parent assignment for recursively nested objects +* [#19876](https://github.com/netbox-community/netbox/issues/19876) - Remove Markdown rendering from CustomFieldChoiceSet description field + +--- + ## v4.3.3 (2025-06-26) ### Enhancements diff --git a/docs/release-notes/version-4.4.md b/docs/release-notes/version-4.4.md new file mode 100644 index 000000000..4de258c4d --- /dev/null +++ b/docs/release-notes/version-4.4.md @@ -0,0 +1,61 @@ +# NetBox v4.4 + +## v4.4.0 (FUTURE) + +### New Features + +#### Background Jobs for Bulk Operations ([#19589](https://github.com/netbox-community/netbox/issues/19589), [#19891](https://github.com/netbox-community/netbox/issues/19891)) + +Most bulk operations, such as the import, modification, or deletion of objects can now be executed as a background job. This frees the user to continue working in NetBox while the bulk operation is processed. Once completed, the user will be notified of the job's result. + +#### Logging Mechanism for Background Jobs ([#19891](https://github.com/netbox-community/netbox/issues/19816)) + +A dedicated logging mechanism has been implemented for background jobs. Jobs can now easily record log messages by calling e.g. `self.logger.info("Log message")` under the `run()` method. These messages are displayed along with the job's resulting data. Supported log levels include `DEBUG`, `INFO`, `WARNING`, and `ERROR`. + +#### Changelog Comments ([#19713](https://github.com/netbox-community/netbox/issues/19713)) + +When creating, editing, or deleting objects in NetBox, users now have the option of providing a short message explaining the change. This message will be recorded on the resulting changelog records for all affected objects. + +### Enhancements + +* [#17413](https://github.com/netbox-community/netbox/issues/17413) - Platforms belonging to different manufacturers may now have identical names +* [#18204](https://github.com/netbox-community/netbox/issues/18204) - Improved layout of the image attachments view & tables +* [#18528](https://github.com/netbox-community/netbox/issues/18528) - Introduced the `HOSTNAME` configuration parameter to override the system hostname reported by NetBox +* [#18990](https://github.com/netbox-community/netbox/issues/18990) - Image attachments now include an optional description field +* [#19134](https://github.com/netbox-community/netbox/issues/19134) - Interface transmit power now accepts negative values +* [#19231](https://github.com/netbox-community/netbox/issues/19231) - Bulk renaming support has been implemented in the UI for most object types +* [#19591](https://github.com/netbox-community/netbox/issues/19591) - Thumbnails for all images attached to an object are now displayed under a dedicated tab +* [#19722](https://github.com/netbox-community/netbox/issues/19722) - The REST API endpoint for object types has been extended to include additional details +* [#19739](https://github.com/netbox-community/netbox/issues/19739) - Introduced a user preference for CSV delimiter +* [#19893](https://github.com/netbox-community/netbox/issues/19893) - The `/api/status/` REST API endpoint now includes the system hostname +* [#19920](https://github.com/netbox-community/netbox/issues/19920) - Contacts can now be assigned to ASNs +* [#19945](https://github.com/netbox-community/netbox/issues/19945) - Introduce a new custom script variable to represent decimal values +* [#19965](https://github.com/netbox-community/netbox/issues/19965) - Add REST & GraphQL API request counters to the Prometheus metrics exporter + +### Plugins + +* [#19735](https://github.com/netbox-community/netbox/issues/19735) - Custom individual and bulk operations can now be registered under individual views using `ObjectAction` + +### Other Changes + +* [#18349](https://github.com/netbox-community/netbox/issues/18349) - The housekeeping script has been replaced with a system job +* [#18588](https://github.com/netbox-community/netbox/issues/18588) - The "Service" model has been renamed to "Application Service" for clarity (UI change only) +* [#19829](https://github.com/netbox-community/netbox/issues/19829) - The REST API endpoint for object types is now available under `/api/core/` +* [#19924](https://github.com/netbox-community/netbox/issues/19924) - ObjectTypes are now tracked as concrete objects in the database (alongside ContentTypes) +* [#19973](https://github.com/netbox-community/netbox/issues/19973) - Miscellaneous improvements to the `nbhshell` management command + +### REST API Changes + +* The `/api/status/` endpoint now includes the system hostname. +* The `/api/extras/object-types/` endpoint is now available at `/api/core/object-types/`. (The original endpoint will be removed in NetBox v4.5.) +* The `/api/core/object-types/` endpoint has been expanded to include the following read-only fields: + * `app_name` + * `model_name` + * `model_name_plural` + * `is_plugin_model` + * `rest_api_endpoint` + * `description` +* dcim.Interface + * The `tx_power` field now accepts negative values +* extras.ImageAttachment + * Added an optional `description` field diff --git a/mkdocs.yml b/mkdocs.yml index 27526bd26..d427d3d24 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -144,6 +144,8 @@ nav: - Search: 'plugins/development/search.md' - Event Types: 'plugins/development/event-types.md' - Data Backends: 'plugins/development/data-backends.md' + - Webhooks: 'plugins/development/webhooks.md' + - User Interface: 'plugins/development/user-interface.md' - REST API: 'plugins/development/rest-api.md' - GraphQL API: 'plugins/development/graphql-api.md' - Background Jobs: 'plugins/development/background-jobs.md' @@ -158,7 +160,6 @@ nav: - Okta: 'administration/authentication/okta.md' - Permissions: 'administration/permissions.md' - Error Reporting: 'administration/error-reporting.md' - - Housekeeping: 'administration/housekeeping.md' - Replicating NetBox: 'administration/replicating-netbox.md' - NetBox Shell: 'administration/netbox-shell.md' - Data Model: @@ -309,6 +310,7 @@ nav: - git Cheat Sheet: 'development/git-cheat-sheet.md' - Release Notes: - Summary: 'release-notes/index.md' + - Version 4.4: 'release-notes/version-4.4.md' - Version 4.3: 'release-notes/version-4.3.md' - Version 4.2: 'release-notes/version-4.2.md' - Version 4.1: 'release-notes/version-4.1.md' diff --git a/netbox/core/api/serializers.py b/netbox/core/api/serializers.py index 9a6d4d726..d1c778f65 100644 --- a/netbox/core/api/serializers.py +++ b/netbox/core/api/serializers.py @@ -1,4 +1,5 @@ from .serializers_.change_logging import * from .serializers_.data import * from .serializers_.jobs import * +from .serializers_.object_types import * from .serializers_.tasks import * diff --git a/netbox/core/api/serializers_/change_logging.py b/netbox/core/api/serializers_/change_logging.py index e8af31ae8..575a849d5 100644 --- a/netbox/core/api/serializers_/change_logging.py +++ b/netbox/core/api/serializers_/change_logging.py @@ -44,7 +44,8 @@ class ObjectChangeSerializer(BaseModelSerializer): model = ObjectChange fields = [ 'id', 'url', 'display_url', 'display', 'time', 'user', 'user_name', 'request_id', 'action', - 'changed_object_type', 'changed_object_id', 'changed_object', 'prechange_data', 'postchange_data', + 'changed_object_type', 'changed_object_id', 'changed_object', 'message', 'prechange_data', + 'postchange_data', ] @extend_schema_field(serializers.JSONField(allow_null=True)) diff --git a/netbox/core/api/serializers_/jobs.py b/netbox/core/api/serializers_/jobs.py index 306287e88..dd0dd1245 100644 --- a/netbox/core/api/serializers_/jobs.py +++ b/netbox/core/api/serializers_/jobs.py @@ -23,6 +23,6 @@ class JobSerializer(BaseModelSerializer): model = Job fields = [ 'id', 'url', 'display_url', 'display', 'object_type', 'object_id', 'name', 'status', 'created', 'scheduled', - 'interval', 'started', 'completed', 'user', 'data', 'error', 'job_id', + 'interval', 'started', 'completed', 'user', 'data', 'error', 'job_id', 'log_entries', ] brief_fields = ('url', 'created', 'completed', 'user', 'status') diff --git a/netbox/extras/api/serializers_/objecttypes.py b/netbox/core/api/serializers_/object_types.py similarity index 100% rename from netbox/extras/api/serializers_/objecttypes.py rename to netbox/core/api/serializers_/object_types.py diff --git a/netbox/core/api/urls.py b/netbox/core/api/urls.py index 3c22f1cf4..85d07bfa0 100644 --- a/netbox/core/api/urls.py +++ b/netbox/core/api/urls.py @@ -10,6 +10,7 @@ router.register('data-sources', views.DataSourceViewSet) router.register('data-files', views.DataFileViewSet) router.register('jobs', views.JobViewSet) router.register('object-changes', views.ObjectChangeViewSet) +router.register('object-types', views.ObjectTypeViewSet) router.register('background-queues', views.BackgroundQueueViewSet, basename='rqqueue') router.register('background-workers', views.BackgroundWorkerViewSet, basename='rqworker') router.register('background-tasks', views.BackgroundTaskViewSet, basename='rqtask') diff --git a/netbox/core/api/views.py b/netbox/core/api/views.py index 4e5b148fc..c16efebe7 100644 --- a/netbox/core/api/views.py +++ b/netbox/core/api/views.py @@ -1,29 +1,29 @@ from django.http import Http404, HttpResponse from django.shortcuts import get_object_or_404 from django.utils.translation import gettext_lazy as _ +from django_rq.queues import get_redis_connection +from django_rq.settings import QUEUES_LIST +from django_rq.utils import get_statistics from drf_spectacular.types import OpenApiTypes from drf_spectacular.utils import extend_schema +from rest_framework import viewsets from rest_framework.decorators import action from rest_framework.exceptions import PermissionDenied +from rest_framework.permissions import IsAdminUser from rest_framework.response import Response from rest_framework.routers import APIRootView from rest_framework.viewsets import ReadOnlyModelViewSet +from rq.job import Job as RQ_Job +from rq.worker import Worker from core import filtersets -from core.choices import DataSourceStatusChoices from core.jobs import SyncDataSourceJob from core.models import * from core.utils import delete_rq_job, enqueue_rq_job, get_rq_jobs, requeue_rq_job, stop_rq_job -from django_rq.queues import get_redis_connection -from django_rq.utils import get_statistics -from django_rq.settings import QUEUES_LIST +from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired from netbox.api.metadata import ContentTypeMetadata from netbox.api.pagination import LimitOffsetListPagination from netbox.api.viewsets import NetBoxModelViewSet, NetBoxReadOnlyModelViewSet -from rest_framework import viewsets -from rest_framework.permissions import IsAdminUser -from rq.job import Job as RQ_Job -from rq.worker import Worker from . import serializers @@ -50,10 +50,8 @@ class DataSourceViewSet(NetBoxModelViewSet): if not request.user.has_perm('core.sync_datasource', obj=datasource): raise PermissionDenied(_("This user does not have permission to synchronize this data source.")) - # Enqueue the sync job & update the DataSource's status + # Enqueue the sync job SyncDataSourceJob.enqueue(instance=datasource, user=request.user) - datasource.status = DataSourceStatusChoices.QUEUED - DataSource.objects.filter(pk=datasource.pk).update(status=datasource.status) serializer = serializers.DataSourceSerializer(datasource, context={'request': request}) @@ -85,6 +83,16 @@ class ObjectChangeViewSet(ReadOnlyModelViewSet): filterset_class = filtersets.ObjectChangeFilterSet +class ObjectTypeViewSet(ReadOnlyModelViewSet): + """ + Read-only list of ObjectTypes. + """ + permission_classes = [IsAuthenticatedOrLoginNotRequired] + queryset = ObjectType.objects.order_by('app_label', 'model') + serializer_class = serializers.ObjectTypeSerializer + filterset_class = filtersets.ObjectTypeFilterSet + + class BaseRQViewSet(viewsets.ViewSet): """ Base class for RQ view sets. Provides a list() method. Subclasses must implement get_data(). diff --git a/netbox/core/constants.py b/netbox/core/constants.py index 3c3382dcc..582768186 100644 --- a/netbox/core/constants.py +++ b/netbox/core/constants.py @@ -4,23 +4,31 @@ from django.utils.translation import gettext_lazy as _ from rq.job import JobStatus __all__ = ( + 'JOB_LOG_ENTRY_LEVELS', 'RQ_TASK_STATUSES', ) @dataclass -class Status: +class Badge: label: str color: str RQ_TASK_STATUSES = { - JobStatus.QUEUED: Status(_('Queued'), 'cyan'), - JobStatus.FINISHED: Status(_('Finished'), 'green'), - JobStatus.FAILED: Status(_('Failed'), 'red'), - JobStatus.STARTED: Status(_('Started'), 'blue'), - JobStatus.DEFERRED: Status(_('Deferred'), 'gray'), - JobStatus.SCHEDULED: Status(_('Scheduled'), 'purple'), - JobStatus.STOPPED: Status(_('Stopped'), 'orange'), - JobStatus.CANCELED: Status(_('Cancelled'), 'yellow'), + JobStatus.QUEUED: Badge(_('Queued'), 'cyan'), + JobStatus.FINISHED: Badge(_('Finished'), 'green'), + JobStatus.FAILED: Badge(_('Failed'), 'red'), + JobStatus.STARTED: Badge(_('Started'), 'blue'), + JobStatus.DEFERRED: Badge(_('Deferred'), 'gray'), + JobStatus.SCHEDULED: Badge(_('Scheduled'), 'purple'), + JobStatus.STOPPED: Badge(_('Stopped'), 'orange'), + JobStatus.CANCELED: Badge(_('Cancelled'), 'yellow'), +} + +JOB_LOG_ENTRY_LEVELS = { + 'debug': Badge(_('Debug'), 'gray'), + 'info': Badge(_('Info'), 'blue'), + 'warning': Badge(_('Warning'), 'orange'), + 'error': Badge(_('Error'), 'red'), } diff --git a/netbox/core/dataclasses.py b/netbox/core/dataclasses.py new file mode 100644 index 000000000..21f97d01d --- /dev/null +++ b/netbox/core/dataclasses.py @@ -0,0 +1,21 @@ +import logging + +from dataclasses import dataclass, field +from datetime import datetime + +from django.utils import timezone + +__all__ = ( + 'JobLogEntry', +) + + +@dataclass +class JobLogEntry: + level: str + message: str + timestamp: datetime = field(default_factory=timezone.now) + + @classmethod + def from_logrecord(cls, record: logging.LogRecord): + return cls(record.levelname.lower(), record.msg) diff --git a/netbox/core/exceptions.py b/netbox/core/exceptions.py index 5790704c2..b4767a4fa 100644 --- a/netbox/core/exceptions.py +++ b/netbox/core/exceptions.py @@ -1,9 +1,19 @@ from django.core.exceptions import ImproperlyConfigured - -class SyncError(Exception): - pass +__all__ = ( + 'IncompatiblePluginError', + 'JobFailed', + 'SyncError', +) class IncompatiblePluginError(ImproperlyConfigured): pass + + +class JobFailed(Exception): + pass + + +class SyncError(Exception): + pass diff --git a/netbox/core/filtersets.py b/netbox/core/filtersets.py index 42ec22350..9f90752d7 100644 --- a/netbox/core/filtersets.py +++ b/netbox/core/filtersets.py @@ -1,9 +1,8 @@ +import django_filters from django.contrib.contenttypes.models import ContentType from django.db.models import Q from django.utils.translation import gettext as _ -import django_filters - from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet from netbox.utils import get_data_backend_choices from users.models import User @@ -17,6 +16,7 @@ __all__ = ( 'DataSourceFilterSet', 'JobFilterSet', 'ObjectChangeFilterSet', + 'ObjectTypeFilterSet', ) @@ -134,6 +134,25 @@ class JobFilterSet(BaseFilterSet): ) +class ObjectTypeFilterSet(django_filters.FilterSet): + q = django_filters.CharFilter( + method='search', + label=_('Search'), + ) + + class Meta: + model = ObjectType + fields = ('id', 'app_label', 'model') + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(app_label__icontains=value) | + Q(model__icontains=value) + ) + + class ObjectChangeFilterSet(BaseFilterSet): q = django_filters.CharFilter( method='search', @@ -167,7 +186,8 @@ class ObjectChangeFilterSet(BaseFilterSet): return queryset return queryset.filter( Q(user_name__icontains=value) | - Q(object_repr__icontains=value) + Q(object_repr__icontains=value) | + Q(message__icontains=value) ) diff --git a/netbox/core/jobs.py b/netbox/core/jobs.py index b3dfaf1e7..55069d4cf 100644 --- a/netbox/core/jobs.py +++ b/netbox/core/jobs.py @@ -1,13 +1,20 @@ import logging -import requests import sys +from datetime import timedelta +from importlib import import_module +import requests from django.conf import settings +from django.core.cache import cache +from django.utils import timezone +from packaging import version + +from core.models import Job, ObjectChange +from netbox.config import Config from netbox.jobs import JobRunner, system_job from netbox.search.backends import search_backend from utilities.proxy import resolve_proxies from .choices import DataSourceStatusChoices, JobIntervalChoices -from .exceptions import SyncError from .models import DataSource logger = logging.getLogger(__name__) @@ -21,21 +28,36 @@ class SyncDataSourceJob(JobRunner): class Meta: name = 'Synchronization' + @classmethod + def enqueue(cls, *args, **kwargs): + job = super().enqueue(*args, **kwargs) + + # Update the DataSource's synchronization status to queued + if datasource := job.object: + datasource.status = DataSourceStatusChoices.QUEUED + DataSource.objects.filter(pk=datasource.pk).update(status=datasource.status) + + return job + def run(self, *args, **kwargs): datasource = DataSource.objects.get(pk=self.job.object_id) + self.logger.debug(f"Found DataSource ID {datasource.pk}") try: + self.logger.info(f"Syncing data source {datasource}") datasource.sync() # Update the search cache for DataFiles belonging to this source + self.logger.debug("Updating search cache for data files") search_backend.cache(datasource.datafiles.iterator()) except Exception as e: + self.logger.error(f"Error syncing data source: {e}") DataSource.objects.filter(pk=datasource.pk).update(status=DataSourceStatusChoices.FAILED) - if type(e) is SyncError: - logging.error(e) raise e + self.logger.info("Syncing completed successfully") + @system_job(interval=JobIntervalChoices.INTERVAL_DAILY) class SystemHousekeepingJob(JobRunner): @@ -50,16 +72,23 @@ class SystemHousekeepingJob(JobRunner): if settings.DEBUG or 'test' in sys.argv: return - # TODO: Migrate other housekeeping functions from the `housekeeping` management command. self.send_census_report() + self.clear_expired_sessions() + self.prune_changelog() + self.delete_expired_jobs() + self.check_for_new_releases() @staticmethod def send_census_report(): """ Send a census report (if enabled). """ - # Skip if census reporting is disabled - if settings.ISOLATED_DEPLOYMENT or not settings.CENSUS_REPORTING_ENABLED: + logging.info("Reporting census data...") + if settings.ISOLATED_DEPLOYMENT: + logging.info("ISOLATED_DEPLOYMENT is enabled; skipping") + return + if not settings.CENSUS_REPORTING_ENABLED: + logging.info("CENSUS_REPORTING_ENABLED is disabled; skipping") return census_data = { @@ -76,3 +105,94 @@ class SystemHousekeepingJob(JobRunner): ) except requests.exceptions.RequestException: pass + + @staticmethod + def clear_expired_sessions(): + """ + Clear any expired sessions from the database. + """ + logging.info("Clearing expired sessions...") + engine = import_module(settings.SESSION_ENGINE) + try: + engine.SessionStore.clear_expired() + logging.info("Sessions cleared.") + except NotImplementedError: + logging.warning( + f"The configured session engine ({settings.SESSION_ENGINE}) does not support " + f"clearing sessions; skipping." + ) + + @staticmethod + def prune_changelog(): + """ + Delete any ObjectChange records older than the configured changelog retention time (if any). + """ + logging.info("Pruning old changelog entries...") + config = Config() + if not config.CHANGELOG_RETENTION: + logging.info("No retention period specified; skipping.") + return + + cutoff = timezone.now() - timedelta(days=config.CHANGELOG_RETENTION) + logging.debug(f"Retention period: {config.CHANGELOG_RETENTION} days") + logging.debug(f"Cut-off time: {cutoff}") + + count = ObjectChange.objects.filter(time__lt=cutoff).delete()[0] + logging.info(f"Deleted {count} expired records") + + @staticmethod + def delete_expired_jobs(): + """ + Delete any jobs older than the configured retention period (if any). + """ + logging.info("Deleting expired jobs...") + config = Config() + if not config.JOB_RETENTION: + logging.info("No retention period specified; skipping.") + return + + cutoff = timezone.now() - timedelta(days=config.JOB_RETENTION) + logging.debug(f"Retention period: {config.CHANGELOG_RETENTION} days") + logging.debug(f"Cut-off time: {cutoff}") + + count = Job.objects.filter(created__lt=cutoff).delete()[0] + logging.info(f"Deleted {count} expired records") + + @staticmethod + def check_for_new_releases(): + """ + Check for new releases and cache the latest release. + """ + logging.info("Checking for new releases...") + if settings.ISOLATED_DEPLOYMENT: + logging.info("ISOLATED_DEPLOYMENT is enabled; skipping") + return + if not settings.RELEASE_CHECK_URL: + logging.info("RELEASE_CHECK_URL is not set; skipping") + return + + # Fetch the latest releases + logging.debug(f"Release check URL: {settings.RELEASE_CHECK_URL}") + try: + response = requests.get( + url=settings.RELEASE_CHECK_URL, + headers={'Accept': 'application/vnd.github.v3+json'}, + proxies=resolve_proxies(url=settings.RELEASE_CHECK_URL) + ) + response.raise_for_status() + except requests.exceptions.RequestException as exc: + logging.error(f"Error fetching release: {exc}") + return + + # Determine the most recent stable release + releases = [] + for release in response.json(): + if 'tag_name' not in release or release.get('devrelease') or release.get('prerelease'): + continue + releases.append((version.parse(release['tag_name']), release.get('html_url'))) + latest_release = max(releases) + logging.debug(f"Found {len(response.json())} releases; {len(releases)} usable") + logging.info(f"Latest release: {latest_release[0]}") + + # Cache the most recent release + cache.set('latest_release', latest_release, None) diff --git a/netbox/core/management/commands/nbshell.py b/netbox/core/management/commands/nbshell.py index 8f729d10a..ef7ae191e 100644 --- a/netbox/core/management/commands/nbshell.py +++ b/netbox/core/management/commands/nbshell.py @@ -1,29 +1,48 @@ import code import platform -import sys +from collections import defaultdict +from types import SimpleNamespace +from colorama import Fore, Style from django import get_version from django.apps import apps from django.conf import settings from django.core.management.base import BaseCommand +from django.utils.module_loading import import_string -from core.models import ObjectType -from users.models import User +from netbox.constants import CORE_APPS +from netbox.plugins.utils import get_installed_plugins -APPS = ('circuits', 'core', 'dcim', 'extras', 'ipam', 'tenancy', 'users', 'virtualization', 'vpn', 'wireless') -EXCLUDE_MODELS = ( - 'extras.branch', - 'extras.stagedchange', -) -BANNER_TEXT = """### NetBox interactive shell ({node}) -### Python {python} | Django {django} | NetBox {netbox} -### lsmodels() will show available models. Use help() for more info.""".format( - node=platform.node(), - python=platform.python_version(), - django=get_version(), - netbox=settings.RELEASE.name -) +def color(color: str, text: str): + return getattr(Fore, color.upper()) + text + Style.RESET_ALL + + +def bright(text: str): + return Style.BRIGHT + text + Style.RESET_ALL + + +def get_models(app_config): + """ + Return a list of all non-private models within an app. + """ + return [ + model for model in app_config.get_models() + if not getattr(model, '_netbox_private', False) + ] + + +def get_constants(app_config): + """ + Return a dictionary mapping of all constants defined within an app. + """ + try: + constants = import_string(f'{app_config.name}.constants') + except ImportError: + return {} + return { + name: value for name, value in vars(constants).items() + } class Command(BaseCommand): @@ -36,47 +55,88 @@ class Command(BaseCommand): help='Python code to execute (instead of starting an interactive shell)', ) - def _lsmodels(self): - for app, models in self.django_models.items(): - app_name = apps.get_app_config(app).verbose_name + def _lsapps(self): + for app_label in self.django_models.keys(): + app_name = apps.get_app_config(app_label).verbose_name + print(f'{app_label} - {app_name}') + + def _lsmodels(self, app_label=None): + """ + Return a list of all models within each app. + + Args: + app_label: The name of a specific app + """ + if app_label: + if app_label not in self.django_models: + print(f"No models listed for {app_label}") + return + app_labels = [app_label] + else: + app_labels = self.django_models.keys() # All apps + + for app_label in app_labels: + app_name = apps.get_app_config(app_label).verbose_name print(f'{app_name}:') - for m in models: + for m in self.django_models[app_label]: print(f' {m}') def get_namespace(self): - namespace = {} + namespace = defaultdict(SimpleNamespace) - # Gather Django models and constants from each app - for app in APPS: - models = [] + # Iterate through all core apps & plugins to compile namespace of models and constants + for app_name in [*CORE_APPS, *get_installed_plugins().keys()]: + app_config = apps.get_app_config(app_name) - # Load models from each app - for model in apps.get_app_config(app).get_models(): - app_label = model._meta.app_label - model_name = model._meta.model_name - if f'{app_label}.{model_name}' not in EXCLUDE_MODELS: - namespace[model.__name__] = model - models.append(model.__name__) - self.django_models[app] = sorted(models) + # Populate models + if models := get_models(app_config): + for model in models: + setattr(namespace[app_name], model.__name__, model) + self.django_models[app_name] = sorted([ + model.__name__ for model in models + ]) - # Constants - try: - app_constants = sys.modules[f'{app}.constants'] - for name in dir(app_constants): - namespace[name] = getattr(app_constants, name) - except KeyError: - pass + # Populate constants + for const_name, const_value in get_constants(app_config).items(): + setattr(namespace[app_name], const_name, const_value) - # Additional objects to include - namespace['ObjectType'] = ObjectType - namespace['User'] = User - - # Load convenience commands - namespace.update({ + return { + **namespace, + 'lsapps': self._lsapps, 'lsmodels': self._lsmodels, - }) + } - return namespace + @staticmethod + def get_banner_text(): + lines = [ + '{title} ({hostname})'.format( + title=bright('NetBox interactive shell'), + hostname=platform.node(), + ), + '{python} | {django} | {netbox}'.format( + python=color('green', f'Python v{platform.python_version()}'), + django=color('green', f'Django v{get_version()}'), + netbox=color('green', settings.RELEASE.name), + ), + ] + + if installed_plugins := get_installed_plugins(): + plugin_list = ', '.join([ + color('cyan', f'{name} v{version}') for name, version in installed_plugins.items() + ]) + lines.append( + 'Plugins: {plugin_list}'.format( + plugin_list=plugin_list + ) + ) + + lines.append( + 'lsapps() & lsmodels() will show available models. Use help() for more info.' + ) + + return '\n'.join([ + f'### {line}' for line in lines + ]) def handle(self, **options): namespace = self.get_namespace() @@ -97,5 +157,4 @@ class Command(BaseCommand): readline.parse_and_bind('tab: complete') # Run interactive shell - shell = code.interact(banner=BANNER_TEXT, local=namespace) - return shell + return code.interact(banner=self.get_banner_text(), local=namespace) diff --git a/netbox/core/migrations/0008_contenttype_proxy.py b/netbox/core/migrations/0008_contenttype_proxy.py index 9acaf3ad7..a0af81813 100644 --- a/netbox/core/migrations/0008_contenttype_proxy.py +++ b/netbox/core/migrations/0008_contenttype_proxy.py @@ -1,4 +1,4 @@ -import core.models.contenttypes +import core.models.object_types from django.db import migrations @@ -19,7 +19,7 @@ class Migration(migrations.Migration): }, bases=('contenttypes.contenttype',), managers=[ - ('objects', core.models.contenttypes.ObjectTypeManager()), + ('objects', core.models.object_types.ObjectTypeManager()), ], ), ] diff --git a/netbox/core/migrations/0016_job_log_entries.py b/netbox/core/migrations/0016_job_log_entries.py new file mode 100644 index 000000000..030bd4e38 --- /dev/null +++ b/netbox/core/migrations/0016_job_log_entries.py @@ -0,0 +1,28 @@ +import django.contrib.postgres.fields +import django.core.serializers.json +from django.db import migrations, models + +import utilities.json + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0015_remove_redundant_indexes'), + ] + + operations = [ + migrations.AddField( + model_name='job', + name='log_entries', + field=django.contrib.postgres.fields.ArrayField( + base_field=models.JSONField( + decoder=utilities.json.JobLogDecoder, + encoder=django.core.serializers.json.DjangoJSONEncoder + ), + blank=True, + default=list, + size=None + ), + ), + ] diff --git a/netbox/core/migrations/0017_objectchange_message.py b/netbox/core/migrations/0017_objectchange_message.py new file mode 100644 index 000000000..c669513a0 --- /dev/null +++ b/netbox/core/migrations/0017_objectchange_message.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('core', '0016_job_log_entries'), + ] + + operations = [ + migrations.AddField( + model_name='objectchange', + name='message', + field=models.CharField(blank=True, editable=False, max_length=200), + ), + ] diff --git a/netbox/core/migrations/0018_concrete_objecttype.py b/netbox/core/migrations/0018_concrete_objecttype.py new file mode 100644 index 000000000..4e227fe7a --- /dev/null +++ b/netbox/core/migrations/0018_concrete_objecttype.py @@ -0,0 +1,63 @@ +import django.contrib.postgres.fields +import django.contrib.postgres.indexes +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('core', '0017_objectchange_message'), + ] + + operations = [ + # Delete the proxy model from the migration state + migrations.DeleteModel( + name='ObjectType', + ), + # Create the new concrete model + migrations.CreateModel( + name='ObjectType', + fields=[ + ( + 'contenttype_ptr', + models.OneToOneField( + on_delete=django.db.models.deletion.CASCADE, + parent_link=True, + primary_key=True, + serialize=False, + to='contenttypes.contenttype', + related_name='object_type' + ) + ), + ( + 'public', + models.BooleanField( + default=False + ) + ), + ( + 'features', + django.contrib.postgres.fields.ArrayField( + base_field=models.CharField(max_length=50), + default=list, + size=None + ) + ), + ], + options={ + 'verbose_name': 'object type', + 'verbose_name_plural': 'object types', + 'ordering': ('app_label', 'model'), + 'indexes': [ + django.contrib.postgres.indexes.GinIndex( + fields=['features'], + name='core_object_feature_aec4de_gin' + ), + ] + }, + bases=('contenttypes.contenttype',), + managers=[], + ), + ] diff --git a/netbox/core/models/__init__.py b/netbox/core/models/__init__.py index db00e67aa..27eb3a92b 100644 --- a/netbox/core/models/__init__.py +++ b/netbox/core/models/__init__.py @@ -1,4 +1,4 @@ -from .contenttypes import * +from .object_types import * from .change_logging import * from .config import * from .data import * diff --git a/netbox/core/models/change_logging.py b/netbox/core/models/change_logging.py index 1d1bbc07c..a011c457f 100644 --- a/netbox/core/models/change_logging.py +++ b/netbox/core/models/change_logging.py @@ -11,8 +11,8 @@ from mptt.models import MPTTModel from core.choices import ObjectChangeActionChoices from core.querysets import ObjectChangeQuerySet from netbox.models.features import ChangeLoggingMixin +from netbox.models.features import has_feature from utilities.data import shallow_compare_dict -from .contenttypes import ObjectType __all__ = ( 'ObjectChange', @@ -82,6 +82,12 @@ class ObjectChange(models.Model): max_length=200, editable=False ) + message = models.CharField( + verbose_name=_('message'), + max_length=200, + editable=False, + blank=True + ) prechange_data = models.JSONField( verbose_name=_('pre-change data'), editable=False, @@ -118,7 +124,7 @@ class ObjectChange(models.Model): super().clean() # Validate the assigned object type - if self.changed_object_type not in ObjectType.objects.with_feature('change_logging'): + if not has_feature(self.changed_object_type, 'change_logging'): raise ValidationError( _("Change logging is not supported for this object type ({type}).").format( type=self.changed_object_type diff --git a/netbox/core/models/contenttypes.py b/netbox/core/models/contenttypes.py index a7d5c91af..3d5c5e8b2 100644 --- a/netbox/core/models/contenttypes.py +++ b/netbox/core/models/contenttypes.py @@ -1,78 +1,3 @@ -from django.contrib.contenttypes.models import ContentType, ContentTypeManager -from django.db.models import Q - -from netbox.plugins import PluginConfig -from netbox.registry import registry -from utilities.string import title - -__all__ = ( - 'ObjectType', - 'ObjectTypeManager', -) - - -class ObjectTypeManager(ContentTypeManager): - - def public(self): - """ - Filter the base queryset to return only ContentTypes corresponding to "public" models; those which are listed - in registry['models'] and intended for reference by other objects. - """ - q = Q() - for app_label, models in registry['models'].items(): - q |= Q(app_label=app_label, model__in=models) - return self.get_queryset().filter(q) - - def with_feature(self, feature): - """ - Return the ContentTypes only for models which are registered as supporting the specified feature. For example, - we can find all ContentTypes for models which support webhooks with - - ContentType.objects.with_feature('event_rules') - """ - if feature not in registry['model_features']: - raise KeyError( - f"{feature} is not a registered model feature! Valid features are: {registry['model_features'].keys()}" - ) - - q = Q() - for app_label, models in registry['model_features'][feature].items(): - q |= Q(app_label=app_label, model__in=models) - - return self.get_queryset().filter(q) - - -class ObjectType(ContentType): - """ - Wrap Django's native ContentType model to use our custom manager. - """ - objects = ObjectTypeManager() - - class Meta: - proxy = True - - @property - def app_labeled_name(self): - # Override ContentType's "app | model" representation style. - return f"{self.app_verbose_name} > {title(self.model_verbose_name)}" - - @property - def app_verbose_name(self): - if model := self.model_class(): - return model._meta.app_config.verbose_name - - @property - def model_verbose_name(self): - if model := self.model_class(): - return model._meta.verbose_name - - @property - def model_verbose_name_plural(self): - if model := self.model_class(): - return model._meta.verbose_name_plural - - @property - def is_plugin_model(self): - if not (model := self.model_class()): - return # Return null if model class is invalid - return isinstance(model._meta.app_config, PluginConfig) +# TODO: Remove this module in NetBox v4.5 +# Provided for backward compatibility +from .object_types import * diff --git a/netbox/core/models/jobs.py b/netbox/core/models/jobs.py index 779e767b6..8a6bf6a1d 100644 --- a/netbox/core/models/jobs.py +++ b/netbox/core/models/jobs.py @@ -1,9 +1,12 @@ +import logging import uuid +from dataclasses import asdict from functools import partial import django_rq from django.conf import settings from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.postgres.fields import ArrayField from django.core.exceptions import ValidationError from django.core.serializers.json import DjangoJSONEncoder from django.core.validators import MinValueValidator @@ -14,8 +17,13 @@ from django.utils.translation import gettext as _ from rq.exceptions import InvalidJobOperation from core.choices import JobStatusChoices +from core.dataclasses import JobLogEntry +from core.events import JOB_COMPLETED, JOB_ERRORED, JOB_FAILED from core.models import ObjectType from core.signals import job_end, job_start +from extras.models import Notification +from netbox.models.features import has_feature +from utilities.json import JobLogDecoder from utilities.querysets import RestrictedQuerySet from utilities.rqworker import get_queue_for_model @@ -104,6 +112,15 @@ class Job(models.Model): verbose_name=_('job ID'), unique=True ) + log_entries = ArrayField( + verbose_name=_('log entries'), + base_field=models.JSONField( + encoder=DjangoJSONEncoder, + decoder=JobLogDecoder, + ), + blank=True, + default=list, + ) objects = RestrictedQuerySet.as_manager() @@ -116,7 +133,7 @@ class Job(models.Model): verbose_name_plural = _('jobs') def __str__(self): - return str(self.job_id) + return self.name def get_absolute_url(self): # TODO: Employ dynamic registration @@ -130,11 +147,18 @@ class Job(models.Model): def get_status_color(self): return JobStatusChoices.colors.get(self.status) + def get_event_type(self): + return { + JobStatusChoices.STATUS_COMPLETED: JOB_COMPLETED, + JobStatusChoices.STATUS_FAILED: JOB_FAILED, + JobStatusChoices.STATUS_ERRORED: JOB_ERRORED, + }.get(self.status) + def clean(self): super().clean() # Validate the assigned object type - if self.object_type and self.object_type not in ObjectType.objects.with_feature('jobs'): + if self.object_type and not has_feature(self.object_type, 'jobs'): raise ValidationError( _("Jobs cannot be assigned to this object type ({type}).").format(type=self.object_type) ) @@ -187,24 +211,38 @@ class Job(models.Model): """ Mark the job as completed, optionally specifying a particular termination status. """ - valid_statuses = JobStatusChoices.TERMINAL_STATE_CHOICES - if status not in valid_statuses: + if status not in JobStatusChoices.TERMINAL_STATE_CHOICES: raise ValueError( _("Invalid status for job termination. Choices are: {choices}").format( - choices=', '.join(valid_statuses) + choices=', '.join(JobStatusChoices.TERMINAL_STATE_CHOICES) ) ) - # Mark the job as completed + # Set the job's status and completion time self.status = status if error: self.error = error self.completed = timezone.now() self.save() + # Notify the user (if any) of completion + if self.user: + Notification( + user=self.user, + object=self, + event_type=self.get_event_type(), + ).save() + # Send signal job_end.send(self) + def log(self, record: logging.LogRecord): + """ + Record a LogRecord from Python's native logging in the job's log. + """ + entry = JobLogEntry.from_logrecord(record) + self.log_entries.append(asdict(entry)) + @classmethod def enqueue( cls, diff --git a/netbox/core/models/object_types.py b/netbox/core/models/object_types.py new file mode 100644 index 000000000..e8a4f7202 --- /dev/null +++ b/netbox/core/models/object_types.py @@ -0,0 +1,205 @@ +from collections import defaultdict + +from django.contrib.contenttypes.models import ContentType +from django.contrib.postgres.fields import ArrayField +from django.contrib.postgres.indexes import GinIndex +from django.core.exceptions import ObjectDoesNotExist +from django.db import models +from django.db.models import Q +from django.utils.translation import gettext as _ + +from netbox.plugins import PluginConfig +from netbox.registry import registry +from utilities.string import title + +__all__ = ( + 'ObjectType', + 'ObjectTypeManager', + 'ObjectTypeQuerySet', +) + + +class ObjectTypeQuerySet(models.QuerySet): + + def create(self, **kwargs): + # If attempting to create a new ObjectType for a given app_label & model, replace those kwargs + # with a reference to the ContentType (if one exists). + if (app_label := kwargs.get('app_label')) and (model := kwargs.get('model')): + try: + kwargs['contenttype_ptr'] = ContentType.objects.get(app_label=app_label, model=model) + except ObjectDoesNotExist: + pass + return super().create(**kwargs) + + +class ObjectTypeManager(models.Manager): + + def get_queryset(self): + return ObjectTypeQuerySet(self.model, using=self._db) + + def get_by_natural_key(self, app_label, model): + """ + Retrieve an ObjectType by its application label & model name. + + This method exists to provide parity with ContentTypeManager. + """ + return self.get(app_label=app_label, model=model) + + # TODO: Remove in NetBox v4.5 + def get_for_id(self, id): + """ + Retrieve an ObjectType by its primary key (numeric ID). + + This method exists to provide parity with ContentTypeManager. + """ + return self.get(pk=id) + + def _get_opts(self, model, for_concrete_model): + if for_concrete_model: + model = model._meta.concrete_model + return model._meta + + def get_for_model(self, model, for_concrete_model=True): + """ + Retrieve or create and return the ObjectType for a model. + """ + from netbox.models.features import get_model_features, model_is_public + opts = self._get_opts(model, for_concrete_model) + + try: + # Use .get() instead of .get_or_create() initially to ensure db_for_read is honored (Django bug #20401). + ot = self.get(app_label=opts.app_label, model=opts.model_name) + except self.model.DoesNotExist: + # If the ObjectType doesn't exist, create it. (Use .get_or_create() to avoid race conditions.) + ot = self.get_or_create( + app_label=opts.app_label, + model=opts.model_name, + public=model_is_public(model), + features=get_model_features(model.__class__), + )[0] + + return ot + + def get_for_models(self, *models, for_concrete_models=True): + """ + Retrieve or create the ObjectTypes for multiple models, returning a mapping {model: ObjectType}. + + This method exists to provide parity with ContentTypeManager. + """ + from netbox.models.features import get_model_features, model_is_public + results = {} + + # Compile the model and options mappings + needed_models = defaultdict(set) + needed_opts = defaultdict(list) + for model in models: + opts = self._get_opts(model, for_concrete_models) + needed_models[opts.app_label].add(opts.model_name) + needed_opts[(opts.app_label, opts.model_name)].append(model) + + # Fetch existing ObjectType from the database + condition = Q( + *( + Q(('app_label', app_label), ('model__in', model_names)) + for app_label, model_names in needed_models.items() + ), + _connector=Q.OR, + ) + for ot in self.filter(condition): + opts_models = needed_opts.pop((ot.app_label, ot.model), []) + for model in opts_models: + results[model] = ot + + # Create any missing ObjectTypes + for (app_label, model_name), opts_models in needed_opts.items(): + for model in opts_models: + results[model] = self.create( + app_label=app_label, + model=model_name, + public=model_is_public(model), + features=get_model_features(model.__class__), + ) + + return results + + def public(self): + """ + Includes only ObjectTypes for "public" models. + + Filter the base queryset to return only ObjectTypes corresponding to public models; those which are intended + for reference by other objects within the application. + """ + return self.get_queryset().filter(public=True) + + def with_feature(self, feature): + """ + Return ObjectTypes only for models which support the given feature. + + Only ObjectTypes which list the specified feature will be included. Supported features are declared in + netbox.models.features.FEATURES_MAP. For example, we can find all ObjectTypes for models which support event + rules with: + + ObjectType.objects.with_feature('event_rules') + """ + if feature not in registry['model_features']: + raise KeyError( + f"{feature} is not a registered model feature! Valid features are: {registry['model_features'].keys()}" + ) + return self.get_queryset().filter(features__contains=[feature]) + + +class ObjectType(ContentType): + """ + Wrap Django's native ContentType model to use our custom manager. + """ + contenttype_ptr = models.OneToOneField( + on_delete=models.CASCADE, + to='contenttypes.ContentType', + parent_link=True, + primary_key=True, + serialize=False, + related_name='object_type', + ) + public = models.BooleanField( + default=False, + ) + features = ArrayField( + base_field=models.CharField(max_length=50), + default=list, + ) + + objects = ObjectTypeManager() + + class Meta: + verbose_name = _('object type') + verbose_name_plural = _('object types') + ordering = ('app_label', 'model') + indexes = [ + GinIndex(fields=['features']), + ] + + @property + def app_labeled_name(self): + # Override ContentType's "app | model" representation style. + return f"{self.app_verbose_name} > {title(self.model_verbose_name)}" + + @property + def app_verbose_name(self): + if model := self.model_class(): + return model._meta.app_config.verbose_name + + @property + def model_verbose_name(self): + if model := self.model_class(): + return model._meta.verbose_name + + @property + def model_verbose_name_plural(self): + if model := self.model_class(): + return model._meta.verbose_name_plural + + @property + def is_plugin_model(self): + if not (model := self.model_class()): + return # Return null if model class is invalid + return isinstance(model._meta.app_config, PluginConfig) diff --git a/netbox/core/signals.py b/netbox/core/signals.py index 8ba8cc244..dff5571ab 100644 --- a/netbox/core/signals.py +++ b/netbox/core/signals.py @@ -1,20 +1,23 @@ import logging +from threading import local from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ValidationError +from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.db.models.fields.reverse_related import ManyToManyRel, ManyToOneRel -from django.db.models.signals import m2m_changed, post_save, pre_delete +from django.db.models.signals import m2m_changed, post_migrate, post_save, pre_delete from django.dispatch import receiver, Signal +from django.core.signals import request_finished from django.utils.translation import gettext_lazy as _ from django_prometheus.models import model_deletes, model_inserts, model_updates from core.choices import JobStatusChoices, ObjectChangeActionChoices from core.events import * +from core.models import ObjectType from extras.events import enqueue_event from extras.utils import run_validators from netbox.config import get_config from netbox.context import current_request, events_queue -from netbox.models.features import ChangeLoggingMixin +from netbox.models.features import ChangeLoggingMixin, get_model_features, model_is_public from utilities.exceptions import AbortRequest from .models import ConfigRevision, DataSource, ObjectChange @@ -38,10 +41,45 @@ post_sync = Signal() clear_events = Signal() +# +# Object types +# + +@receiver(post_migrate) +def update_object_types(sender, **kwargs): + """ + Create or update the corresponding ObjectType for each model within the migrated app. + """ + for model in sender.get_models(): + app_label, model_name = model._meta.label_lower.split('.') + + # Determine whether model is public and its supported features + is_public = model_is_public(model) + features = get_model_features(model) + + # Create/update the ObjectType for the model + try: + ot = ObjectType.objects.get_by_natural_key(app_label=app_label, model=model_name) + ot.public = is_public + ot.features = features + ot.save() + except ObjectDoesNotExist: + ObjectType.objects.create( + app_label=app_label, + model=model_name, + public=is_public, + features=features, + ) + + # # Change logging & event handling # +# Used to track received signals per object +_signals_received = local() + + @receiver((post_save, m2m_changed)) def handle_changed_object(sender, instance, **kwargs): """ @@ -98,7 +136,7 @@ def handle_changed_object(sender, instance, **kwargs): # Enqueue the object for event processing queue = events_queue.get() - enqueue_event(queue, instance, request.user, request.id, event_type) + enqueue_event(queue, instance, request, event_type) events_queue.set(queue) # Increment metric counters @@ -130,6 +168,16 @@ def handle_deleted_object(sender, instance, **kwargs): if request is None: return + # Check whether we've already processed a pre_delete signal for this object. (This can + # happen e.g. when both a parent object and its child are deleted simultaneously, due + # to cascading deletion.) + if not hasattr(_signals_received, 'pre_delete'): + _signals_received.pre_delete = set() + signature = (ContentType.objects.get_for_model(instance), instance.pk) + if signature in _signals_received.pre_delete: + return + _signals_received.pre_delete.add(signature) + # Record an ObjectChange if applicable if hasattr(instance, 'to_objectchange'): if hasattr(instance, 'snapshot') and not getattr(instance, '_prechange_snapshot', None): @@ -172,13 +220,21 @@ def handle_deleted_object(sender, instance, **kwargs): # Enqueue the object for event processing queue = events_queue.get() - enqueue_event(queue, instance, request.user, request.id, OBJECT_DELETED) + enqueue_event(queue, instance, request, OBJECT_DELETED) events_queue.set(queue) # Increment metric counters model_deletes.labels(instance._meta.model_name).inc() +@receiver(request_finished) +def clear_signal_history(sender, **kwargs): + """ + Clear out the signals history once the request is finished. + """ + _signals_received.pre_delete = set() + + @receiver(clear_events) def clear_events_queue(sender, **kwargs): """ diff --git a/netbox/core/tables/change_logging.py b/netbox/core/tables/change_logging.py index aced0e8a6..b35b711bb 100644 --- a/netbox/core/tables/change_logging.py +++ b/netbox/core/tables/change_logging.py @@ -41,6 +41,9 @@ class ObjectChangeTable(NetBoxTable): template_code=OBJECTCHANGE_REQUEST_ID, verbose_name=_('Request ID') ) + message = tables.Column( + verbose_name=_('Message'), + ) actions = columns.ActionsColumn( actions=() ) @@ -49,5 +52,8 @@ class ObjectChangeTable(NetBoxTable): model = ObjectChange fields = ( 'pk', 'time', 'user_name', 'full_name', 'action', 'changed_object_type', 'object_repr', 'request_id', - 'actions', + 'message', 'actions', + ) + default_columns = ( + 'pk', 'time', 'user_name', 'action', 'changed_object_type', 'object_repr', 'message', 'actions', ) diff --git a/netbox/core/tables/columns.py b/netbox/core/tables/columns.py index f3d985bc3..84187b0fa 100644 --- a/netbox/core/tables/columns.py +++ b/netbox/core/tables/columns.py @@ -1,12 +1,11 @@ import django_tables2 as tables from django.utils.safestring import mark_safe -from core.constants import RQ_TASK_STATUSES from netbox.registry import registry __all__ = ( 'BackendTypeColumn', - 'RQJobStatusColumn', + 'BadgeColumn', ) @@ -23,14 +22,21 @@ class BackendTypeColumn(tables.Column): return value -class RQJobStatusColumn(tables.Column): +class BadgeColumn(tables.Column): """ - Render a colored label for the status of an RQ job. + Render a colored badge for a value. + + Args: + badges: A dictionary mapping of values to core.constants.Badge instances. """ + def __init__(self, badges, *args, **kwargs): + super().__init__(*args, **kwargs) + self.badges = badges + def render(self, value): - status = RQ_TASK_STATUSES.get(value) - return mark_safe(f'{status.label}') + badge = self.badges.get(value) + return mark_safe(f'{badge.label}') def value(self, value): - status = RQ_TASK_STATUSES.get(value) - return status.label + badge = self.badges.get(value) + return badge.label diff --git a/netbox/core/tables/jobs.py b/netbox/core/tables/jobs.py index ac27224b3..00032057f 100644 --- a/netbox/core/tables/jobs.py +++ b/netbox/core/tables/jobs.py @@ -1,8 +1,10 @@ import django_tables2 as tables from django.utils.translation import gettext_lazy as _ -from netbox.tables import NetBoxTable, columns -from ..models import Job +from netbox.tables import BaseTable, NetBoxTable, columns +from core.constants import JOB_LOG_ENTRY_LEVELS +from core.models import Job +from core.tables.columns import BadgeColumn class JobTable(NetBoxTable): @@ -40,6 +42,9 @@ class JobTable(NetBoxTable): completed = columns.DateTimeColumn( verbose_name=_('Completed'), ) + log_entries = tables.Column( + verbose_name=_('Log Entries'), + ) actions = columns.ActionsColumn( actions=('delete',) ) @@ -53,3 +58,24 @@ class JobTable(NetBoxTable): default_columns = ( 'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'started', 'completed', 'user', ) + + def render_log_entries(self, value): + return len(value) + + +class JobLogEntryTable(BaseTable): + timestamp = columns.DateTimeColumn( + timespec='milliseconds', + verbose_name=_('Time'), + ) + level = BadgeColumn( + badges=JOB_LOG_ENTRY_LEVELS, + verbose_name=_('Level'), + ) + message = tables.Column( + verbose_name=_('Message'), + ) + + class Meta(BaseTable.Meta): + empty_text = _('No log entries') + fields = ('timestamp', 'level', 'message') diff --git a/netbox/core/tables/tasks.py b/netbox/core/tables/tasks.py index f53e598b5..64641b282 100644 --- a/netbox/core/tables/tasks.py +++ b/netbox/core/tables/tasks.py @@ -2,7 +2,8 @@ import django_tables2 as tables from django.utils.translation import gettext_lazy as _ from django_tables2.utils import A -from core.tables.columns import RQJobStatusColumn +from core.constants import RQ_TASK_STATUSES +from core.tables.columns import BadgeColumn from netbox.tables import BaseTable, columns @@ -84,7 +85,8 @@ class BackgroundTaskTable(BaseTable): ended_at = columns.DateTimeColumn( verbose_name=_("Ended") ) - status = RQJobStatusColumn( + status = BadgeColumn( + badges=RQ_TASK_STATUSES, verbose_name=_("Status"), accessor='get_status' ) diff --git a/netbox/core/tests/test_api.py b/netbox/core/tests/test_api.py index e9e77f252..4a285bdb4 100644 --- a/netbox/core/tests/test_api.py +++ b/netbox/core/tests/test_api.py @@ -7,6 +7,7 @@ from django.utils import timezone from rq.job import Job as RQ_Job, JobStatus from rq.registry import FailedJobRegistry, StartedJobRegistry +from rest_framework import status from users.models import Token, User from utilities.testing import APITestCase, APIViewTestCases, TestCase from utilities.testing.utils import disable_logging @@ -101,6 +102,22 @@ class DataFileTest( DataFile.objects.bulk_create(data_files) +class ObjectTypeTest(APITestCase): + + def test_list_objects(self): + object_type_count = ObjectType.objects.count() + + response = self.client.get(reverse('extras-api:objecttype-list'), **self.header) + self.assertHttpStatus(response, status.HTTP_200_OK) + self.assertEqual(response.data['count'], object_type_count) + + def test_get_object(self): + object_type = ObjectType.objects.first() + + url = reverse('extras-api:objecttype-detail', kwargs={'pk': object_type.pk}) + self.assertHttpStatus(self.client.get(url, **self.header), status.HTTP_200_OK) + + class BackgroundTaskTestCase(TestCase): user_permissions = () diff --git a/netbox/core/tests/test_changelog.py b/netbox/core/tests/test_changelog.py index df8461076..4a00e4a25 100644 --- a/netbox/core/tests/test_changelog.py +++ b/netbox/core/tests/test_changelog.py @@ -346,6 +346,38 @@ class ChangeLogViewTest(ModelViewTestCase): self.assertEqual(changes[1].changed_object_type, ContentType.objects.get_for_model(Interface)) self.assertEqual(changes[2].changed_object_type, ContentType.objects.get_for_model(Device)) + def test_duplicate_deletions(self): + """ + Check that a cascading deletion event does not generate multiple "deleted" ObjectChange records for + the same object. + """ + role1 = DeviceRole(name='Role 1', slug='role-1') + role1.save() + role2 = DeviceRole(name='Role 2', slug='role-2', parent=role1) + role2.save() + pk_list = [role1.pk, role2.pk] + + # Delete both objects simultaneously + form_data = { + 'pk': pk_list, + 'confirm': True, + '_confirm': True, + } + request = { + 'path': reverse('dcim:devicerole_bulk_delete'), + 'data': post_data(form_data), + } + self.add_permissions('dcim.delete_devicerole') + self.assertHttpStatus(self.client.post(**request), 302) + + # This should result in exactly one change record per object + objectchanges = ObjectChange.objects.filter( + changed_object_type=ContentType.objects.get_for_model(DeviceRole), + changed_object_id__in=pk_list, + action=ObjectChangeActionChoices.ACTION_DELETE + ) + self.assertEqual(objectchanges.count(), 2) + class ChangeLogAPITest(APITestCase): diff --git a/netbox/core/tests/test_filtersets.py b/netbox/core/tests/test_filtersets.py index b7dfd516e..4b2cff84d 100644 --- a/netbox/core/tests/test_filtersets.py +++ b/netbox/core/tests/test_filtersets.py @@ -150,7 +150,7 @@ class DataFileTestCase(TestCase, ChangeLoggedFilterSetTests): class ObjectChangeTestCase(TestCase, BaseFilterSetTests): queryset = ObjectChange.objects.all() filterset = ObjectChangeFilterSet - ignore_fields = ('prechange_data', 'postchange_data') + ignore_fields = ('message', 'prechange_data', 'postchange_data') @classmethod def setUpTestData(cls): diff --git a/netbox/core/tests/test_models.py b/netbox/core/tests/test_models.py index ff71c2e88..28225c7a6 100644 --- a/netbox/core/tests/test_models.py +++ b/netbox/core/tests/test_models.py @@ -1,7 +1,10 @@ +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ObjectDoesNotExist from django.test import TestCase -from core.models import DataSource +from core.models import DataSource, ObjectType from core.choices import ObjectChangeActionChoices +from dcim.models import Site, Location, Device from netbox.constants import CENSOR_TOKEN, CENSOR_TOKEN_CHANGED @@ -120,3 +123,80 @@ class DataSourceChangeLoggingTestCase(TestCase): self.assertEqual(objectchange.prechange_data['parameters']['password'], CENSOR_TOKEN) self.assertEqual(objectchange.postchange_data['parameters']['username'], 'username2') self.assertEqual(objectchange.postchange_data['parameters']['password'], CENSOR_TOKEN) + + +class ObjectTypeTest(TestCase): + + def test_create(self): + """ + Test that an ObjectType created for a given app_label & model name will be automatically assigned to + the appropriate ContentType. + """ + kwargs = { + 'app_label': 'foo', + 'model': 'bar', + } + ct = ContentType.objects.create(**kwargs) + ot = ObjectType.objects.create(**kwargs) + self.assertEqual(ot.contenttype_ptr, ct) + + def test_get_by_natural_key(self): + """ + Test that get_by_natural_key() returns the appropriate ObjectType. + """ + self.assertEqual( + ObjectType.objects.get_by_natural_key('dcim', 'site'), + ObjectType.objects.get(app_label='dcim', model='site') + ) + with self.assertRaises(ObjectDoesNotExist): + ObjectType.objects.get_by_natural_key('foo', 'bar') + + def test_get_for_id(self): + """ + Test that get_by_id() returns the appropriate ObjectType. + """ + ot = ObjectType.objects.get_by_natural_key('dcim', 'site') + self.assertEqual( + ObjectType.objects.get_for_id(ot.pk), + ObjectType.objects.get(pk=ot.pk) + ) + with self.assertRaises(ObjectDoesNotExist): + ObjectType.objects.get_for_id(0) + + def test_get_for_model(self): + """ + Test that get_by_model() returns the appropriate ObjectType. + """ + self.assertEqual( + ObjectType.objects.get_for_model(Site), + ObjectType.objects.get_by_natural_key('dcim', 'site') + ) + + def test_get_for_models(self): + """ + Test that get_by_models() returns the appropriate ObjectType mapping. + """ + self.assertEqual( + ObjectType.objects.get_for_models(Site, Location, Device), + { + Site: ObjectType.objects.get_by_natural_key('dcim', 'site'), + Location: ObjectType.objects.get_by_natural_key('dcim', 'location'), + Device: ObjectType.objects.get_by_natural_key('dcim', 'device'), + } + ) + + def test_public(self): + """ + Test that public() returns only ObjectTypes for public models. + """ + public_ots = ObjectType.objects.public() + self.assertIn(ObjectType.objects.get_by_natural_key('dcim', 'site'), public_ots) + self.assertNotIn(ObjectType.objects.get_by_natural_key('extras', 'taggeditem'), public_ots) + + def test_with_feature(self): + """ + Test that with_feature() returns only ObjectTypes for models which support the specified feature. + """ + bookmarks_ots = ObjectType.objects.with_feature('bookmarks') + self.assertIn(ObjectType.objects.get_by_natural_key('dcim', 'site'), bookmarks_ots) + self.assertNotIn(ObjectType.objects.get_by_natural_key('dcim', 'cabletermination'), bookmarks_ots) diff --git a/netbox/core/views.py b/netbox/core/views.py index 5729e5f2c..31d60dee6 100644 --- a/netbox/core/views.py +++ b/netbox/core/views.py @@ -32,13 +32,12 @@ from utilities.forms import ConfirmationForm from utilities.htmx import htmx_partial from utilities.json import ConfigJSONEncoder from utilities.query import count_related -from utilities.views import ContentTypePermissionRequiredMixin, GetRelatedModelsMixin, register_model_view +from utilities.views import ContentTypePermissionRequiredMixin, GetRelatedModelsMixin, ViewTab, register_model_view from . import filtersets, forms, tables -from .choices import DataSourceStatusChoices from .jobs import SyncDataSourceJob from .models import * from .plugins import get_catalog_plugins, get_local_plugins -from .tables import CatalogPluginTable, PluginVersionTable +from .tables import CatalogPluginTable, JobLogEntryTable, PluginVersionTable # @@ -79,12 +78,8 @@ class DataSourceSyncView(BaseObjectView): def post(self, request, pk): datasource = get_object_or_404(self.queryset, pk=pk) - - # Enqueue the sync job & update the DataSource's status + # Enqueue the sync job job = SyncDataSourceJob.enqueue(instance=datasource, user=request.user) - datasource.status = DataSourceStatusChoices.QUEUED - DataSource.objects.filter(pk=datasource.pk).update(status=datasource.status) - messages.success( request, _("Queued job #{id} to sync {datasource}").format(id=job.pk, datasource=datasource) @@ -184,6 +179,25 @@ class JobView(generic.ObjectView): actions = (DeleteObject,) +@register_model_view(Job, 'log') +class JobLogView(generic.ObjectView): + queryset = Job.objects.all() + actions = (DeleteObject,) + template_name = 'core/job/log.html' + tab = ViewTab( + label=_('Log'), + badge=lambda obj: len(obj.log_entries), + weight=500, + ) + + def get_extra_context(self, request, instance): + table = JobLogEntryTable(instance.log_entries) + table.configure(request) + return { + 'table': table, + } + + @register_model_view(Job, 'delete') class JobDeleteView(generic.ObjectDeleteView): queryset = Job.objects.defer('data') diff --git a/netbox/dcim/api/serializers_/devicetype_components.py b/netbox/dcim/api/serializers_/devicetype_components.py index 04f6395a6..8d4403d2d 100644 --- a/netbox/dcim/api/serializers_/devicetype_components.py +++ b/netbox/dcim/api/serializers_/devicetype_components.py @@ -9,7 +9,7 @@ from dcim.models import ( InventoryItemTemplate, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate, ) from netbox.api.fields import ChoiceField, ContentTypeField -from netbox.api.serializers import ValidatedModelSerializer +from netbox.api.serializers import ChangeLogMessageSerializer, ValidatedModelSerializer from utilities.api import get_serializer_for_model from wireless.choices import * from .devicetypes import DeviceTypeSerializer, ModuleTypeSerializer @@ -31,7 +31,11 @@ __all__ = ( ) -class ConsolePortTemplateSerializer(ValidatedModelSerializer): +class ComponentTemplateSerializer(ChangeLogMessageSerializer, ValidatedModelSerializer): + pass + + +class ConsolePortTemplateSerializer(ComponentTemplateSerializer): device_type = DeviceTypeSerializer( nested=True, required=False, @@ -59,7 +63,7 @@ class ConsolePortTemplateSerializer(ValidatedModelSerializer): brief_fields = ('id', 'url', 'display', 'name', 'description') -class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer): +class ConsoleServerPortTemplateSerializer(ComponentTemplateSerializer): device_type = DeviceTypeSerializer( nested=True, required=False, @@ -87,7 +91,7 @@ class ConsoleServerPortTemplateSerializer(ValidatedModelSerializer): brief_fields = ('id', 'url', 'display', 'name', 'description') -class PowerPortTemplateSerializer(ValidatedModelSerializer): +class PowerPortTemplateSerializer(ComponentTemplateSerializer): device_type = DeviceTypeSerializer( nested=True, required=False, @@ -116,7 +120,7 @@ class PowerPortTemplateSerializer(ValidatedModelSerializer): brief_fields = ('id', 'url', 'display', 'name', 'description') -class PowerOutletTemplateSerializer(ValidatedModelSerializer): +class PowerOutletTemplateSerializer(ComponentTemplateSerializer): device_type = DeviceTypeSerializer( nested=True, required=False, @@ -156,7 +160,7 @@ class PowerOutletTemplateSerializer(ValidatedModelSerializer): brief_fields = ('id', 'url', 'display', 'name', 'description') -class InterfaceTemplateSerializer(ValidatedModelSerializer): +class InterfaceTemplateSerializer(ComponentTemplateSerializer): device_type = DeviceTypeSerializer( nested=True, required=False, @@ -202,7 +206,7 @@ class InterfaceTemplateSerializer(ValidatedModelSerializer): brief_fields = ('id', 'url', 'display', 'name', 'description') -class RearPortTemplateSerializer(ValidatedModelSerializer): +class RearPortTemplateSerializer(ComponentTemplateSerializer): device_type = DeviceTypeSerializer( required=False, nested=True, @@ -226,7 +230,7 @@ class RearPortTemplateSerializer(ValidatedModelSerializer): brief_fields = ('id', 'url', 'display', 'name', 'description') -class FrontPortTemplateSerializer(ValidatedModelSerializer): +class FrontPortTemplateSerializer(ComponentTemplateSerializer): device_type = DeviceTypeSerializer( nested=True, required=False, @@ -251,7 +255,7 @@ class FrontPortTemplateSerializer(ValidatedModelSerializer): brief_fields = ('id', 'url', 'display', 'name', 'description') -class ModuleBayTemplateSerializer(ValidatedModelSerializer): +class ModuleBayTemplateSerializer(ComponentTemplateSerializer): device_type = DeviceTypeSerializer( nested=True, required=False, @@ -274,7 +278,7 @@ class ModuleBayTemplateSerializer(ValidatedModelSerializer): brief_fields = ('id', 'url', 'display', 'name', 'description') -class DeviceBayTemplateSerializer(ValidatedModelSerializer): +class DeviceBayTemplateSerializer(ComponentTemplateSerializer): device_type = DeviceTypeSerializer( nested=True ) @@ -288,7 +292,7 @@ class DeviceBayTemplateSerializer(ValidatedModelSerializer): brief_fields = ('id', 'url', 'display', 'name', 'description') -class InventoryItemTemplateSerializer(ValidatedModelSerializer): +class InventoryItemTemplateSerializer(ComponentTemplateSerializer): device_type = DeviceTypeSerializer( nested=True ) diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 9db7c250e..5f70683ae 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -11,6 +11,7 @@ from ipam.choices import VLANQinQRoleChoices from ipam.models import ASN, VLAN, VLANGroup, VRF from netbox.choices import * from netbox.forms import NetBoxModelBulkEditForm +from netbox.forms.mixins import ChangelogMessageMixin from tenancy.models import Tenant from users.models import User from utilities.forms import BulkEditForm, add_blank_choice, form_from_model @@ -1037,7 +1038,11 @@ class PowerFeedBulkEditForm(NetBoxModelBulkEditForm): # Device component templates # -class ConsolePortTemplateBulkEditForm(BulkEditForm): +class ComponentTemplateBulkEditForm(ChangelogMessageMixin, BulkEditForm): + pass + + +class ConsolePortTemplateBulkEditForm(ComponentTemplateBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=ConsolePortTemplate.objects.all(), widget=forms.MultipleHiddenInput() @@ -1056,7 +1061,7 @@ class ConsolePortTemplateBulkEditForm(BulkEditForm): nullable_fields = ('label', 'type', 'description') -class ConsoleServerPortTemplateBulkEditForm(BulkEditForm): +class ConsoleServerPortTemplateBulkEditForm(ComponentTemplateBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=ConsoleServerPortTemplate.objects.all(), widget=forms.MultipleHiddenInput() @@ -1079,7 +1084,7 @@ class ConsoleServerPortTemplateBulkEditForm(BulkEditForm): nullable_fields = ('label', 'type', 'description') -class PowerPortTemplateBulkEditForm(BulkEditForm): +class PowerPortTemplateBulkEditForm(ComponentTemplateBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=PowerPortTemplate.objects.all(), widget=forms.MultipleHiddenInput() @@ -1114,7 +1119,7 @@ class PowerPortTemplateBulkEditForm(BulkEditForm): nullable_fields = ('label', 'type', 'maximum_draw', 'allocated_draw', 'description') -class PowerOutletTemplateBulkEditForm(BulkEditForm): +class PowerOutletTemplateBulkEditForm(ComponentTemplateBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=PowerOutletTemplate.objects.all(), widget=forms.MultipleHiddenInput() @@ -1165,7 +1170,7 @@ class PowerOutletTemplateBulkEditForm(BulkEditForm): self.fields['power_port'].widget.attrs['disabled'] = True -class InterfaceTemplateBulkEditForm(BulkEditForm): +class InterfaceTemplateBulkEditForm(ComponentTemplateBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=InterfaceTemplate.objects.all(), widget=forms.MultipleHiddenInput() @@ -1216,7 +1221,7 @@ class InterfaceTemplateBulkEditForm(BulkEditForm): nullable_fields = ('label', 'description', 'poe_mode', 'poe_type', 'rf_role') -class FrontPortTemplateBulkEditForm(BulkEditForm): +class FrontPortTemplateBulkEditForm(ComponentTemplateBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=FrontPortTemplate.objects.all(), widget=forms.MultipleHiddenInput() @@ -1243,7 +1248,7 @@ class FrontPortTemplateBulkEditForm(BulkEditForm): nullable_fields = ('description',) -class RearPortTemplateBulkEditForm(BulkEditForm): +class RearPortTemplateBulkEditForm(ComponentTemplateBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=RearPortTemplate.objects.all(), widget=forms.MultipleHiddenInput() @@ -1270,7 +1275,7 @@ class RearPortTemplateBulkEditForm(BulkEditForm): nullable_fields = ('description',) -class ModuleBayTemplateBulkEditForm(BulkEditForm): +class ModuleBayTemplateBulkEditForm(ComponentTemplateBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=ModuleBayTemplate.objects.all(), widget=forms.MultipleHiddenInput() @@ -1288,7 +1293,7 @@ class ModuleBayTemplateBulkEditForm(BulkEditForm): nullable_fields = ('label', 'position', 'description') -class DeviceBayTemplateBulkEditForm(BulkEditForm): +class DeviceBayTemplateBulkEditForm(ComponentTemplateBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=DeviceBayTemplate.objects.all(), widget=forms.MultipleHiddenInput() @@ -1306,7 +1311,7 @@ class DeviceBayTemplateBulkEditForm(BulkEditForm): nullable_fields = ('label', 'description') -class InventoryItemTemplateBulkEditForm(BulkEditForm): +class InventoryItemTemplateBulkEditForm(ComponentTemplateBulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=InventoryItemTemplate.objects.all(), widget=forms.MultipleHiddenInput() diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 3ad4ced91..fc33c2162 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -470,8 +470,8 @@ class ModuleTypeImportForm(NetBoxModelImportForm): class Meta: model = ModuleType fields = [ - 'manufacturer', 'model', 'part_number', 'description', 'airflow', 'weight', 'weight_unit', 'comments', - 'tags', + 'manufacturer', 'model', 'part_number', 'description', 'airflow', 'weight', 'weight_unit', 'profile', + 'comments', 'tags' ] @@ -1335,6 +1335,13 @@ class MACAddressImportForm(NetBoxModelImportForm): class CableImportForm(NetBoxModelImportForm): # Termination A + side_a_site = CSVModelChoiceField( + label=_('Side A site'), + queryset=Site.objects.all(), + required=False, + to_field_name='name', + help_text=_('Site of parent device A (if any)'), + ) side_a_device = CSVModelChoiceField( label=_('Side A device'), queryset=Device.objects.all(), @@ -1353,6 +1360,13 @@ class CableImportForm(NetBoxModelImportForm): ) # Termination B + side_b_site = CSVModelChoiceField( + label=_('Side B site'), + queryset=Site.objects.all(), + required=False, + to_field_name='name', + help_text=_('Site of parent device B (if any)'), + ) side_b_device = CSVModelChoiceField( label=_('Side B device'), queryset=Device.objects.all(), @@ -1396,14 +1410,39 @@ class CableImportForm(NetBoxModelImportForm): required=False, help_text=_('Length unit') ) + color = forms.CharField( + label=_('Color'), + required=False, + max_length=16, + help_text=_('Color name (e.g. "Red") or hex code (e.g. "f44336")') + ) class Meta: model = Cable fields = [ - 'side_a_device', 'side_a_type', 'side_a_name', 'side_b_device', 'side_b_type', 'side_b_name', 'type', - 'status', 'tenant', 'label', 'color', 'length', 'length_unit', 'description', 'comments', 'tags', + 'side_a_site', 'side_a_device', 'side_a_type', 'side_a_name', 'side_b_site', 'side_b_device', 'side_b_type', + 'side_b_name', 'type', 'status', 'tenant', 'label', 'color', 'length', 'length_unit', 'description', + 'comments', 'tags', ] + def __init__(self, data=None, *args, **kwargs): + super().__init__(data, *args, **kwargs) + + if data: + # Limit choices for side_a_device to the assigned side_a_site + if side_a_site := data.get('side_a_site'): + side_a_device_params = {f'site__{self.fields["side_a_site"].to_field_name}': side_a_site} + self.fields['side_a_device'].queryset = self.fields['side_a_device'].queryset.filter( + **side_a_device_params + ) + + # Limit choices for side_b_device to the assigned side_b_site + if side_b_site := data.get('side_b_site'): + side_b_device_params = {f'site__{self.fields["side_b_site"].to_field_name}': side_b_site} + self.fields['side_b_device'].queryset = self.fields['side_b_device'].queryset.filter( + **side_b_device_params + ) + def _clean_side(self, side): """ Derive a Cable's A/B termination objects. @@ -1440,6 +1479,24 @@ class CableImportForm(NetBoxModelImportForm): setattr(self.instance, f'{side}_terminations', [termination_object]) return termination_object + def _clean_color(self, color): + """ + Derive a colors hex code + + :param color: color as hex or color name + """ + color_parsed = color.strip().lower() + + for hex_code, label in ColorChoices.CHOICES: + if color.lower() == label.lower(): + color_parsed = hex_code + + if len(color_parsed) > 6: + raise forms.ValidationError( + _(f"{color} did not match any used color name and was longer than six characters: invalid hex.") + ) + return color_parsed + def clean_side_a_name(self): return self._clean_side('a') @@ -1451,11 +1508,14 @@ class CableImportForm(NetBoxModelImportForm): length_unit = self.cleaned_data.get('length_unit', None) return length_unit if length_unit is not None else '' - + def clean_color(self): + color = self.cleaned_data.get('color', None) + return self._clean_color(color) if color is not None else '' # # Virtual chassis # + class VirtualChassisImportForm(NetBoxModelImportForm): master = CSVModelChoiceField( label=_('Master'), diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index d8cff372f..6454e1d14 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -11,6 +11,7 @@ from extras.models import ConfigTemplate from ipam.choices import VLANQinQRoleChoices from ipam.models import ASN, IPAddress, VLAN, VLANGroup, VLANTranslationPolicy, VRF from netbox.forms import NetBoxModelForm +from netbox.forms.mixins import ChangelogMessageMixin from tenancy.forms import TenancyForm from users.models import User from utilities.forms import add_blank_choice, get_field_value @@ -973,7 +974,7 @@ class VCMemberSelectForm(forms.Form): # Device component templates # -class ComponentTemplateForm(forms.ModelForm): +class ComponentTemplateForm(ChangelogMessageMixin, forms.ModelForm): device_type = DynamicModelChoiceField( label=_('Device type'), queryset=DeviceType.objects.all(), diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index bcf91c547..5c9599eeb 100644 --- a/netbox/dcim/forms/object_create.py +++ b/netbox/dcim/forms/object_create.py @@ -426,6 +426,11 @@ class VirtualChassisCreateForm(NetBoxModelForm): help_text=_('Position of the first member device. Increases by one for each additional member.') ) + fieldsets = ( + FieldSet('name', 'domain', 'description', 'tags', name=_('Virtual Chassis')), + FieldSet('region', 'site_group', 'site', 'rack', 'members', 'initial_position', name=_('Member Devices')), + ) + class Meta: model = VirtualChassis fields = [ diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index d0818a738..8b1755e35 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -33,6 +33,7 @@ if TYPE_CHECKING: from tenancy.graphql.types import TenantType from users.graphql.types import UserType from virtualization.graphql.types import ClusterType, VMInterfaceType, VirtualMachineType + from vpn.graphql.types import L2VPNTerminationType from wireless.graphql.types import WirelessLANType, WirelessLinkType __all__ = ( @@ -440,6 +441,7 @@ class InterfaceType(IPAddressesMixin, ModularComponentType, CabledObjectMixin, P primary_mac_address: Annotated["MACAddressType", strawberry.lazy('dcim.graphql.types')] | None qinq_svlan: Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None vlan_translation_policy: Annotated["VLANTranslationPolicyType", strawberry.lazy('ipam.graphql.types')] | None + l2vpn_termination: Annotated["L2VPNTerminationType", strawberry.lazy('vpn.graphql.types')] | None vdcs: List[Annotated["VirtualDeviceContextType", strawberry.lazy('dcim.graphql.types')]] tagged_vlans: List[Annotated["VLANType", strawberry.lazy('ipam.graphql.types')]] diff --git a/netbox/dcim/migrations/0206_load_module_type_profiles.py b/netbox/dcim/migrations/0206_load_module_type_profiles.py index 8f131570f..87661a8ac 100644 --- a/netbox/dcim/migrations/0206_load_module_type_profiles.py +++ b/netbox/dcim/migrations/0206_load_module_type_profiles.py @@ -19,7 +19,8 @@ def load_initial_data(apps, schema_editor): 'gpu', 'hard_disk', 'memory', - 'power_supply' + 'power_supply', + 'expansion_card' ) for name in initial_profiles: diff --git a/netbox/dcim/migrations/0208_devicerole_uniqueness.py b/netbox/dcim/migrations/0208_devicerole_uniqueness.py new file mode 100644 index 000000000..fbb9c7e64 --- /dev/null +++ b/netbox/dcim/migrations/0208_devicerole_uniqueness.py @@ -0,0 +1,44 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0207_remove_redundant_indexes'), + ('extras', '0129_fix_script_paths'), + ] + + operations = [ + migrations.AddConstraint( + model_name='devicerole', + constraint=models.UniqueConstraint( + fields=('parent', 'name'), + name='dcim_devicerole_parent_name' + ), + ), + migrations.AddConstraint( + model_name='devicerole', + constraint=models.UniqueConstraint( + condition=models.Q(('parent__isnull', True)), + fields=('name',), + name='dcim_devicerole_name', + violation_error_message='A top-level device role with this name already exists.' + ), + ), + migrations.AddConstraint( + model_name='devicerole', + constraint=models.UniqueConstraint( + fields=('parent', 'slug'), + name='dcim_devicerole_parent_slug' + ), + ), + migrations.AddConstraint( + model_name='devicerole', + constraint=models.UniqueConstraint( + condition=models.Q(('parent__isnull', True)), + fields=('slug',), + name='dcim_devicerole_slug', + violation_error_message='A top-level device role with this slug already exists.' + ), + ), + ] diff --git a/netbox/dcim/migrations/0208_platform_manufacturer_uniqueness.py b/netbox/dcim/migrations/0209_platform_manufacturer_uniqueness.py similarity index 97% rename from netbox/dcim/migrations/0208_platform_manufacturer_uniqueness.py rename to netbox/dcim/migrations/0209_platform_manufacturer_uniqueness.py index 9659aadf4..7114cf2a6 100644 --- a/netbox/dcim/migrations/0208_platform_manufacturer_uniqueness.py +++ b/netbox/dcim/migrations/0209_platform_manufacturer_uniqueness.py @@ -4,7 +4,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('dcim', '0207_remove_redundant_indexes'), + ('dcim', '0208_devicerole_uniqueness'), ('extras', '0129_fix_script_paths'), ] diff --git a/netbox/dcim/migrations/0209_interface_tx_power_negative.py b/netbox/dcim/migrations/0210_interface_tx_power_negative.py similarity index 90% rename from netbox/dcim/migrations/0209_interface_tx_power_negative.py rename to netbox/dcim/migrations/0210_interface_tx_power_negative.py index 70d692413..cf8c99f5e 100644 --- a/netbox/dcim/migrations/0209_interface_tx_power_negative.py +++ b/netbox/dcim/migrations/0210_interface_tx_power_negative.py @@ -5,7 +5,7 @@ from django.db import migrations, models class Migration(migrations.Migration): dependencies = [ - ('dcim', '0208_platform_manufacturer_uniqueness'), + ('dcim', '0209_platform_manufacturer_uniqueness'), ] operations = [ diff --git a/netbox/dcim/migrations/initial_data/module_type_profiles/expansion_card.json b/netbox/dcim/migrations/initial_data/module_type_profiles/expansion_card.json new file mode 100644 index 000000000..a1d2ffcab --- /dev/null +++ b/netbox/dcim/migrations/initial_data/module_type_profiles/expansion_card.json @@ -0,0 +1,15 @@ +{ + "name": "Expansion card", + "schema": { + "properties": { + "connector_type": { + "type": "string", + "description": "Connector type e.g. PCIe x4" + }, + "bandwidth": { + "type": "integer", + "description": "Total Bandwidth for this module" + } + } + } +} diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index 0a28d5acb..32e26f982 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -1,6 +1,7 @@ import itertools from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.db import models from django.dispatch import Signal @@ -479,13 +480,13 @@ class CablePath(models.Model): def origin_type(self): if self.path: ct_id, _ = decompile_path_node(self.path[0][0]) - return ObjectType.objects.get_for_id(ct_id) + return ContentType.objects.get_for_id(ct_id) @property def destination_type(self): if self.is_complete: ct_id, _ = decompile_path_node(self.path[-1][0]) - return ObjectType.objects.get_for_id(ct_id) + return ContentType.objects.get_for_id(ct_id) @property def _path_decompiled(self): diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index f85b4440d..78fd881a7 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -4,6 +4,7 @@ import yaml from functools import cached_property from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation +from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.core.files.storage import default_storage from django.core.validators import MaxValueValidator, MinValueValidator @@ -15,7 +16,6 @@ from django.urls import reverse from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ -from core.models import ObjectType from dcim.choices import * from dcim.constants import * from dcim.fields import MACAddressField @@ -398,6 +398,28 @@ class DeviceRole(NestedGroupModel): class Meta: ordering = ('name',) + constraints = ( + models.UniqueConstraint( + fields=('parent', 'name'), + name='%(app_label)s_%(class)s_parent_name' + ), + models.UniqueConstraint( + fields=('name',), + name='%(app_label)s_%(class)s_name', + condition=Q(parent__isnull=True), + violation_error_message=_("A top-level device role with this name already exists.") + ), + models.UniqueConstraint( + fields=('parent', 'slug'), + name='%(app_label)s_%(class)s_parent_slug' + ), + models.UniqueConstraint( + fields=('slug',), + name='%(app_label)s_%(class)s_slug', + condition=Q(parent__isnull=True), + violation_error_message=_("A top-level device role with this slug already exists.") + ), + ) verbose_name = _('device role') verbose_name_plural = _('device roles') @@ -1306,7 +1328,7 @@ class MACAddress(PrimaryModel): super().clean() if self._original_assigned_object_id and self._original_assigned_object_type_id: assigned_object = self.assigned_object - ct = ObjectType.objects.get_for_id(self._original_assigned_object_type_id) + ct = ContentType.objects.get_for_id(self._original_assigned_object_type_id) original_assigned_object = ct.get_object_for_this_type(pk=self._original_assigned_object_id) if ( diff --git a/netbox/dcim/svg/racks.py b/netbox/dcim/svg/racks.py index de695664a..7bea9d91d 100644 --- a/netbox/dcim/svg/racks.py +++ b/netbox/dcim/svg/racks.py @@ -3,6 +3,7 @@ import svgwrite from svgwrite.container import Hyperlink from svgwrite.image import Image from svgwrite.gradients import LinearGradient +from svgwrite.masking import ClipPath from svgwrite.shapes import Rect from svgwrite.text import Text @@ -67,6 +68,20 @@ def get_device_description(device): return description +def truncate_text(text, width, font_size=15): + """ + Truncate text to fit within the width of a rectangle. + + :param text: The text to truncate + :param width: Width of rectangle + :param font_size: Font size (default is 15, ~0.875rem) + """ + char_width = font_size * 0.6 # 0.6 is an approximation of the average character width in pixels + max_char = int(width / char_width) + + return text if len(text) <= max_char else text[:max_char] + '...' + + class RackElevationSVG: """ Use this class to render a rack elevation as an SVG image. @@ -177,12 +192,26 @@ class RackElevationSVG: link = Hyperlink(href=f'{self.base_url}{device.get_absolute_url()}', target="_parent") link.set_desc(description) + # Create clipPath element + # This is necessary as fallback because the truncate_text method is an approximation + clip_id = f"clip-{device.id}" + clip_path = ClipPath(id=clip_id) + clip_path.add(Rect(coords, size)) + + self.drawing.defs.add(clip_path) + + # Name to display + display_name = truncate_text(name, size[0]) + # Add rect element to hyperlink if color: link.add(Rect(coords, size, style=f'fill: #{color}', class_=f'slot{css_extra}')) else: link.add(Rect(coords, size, class_=f'slot blocked{css_extra}')) - link.add(Text(name, insert=text_coords, fill=text_color, class_=f'label{css_extra}')) + link.add( + Text(display_name, insert=text_coords, fill=text_color, clip_path=f"url(#{clip_id})", + class_=f'label{css_extra}') + ) # Embed device type image if provided if self.include_images and image: diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 11202d78e..f0465a1b5 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -63,6 +63,10 @@ class DeviceRoleTable(NetBoxTable): verbose_name=_('Name'), linkify=True ) + parent = tables.Column( + verbose_name=_('Parent'), + linkify=True, + ) device_count = columns.LinkedCountColumn( viewname='dcim:device_list', url_params={'role_id': 'pk'}, @@ -88,8 +92,8 @@ class DeviceRoleTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = models.DeviceRole fields = ( - 'pk', 'id', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'config_template', 'description', - 'slug', 'tags', 'actions', 'created', 'last_updated', + 'pk', 'id', 'name', 'parent', 'device_count', 'vm_count', 'color', 'vm_role', 'config_template', + 'description', 'slug', 'tags', 'actions', 'created', 'last_updated', ) default_columns = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description') diff --git a/netbox/dcim/tables/sites.py b/netbox/dcim/tables/sites.py index 0c9494d51..0f8fb9372 100644 --- a/netbox/dcim/tables/sites.py +++ b/netbox/dcim/tables/sites.py @@ -24,6 +24,10 @@ class RegionTable(ContactsColumnMixin, NetBoxTable): verbose_name=_('Name'), linkify=True ) + parent = tables.Column( + verbose_name=_('Parent'), + linkify=True, + ) site_count = columns.LinkedCountColumn( viewname='dcim:site_list', url_params={'region_id': 'pk'}, @@ -39,7 +43,7 @@ class RegionTable(ContactsColumnMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = Region fields = ( - 'pk', 'id', 'name', 'slug', 'site_count', 'description', 'comments', 'contacts', 'tags', + 'pk', 'id', 'name', 'parent', 'slug', 'site_count', 'description', 'comments', 'contacts', 'tags', 'created', 'last_updated', 'actions', ) default_columns = ('pk', 'name', 'site_count', 'description') @@ -54,6 +58,10 @@ class SiteGroupTable(ContactsColumnMixin, NetBoxTable): verbose_name=_('Name'), linkify=True ) + parent = tables.Column( + verbose_name=_('Parent'), + linkify=True, + ) site_count = columns.LinkedCountColumn( viewname='dcim:site_list', url_params={'group_id': 'pk'}, @@ -69,7 +77,7 @@ class SiteGroupTable(ContactsColumnMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = SiteGroup fields = ( - 'pk', 'id', 'name', 'slug', 'site_count', 'description', 'comments', 'contacts', 'tags', + 'pk', 'id', 'name', 'parent', 'slug', 'site_count', 'description', 'comments', 'contacts', 'tags', 'created', 'last_updated', 'actions', ) default_columns = ('pk', 'name', 'site_count', 'description') @@ -135,6 +143,10 @@ class LocationTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): verbose_name=_('Name'), linkify=True ) + parent = tables.Column( + verbose_name=_('Parent'), + linkify=True, + ) site = tables.Column( verbose_name=_('Site'), linkify=True @@ -170,8 +182,8 @@ class LocationTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = Location fields = ( - 'pk', 'id', 'name', 'site', 'status', 'facility', 'tenant', 'tenant_group', 'rack_count', 'device_count', - 'description', 'slug', 'comments', 'contacts', 'tags', 'actions', 'created', 'last_updated', + 'pk', 'id', 'name', 'parent', 'site', 'status', 'facility', 'tenant', 'tenant_group', 'rack_count', + 'device_count', 'description', 'slug', 'comments', 'contacts', 'tags', 'actions', 'created', 'last_updated', 'vlangroup_count', ) default_columns = ( diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 69192f0a1..5e41b37f7 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -3,7 +3,7 @@ from decimal import Decimal from zoneinfo import ZoneInfo import yaml -from django.test import override_settings +from django.test import override_settings, tag from django.urls import reverse from netaddr import EUI @@ -1000,18 +1000,7 @@ inventory-items: self.assertEqual(response.get('Content-Type'), 'text/csv; charset=utf-8') -# TODO: Change base class to PrimaryObjectViewTestCase -# Blocked by absence of bulk import view for ModuleTypes -class ModuleTypeTestCase( - ViewTestCases.GetObjectViewTestCase, - ViewTestCases.GetObjectChangelogViewTestCase, - ViewTestCases.CreateObjectViewTestCase, - ViewTestCases.EditObjectViewTestCase, - ViewTestCases.DeleteObjectViewTestCase, - ViewTestCases.ListObjectsViewTestCase, - ViewTestCases.BulkEditObjectsViewTestCase, - ViewTestCases.BulkDeleteObjectsViewTestCase -): +class ModuleTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = ModuleType @classmethod @@ -1023,7 +1012,7 @@ class ModuleTypeTestCase( ) Manufacturer.objects.bulk_create(manufacturers) - ModuleType.objects.bulk_create([ + module_types = ModuleType.objects.bulk_create([ ModuleType(model='Module Type 1', manufacturer=manufacturers[0]), ModuleType(model='Module Type 2', manufacturer=manufacturers[0]), ModuleType(model='Module Type 3', manufacturer=manufacturers[0]), @@ -1031,6 +1020,8 @@ class ModuleTypeTestCase( tags = create_tags('Alpha', 'Bravo', 'Charlie') + fan_module_type_profile = ModuleTypeProfile.objects.get(name='Fan') + cls.form_data = { 'manufacturer': manufacturers[1].pk, 'model': 'Device Type X', @@ -1044,6 +1035,70 @@ class ModuleTypeTestCase( 'part_number': '456DEF', } + cls.csv_data = ( + "manufacturer,model,part_number,comments,profile", + f"Manufacturer 1,fan0,generic-fan,,{fan_module_type_profile.name}" + ) + + cls.csv_update_data = ( + "id,model", + f"{module_types[0].id},test model", + ) + + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_bulk_update_objects_with_permission(self): + self.add_permissions( + 'dcim.add_consoleporttemplate', + 'dcim.add_consoleserverporttemplate', + 'dcim.add_powerporttemplate', + 'dcim.add_poweroutlettemplate', + 'dcim.add_interfacetemplate', + 'dcim.add_frontporttemplate', + 'dcim.add_rearporttemplate', + 'dcim.add_modulebaytemplate', + ) + + # run base test + super().test_bulk_update_objects_with_permission() + + @tag('regression') + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[]) + def test_bulk_import_objects_with_permission(self): + self.add_permissions( + 'dcim.add_consoleporttemplate', + 'dcim.add_consoleserverporttemplate', + 'dcim.add_powerporttemplate', + 'dcim.add_poweroutlettemplate', + 'dcim.add_interfacetemplate', + 'dcim.add_frontporttemplate', + 'dcim.add_rearporttemplate', + 'dcim.add_modulebaytemplate', + ) + + # run base test + super().test_bulk_import_objects_with_permission() + + # TODO: remove extra regression asserts once parent test supports testing all import fields + fan_module_type = ModuleType.objects.get(part_number='generic-fan') + fan_module_type_profile = ModuleTypeProfile.objects.get(name='Fan') + + assert fan_module_type.profile == fan_module_type_profile + + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[]) + def test_bulk_import_objects_with_constrained_permission(self): + self.add_permissions( + 'dcim.add_consoleporttemplate', + 'dcim.add_consoleserverporttemplate', + 'dcim.add_powerporttemplate', + 'dcim.add_poweroutlettemplate', + 'dcim.add_interfacetemplate', + 'dcim.add_frontporttemplate', + 'dcim.add_rearporttemplate', + 'dcim.add_modulebaytemplate', + ) + + super().test_bulk_import_objects_with_constrained_permission() + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_moduletype_consoleports(self): moduletype = ModuleType.objects.first() @@ -1804,9 +1859,9 @@ class DeviceRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): cls.csv_data = ( "name,slug,color", - "Device Role 4,device-role-4,ff0000", - "Device Role 5,device-role-5,00ff00", - "Device Role 6,device-role-6,0000ff", + "Device Role 6,device-role-6,ff0000", + "Device Role 7,device-role-7,00ff00", + "Device Role 8,device-role-8,0000ff", ) cls.csv_update_data = ( @@ -3211,17 +3266,27 @@ class CableTestCase( @classmethod def setUpTestData(cls): - site = Site.objects.create(name='Site 1', slug='site-1') + sites = ( + Site(name='Site 1', slug='site-1'), + Site(name='Site 2', slug='site-2'), + ) + Site.objects.bulk_create(sites) manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') devicetype = DeviceType.objects.create(model='Device Type 1', manufacturer=manufacturer) role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') vc = VirtualChassis.objects.create(name='Virtual Chassis') + # NOTE: By design, NetBox now allows for the creation of devices with the same name if they belong to + # different sites. + # The CSV test below demonstrates that devices with identical names on different sites can be created + # and referenced successfully. devices = ( - Device(name='Device 1', site=site, device_type=devicetype, role=role), - Device(name='Device 2', site=site, device_type=devicetype, role=role), - Device(name='Device 3', site=site, device_type=devicetype, role=role), - Device(name='Device 4', site=site, device_type=devicetype, role=role), + # Create 'Device 1' assigned to 'Site 1' + Device(name='Device 1', site=sites[0], device_type=devicetype, role=role), + Device(name='Device 2', site=sites[0], device_type=devicetype, role=role), + Device(name='Device 3', site=sites[0], device_type=devicetype, role=role), + # Create 'Device 1' assigned to 'Site 2' (allowed since the site is different) + Device(name='Device 1', site=sites[1], device_type=devicetype, role=role), ) Device.objects.bulk_create(devices) @@ -3272,13 +3337,15 @@ class CableTestCase( 'tags': [t.pk for t in tags], } + # Ensure that CSV bulk import supports assigning terminations from parent devices that share + # the same device name, provided those devices belong to different sites. cls.csv_data = ( - "side_a_device,side_a_type,side_a_name,side_b_device,side_b_type,side_b_name", - "Device 3,dcim.interface,Interface 1,Device 4,dcim.interface,Interface 1", - "Device 3,dcim.interface,Interface 2,Device 4,dcim.interface,Interface 2", - "Device 3,dcim.interface,Interface 3,Device 4,dcim.interface,Interface 3", - "Device 1,dcim.interface,Device 2 Interface,Device 4,dcim.interface,Interface 4", - "Device 1,dcim.interface,Device 3 Interface,Device 4,dcim.interface,Interface 5", + "side_a_site,side_a_device,side_a_type,side_a_name,side_b_site,side_b_device,side_b_type,side_b_name", + "Site 1,Device 3,dcim.interface,Interface 1,Site 2,Device 1,dcim.interface,Interface 1", + "Site 1,Device 3,dcim.interface,Interface 2,Site 2,Device 1,dcim.interface,Interface 2", + "Site 1,Device 3,dcim.interface,Interface 3,Site 2,Device 1,dcim.interface,Interface 3", + "Site 1,Device 1,dcim.interface,Device 2 Interface,Site 2,Device 1,dcim.interface,Interface 4", + "Site 1,Device 1,dcim.interface,Device 3 Interface,Site 2,Device 1,dcim.interface,Interface 5", ) cls.csv_update_data = ( diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 94afc2cb2..277a634ce 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -3702,7 +3702,6 @@ class VirtualChassisView(generic.ObjectView): class VirtualChassisCreateView(generic.ObjectEditView): queryset = VirtualChassis.objects.all() form = forms.VirtualChassisCreateForm - template_name = 'dcim/virtualchassis_add.html' @register_model_view(VirtualChassis, 'edit') @@ -3750,6 +3749,7 @@ class VirtualChassisEditView(ObjectPermissionRequiredMixin, GetReturnURLMixin, V formset = VCMemberFormSet(request.POST, queryset=members_queryset) if vc_form.is_valid() and formset.is_valid(): + virtual_chassis._changelog_message = vc_form.cleaned_data.pop('changelog_message', '') with transaction.atomic(using=router.db_for_write(Device)): diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index 07540c50d..eb8d050cd 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -1,4 +1,3 @@ -from .serializers_.objecttypes import * from .serializers_.attachments import * from .serializers_.bookmarks import * from .serializers_.customfields import * diff --git a/netbox/extras/api/serializers_/attachments.py b/netbox/extras/api/serializers_/attachments.py index fe0964eae..6507a12be 100644 --- a/netbox/extras/api/serializers_/attachments.py +++ b/netbox/extras/api/serializers_/attachments.py @@ -24,10 +24,10 @@ class ImageAttachmentSerializer(ValidatedModelSerializer): class Meta: model = ImageAttachment fields = [ - 'id', 'url', 'display', 'object_type', 'object_id', 'parent', 'name', 'image', + 'id', 'url', 'display', 'object_type', 'object_id', 'parent', 'name', 'image', 'description', 'image_height', 'image_width', 'created', 'last_updated', ] - brief_fields = ('id', 'url', 'display', 'name', 'image') + brief_fields = ('id', 'url', 'display', 'name', 'image', 'description') def validate(self, data): diff --git a/netbox/extras/api/serializers_/configcontexts.py b/netbox/extras/api/serializers_/configcontexts.py index 42a11ffcd..4a3f25e2e 100644 --- a/netbox/extras/api/serializers_/configcontexts.py +++ b/netbox/extras/api/serializers_/configcontexts.py @@ -8,7 +8,7 @@ from dcim.api.serializers_.sites import LocationSerializer, RegionSerializer, Si from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup from extras.models import ConfigContext, Tag from netbox.api.fields import SerializedPKRelatedField -from netbox.api.serializers import ValidatedModelSerializer +from netbox.api.serializers import ChangeLogMessageSerializer, ValidatedModelSerializer from tenancy.api.serializers_.tenants import TenantSerializer, TenantGroupSerializer from tenancy.models import Tenant, TenantGroup from virtualization.api.serializers_.clusters import ClusterSerializer, ClusterGroupSerializer, ClusterTypeSerializer @@ -19,7 +19,7 @@ __all__ = ( ) -class ConfigContextSerializer(ValidatedModelSerializer): +class ConfigContextSerializer(ChangeLogMessageSerializer, ValidatedModelSerializer): regions = SerializedPKRelatedField( queryset=Region.objects.all(), serializer=RegionSerializer, diff --git a/netbox/extras/api/serializers_/configtemplates.py b/netbox/extras/api/serializers_/configtemplates.py index 69652907e..244308535 100644 --- a/netbox/extras/api/serializers_/configtemplates.py +++ b/netbox/extras/api/serializers_/configtemplates.py @@ -1,6 +1,6 @@ from core.api.serializers_.data import DataFileSerializer, DataSourceSerializer from extras.models import ConfigTemplate -from netbox.api.serializers import ValidatedModelSerializer +from netbox.api.serializers import ChangeLogMessageSerializer, ValidatedModelSerializer from netbox.api.serializers.features import TaggableModelSerializer __all__ = ( @@ -8,7 +8,7 @@ __all__ = ( ) -class ConfigTemplateSerializer(TaggableModelSerializer, ValidatedModelSerializer): +class ConfigTemplateSerializer(ChangeLogMessageSerializer, TaggableModelSerializer, ValidatedModelSerializer): data_source = DataSourceSerializer( nested=True, required=False diff --git a/netbox/extras/api/serializers_/customfields.py b/netbox/extras/api/serializers_/customfields.py index a65fafc4e..f50f7a829 100644 --- a/netbox/extras/api/serializers_/customfields.py +++ b/netbox/extras/api/serializers_/customfields.py @@ -7,7 +7,7 @@ from core.models import ObjectType from extras.choices import * from extras.models import CustomField, CustomFieldChoiceSet from netbox.api.fields import ChoiceField, ContentTypeField -from netbox.api.serializers import ValidatedModelSerializer +from netbox.api.serializers import ChangeLogMessageSerializer, ValidatedModelSerializer __all__ = ( 'CustomFieldChoiceSetSerializer', @@ -15,7 +15,7 @@ __all__ = ( ) -class CustomFieldChoiceSetSerializer(ValidatedModelSerializer): +class CustomFieldChoiceSetSerializer(ChangeLogMessageSerializer, ValidatedModelSerializer): base_choices = ChoiceField( choices=CustomFieldChoiceSetBaseChoices, required=False @@ -36,7 +36,7 @@ class CustomFieldChoiceSetSerializer(ValidatedModelSerializer): brief_fields = ('id', 'url', 'display', 'name', 'description', 'choices_count') -class CustomFieldSerializer(ValidatedModelSerializer): +class CustomFieldSerializer(ChangeLogMessageSerializer, ValidatedModelSerializer): object_types = ContentTypeField( queryset=ObjectType.objects.with_feature('custom_fields'), many=True diff --git a/netbox/extras/api/serializers_/customlinks.py b/netbox/extras/api/serializers_/customlinks.py index 8cc4f5f77..951c3aded 100644 --- a/netbox/extras/api/serializers_/customlinks.py +++ b/netbox/extras/api/serializers_/customlinks.py @@ -1,14 +1,14 @@ from core.models import ObjectType from extras.models import CustomLink from netbox.api.fields import ContentTypeField -from netbox.api.serializers import ValidatedModelSerializer +from netbox.api.serializers import ChangeLogMessageSerializer, ValidatedModelSerializer __all__ = ( 'CustomLinkSerializer', ) -class CustomLinkSerializer(ValidatedModelSerializer): +class CustomLinkSerializer(ChangeLogMessageSerializer, ValidatedModelSerializer): object_types = ContentTypeField( queryset=ObjectType.objects.with_feature('custom_links'), many=True diff --git a/netbox/extras/api/serializers_/exporttemplates.py b/netbox/extras/api/serializers_/exporttemplates.py index 0d19d642c..0d3eed442 100644 --- a/netbox/extras/api/serializers_/exporttemplates.py +++ b/netbox/extras/api/serializers_/exporttemplates.py @@ -2,14 +2,14 @@ from core.api.serializers_.data import DataFileSerializer, DataSourceSerializer from core.models import ObjectType from extras.models import ExportTemplate from netbox.api.fields import ContentTypeField -from netbox.api.serializers import ValidatedModelSerializer +from netbox.api.serializers import ChangeLogMessageSerializer, ValidatedModelSerializer __all__ = ( 'ExportTemplateSerializer', ) -class ExportTemplateSerializer(ValidatedModelSerializer): +class ExportTemplateSerializer(ChangeLogMessageSerializer, ValidatedModelSerializer): object_types = ContentTypeField( queryset=ObjectType.objects.with_feature('export_templates'), many=True diff --git a/netbox/extras/api/serializers_/notifications.py b/netbox/extras/api/serializers_/notifications.py index 62e1a8d63..9f0c7cff3 100644 --- a/netbox/extras/api/serializers_/notifications.py +++ b/netbox/extras/api/serializers_/notifications.py @@ -4,7 +4,7 @@ from rest_framework import serializers from core.models import ObjectType from extras.models import Notification, NotificationGroup, Subscription from netbox.api.fields import ContentTypeField, SerializedPKRelatedField -from netbox.api.serializers import ValidatedModelSerializer +from netbox.api.serializers import ChangeLogMessageSerializer, ValidatedModelSerializer from users.api.serializers_.users import GroupSerializer, UserSerializer from users.models import Group, User from utilities.api import get_serializer_for_model @@ -37,7 +37,7 @@ class NotificationSerializer(ValidatedModelSerializer): return serializer(instance.object, nested=True, context=context).data -class NotificationGroupSerializer(ValidatedModelSerializer): +class NotificationGroupSerializer(ChangeLogMessageSerializer, ValidatedModelSerializer): groups = SerializedPKRelatedField( queryset=Group.objects.all(), serializer=GroupSerializer, diff --git a/netbox/extras/api/serializers_/savedfilters.py b/netbox/extras/api/serializers_/savedfilters.py index fb0744e59..e7128389c 100644 --- a/netbox/extras/api/serializers_/savedfilters.py +++ b/netbox/extras/api/serializers_/savedfilters.py @@ -1,14 +1,14 @@ from core.models import ObjectType from extras.models import SavedFilter from netbox.api.fields import ContentTypeField -from netbox.api.serializers import ValidatedModelSerializer +from netbox.api.serializers import ChangeLogMessageSerializer, ValidatedModelSerializer __all__ = ( 'SavedFilterSerializer', ) -class SavedFilterSerializer(ValidatedModelSerializer): +class SavedFilterSerializer(ChangeLogMessageSerializer, ValidatedModelSerializer): object_types = ContentTypeField( queryset=ObjectType.objects.all(), many=True diff --git a/netbox/extras/api/serializers_/tags.py b/netbox/extras/api/serializers_/tags.py index 5dc39584f..7567a4543 100644 --- a/netbox/extras/api/serializers_/tags.py +++ b/netbox/extras/api/serializers_/tags.py @@ -5,7 +5,7 @@ from core.models import ObjectType from extras.models import Tag, TaggedItem from netbox.api.exceptions import SerializerNotFound from netbox.api.fields import ContentTypeField, RelatedObjectCountField -from netbox.api.serializers import BaseModelSerializer, ValidatedModelSerializer +from netbox.api.serializers import BaseModelSerializer, ChangeLogMessageSerializer, ValidatedModelSerializer from utilities.api import get_serializer_for_model __all__ = ( @@ -14,7 +14,7 @@ __all__ = ( ) -class TagSerializer(ValidatedModelSerializer): +class TagSerializer(ChangeLogMessageSerializer, ValidatedModelSerializer): object_types = ContentTypeField( queryset=ObjectType.objects.with_feature('tags'), many=True, diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py index 101808753..bd4a60f6e 100644 --- a/netbox/extras/api/urls.py +++ b/netbox/extras/api/urls.py @@ -1,5 +1,6 @@ from django.urls import include, path +from core.api.views import ObjectTypeViewSet from netbox.api.routers import NetBoxRouter from . import views @@ -26,7 +27,9 @@ router.register('journal-entries', views.JournalEntryViewSet) router.register('config-contexts', views.ConfigContextViewSet) router.register('config-templates', views.ConfigTemplateViewSet) router.register('scripts', views.ScriptViewSet, basename='script') -router.register('object-types', views.ObjectTypeViewSet) + +# TODO: Remove in NetBox v4.5 +router.register('object-types', ObjectTypeViewSet) app_name = 'extras-api' urlpatterns = [ diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 3f5bb172a..289bf016a 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -10,10 +10,9 @@ from rest_framework.mixins import ListModelMixin, RetrieveModelMixin from rest_framework.renderers import JSONRenderer from rest_framework.response import Response from rest_framework.routers import APIRootView -from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet +from rest_framework.viewsets import ModelViewSet from rq import Worker -from core.models import ObjectType from extras import filtersets from extras.jobs import ScriptJob from extras.models import * @@ -185,7 +184,9 @@ class TagViewSet(NetBoxModelViewSet): class TaggedItemViewSet(RetrieveModelMixin, ListModelMixin, BaseViewSet): - queryset = TaggedItem.objects.prefetch_related('content_type', 'content_object', 'tag') + queryset = TaggedItem.objects.prefetch_related( + 'content_type', 'content_object', 'tag' + ).order_by('tag__weight', 'tag__name') serializer_class = serializers.TaggedItemSerializer filterset_class = filtersets.TaggedItemFilterSet @@ -314,20 +315,6 @@ class ScriptViewSet(ModelViewSet): return Response(input_serializer.errors, status=status.HTTP_400_BAD_REQUEST) -# -# Object types -# - -class ObjectTypeViewSet(ReadOnlyModelViewSet): - """ - Read-only list of ObjectTypes. - """ - permission_classes = [IsAuthenticatedOrLoginNotRequired] - queryset = ObjectType.objects.order_by('app_label', 'model') - serializer_class = serializers.ObjectTypeSerializer - filterset_class = filtersets.ObjectTypeFilterSet - - # # User dashboard # diff --git a/netbox/extras/conditions.py b/netbox/extras/conditions.py index 5680be444..e1128c5dc 100644 --- a/netbox/extras/conditions.py +++ b/netbox/extras/conditions.py @@ -1,13 +1,14 @@ import functools +import operator import re from django.utils.translation import gettext as _ __all__ = ( 'Condition', 'ConditionSet', + 'InvalidCondition', ) - AND = 'and' OR = 'or' @@ -19,6 +20,10 @@ def is_ruleset(data): return type(data) is dict and len(data) == 1 and list(data.keys())[0] in (AND, OR) +class InvalidCondition(Exception): + pass + + class Condition: """ An individual conditional rule that evaluates a single attribute and its value. @@ -61,6 +66,7 @@ class Condition: self.attr = attr self.value = value + self.op = op self.eval_func = getattr(self, f'eval_{op}') self.negate = negate @@ -70,16 +76,17 @@ class Condition: """ def _get(obj, key): if isinstance(obj, list): - return [dict.get(i, key) for i in obj] - - return dict.get(obj, key) + return [operator.getitem(item or {}, key) for item in obj] + return operator.getitem(obj or {}, key) try: value = functools.reduce(_get, self.attr.split('.'), data) - except TypeError: - # Invalid key path - value = None - result = self.eval_func(value) + except KeyError: + raise InvalidCondition(f"Invalid key path: {self.attr}") + try: + result = self.eval_func(value) + except TypeError as e: + raise InvalidCondition(f"Invalid data type at '{self.attr}' for '{self.op}' evaluation: {e}") if self.negate: return not result diff --git a/netbox/extras/constants.py b/netbox/extras/constants.py index 94f0b25ad..cadf20cfe 100644 --- a/netbox/extras/constants.py +++ b/netbox/extras/constants.py @@ -21,6 +21,12 @@ WEBHOOK_EVENT_TYPES = { JOB_ERRORED: 'job_ended', } +# Jinja environment parameters which support path imports +JINJA_ENV_PARAMS_WITH_PATH_IMPORT = ( + 'undefined', + 'finalize', +) + # Dashboard DEFAULT_DASHBOARD = [ { diff --git a/netbox/extras/events.py b/netbox/extras/events.py index 95170e18d..9dac4ce45 100644 --- a/netbox/extras/events.py +++ b/netbox/extras/events.py @@ -2,18 +2,19 @@ import logging from collections import defaultdict from django.conf import settings -from django.contrib.contenttypes.models import ContentType from django.utils import timezone from django.utils.module_loading import import_string from django.utils.translation import gettext as _ from django_rq import get_queue from core.events import * +from core.models import ObjectType from netbox.config import get_config from netbox.constants import RQ_QUEUE_DEFAULT -from netbox.registry import registry +from netbox.models.features import has_feature from users.models import User from utilities.api import get_serializer_for_model +from utilities.request import copy_safe_request from utilities.rqworker import get_rq_retry from utilities.serialization import serialize_object from .choices import EventRuleActionChoices @@ -50,16 +51,17 @@ def get_snapshots(instance, event_type): return snapshots -def enqueue_event(queue, instance, user, request_id, event_type): +def enqueue_event(queue, instance, request, event_type): """ Enqueue a serialized representation of a created/updated/deleted object for the processing of events once the request has completed. """ - # Determine whether this type of object supports event rules + # Bail if this type of object does not support event rules + if not has_feature(instance, 'event_rules'): + return + app_label = instance._meta.app_label model_name = instance._meta.model_name - if model_name not in registry['model_features']['event_rules'].get(app_label, []): - return assert instance.pk is not None key = f'{app_label}.{model_name}:{instance.pk}' @@ -71,17 +73,19 @@ def enqueue_event(queue, instance, user, request_id, event_type): queue[key]['event_type'] = event_type else: queue[key] = { - 'object_type': ContentType.objects.get_for_model(instance), + 'object_type': ObjectType.objects.get_for_model(instance), 'object_id': instance.pk, 'event_type': event_type, 'data': serialize_for_event(instance), 'snapshots': get_snapshots(instance, event_type), - 'username': user.username, - 'request_id': request_id + 'request': request, + # Legacy request attributes for backward compatibility + 'username': request.user.username, + 'request_id': request.id, } -def process_event_rules(event_rules, object_type, event_type, data, username=None, snapshots=None, request_id=None): +def process_event_rules(event_rules, object_type, event_type, data, username=None, snapshots=None, request=None): user = User.objects.get(username=username) if username else None for event_rule in event_rules: @@ -104,7 +108,7 @@ def process_event_rules(event_rules, object_type, event_type, data, username=Non # Compile the task parameters params = { "event_rule": event_rule, - "model_name": object_type.model, + "object_type": object_type, "event_type": event_type, "data": event_data, "snapshots": snapshots, @@ -114,8 +118,8 @@ def process_event_rules(event_rules, object_type, event_type, data, username=Non } if snapshots: params["snapshots"] = snapshots - if request_id: - params["request_id"] = request_id + if request: + params["request"] = copy_safe_request(request) # Enqueue the task rq_queue.enqueue( @@ -179,7 +183,7 @@ def process_event_queue(events): data=event['data'], username=event['username'], snapshots=event['snapshots'], - request_id=event['request_id'] + request=event['request'], ) @@ -192,5 +196,5 @@ def flush_events(events): try: func = import_string(name) func(events) - except Exception as e: + except ImportError as e: logger.error(_("Cannot import events pipeline {name} error: {error}").format(name=name, error=e)) diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index 6adad110d..f711ddc01 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -29,7 +29,6 @@ __all__ = ( 'JournalEntryFilterSet', 'LocalConfigContextFilterSet', 'NotificationGroupFilterSet', - 'ObjectTypeFilterSet', 'SavedFilterFilterSet', 'ScriptFilterSet', 'TableConfigFilterSet', @@ -452,12 +451,16 @@ class ImageAttachmentFilterSet(ChangeLoggedModelFilterSet): class Meta: model = ImageAttachment - fields = ('id', 'object_type_id', 'object_id', 'name', 'image_width', 'image_height') + fields = ('id', 'object_type_id', 'object_id', 'name', 'description', 'image_width', 'image_height') def search(self, queryset, name, value): if not value.strip(): return queryset - return queryset.filter(name__icontains=value) + return queryset.filter( + Q(name__icontains=value) | + Q(image__icontains=value) | + Q(description__icontains=value) + ) class JournalEntryFilterSet(NetBoxModelFilterSet): @@ -788,26 +791,3 @@ class LocalConfigContextFilterSet(django_filters.FilterSet): def _local_context_data(self, queryset, name, value): return queryset.exclude(local_context_data__isnull=value) - - -# -# ContentTypes -# - -class ObjectTypeFilterSet(django_filters.FilterSet): - q = django_filters.CharFilter( - method='search', - label=_('Search'), - ) - - class Meta: - model = ObjectType - fields = ('id', 'app_label', 'model') - - def search(self, queryset, name, value): - if not value.strip(): - return queryset - return queryset.filter( - Q(app_label__icontains=value) | - Q(model__icontains=value) - ) diff --git a/netbox/extras/forms/bulk_edit.py b/netbox/extras/forms/bulk_edit.py index c854a6c81..258910a8c 100644 --- a/netbox/extras/forms/bulk_edit.py +++ b/netbox/extras/forms/bulk_edit.py @@ -5,6 +5,7 @@ from extras.choices import * from extras.models import * from netbox.events import get_event_type_choices from netbox.forms import NetBoxModelBulkEditForm +from netbox.forms.mixins import ChangelogMessageMixin from utilities.forms import BulkEditForm, add_blank_choice from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField from utilities.forms.rendering import FieldSet @@ -27,7 +28,7 @@ __all__ = ( ) -class CustomFieldBulkEditForm(BulkEditForm): +class CustomFieldBulkEditForm(ChangelogMessageMixin, BulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=CustomField.objects.all(), widget=forms.MultipleHiddenInput @@ -95,7 +96,7 @@ class CustomFieldBulkEditForm(BulkEditForm): nullable_fields = ('group_name', 'description', 'choice_set') -class CustomFieldChoiceSetBulkEditForm(BulkEditForm): +class CustomFieldChoiceSetBulkEditForm(ChangelogMessageMixin, BulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=CustomFieldChoiceSet.objects.all(), widget=forms.MultipleHiddenInput @@ -115,7 +116,7 @@ class CustomFieldChoiceSetBulkEditForm(BulkEditForm): nullable_fields = ('base_choices', 'description') -class CustomLinkBulkEditForm(BulkEditForm): +class CustomLinkBulkEditForm(ChangelogMessageMixin, BulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=CustomLink.objects.all(), widget=forms.MultipleHiddenInput @@ -141,7 +142,7 @@ class CustomLinkBulkEditForm(BulkEditForm): ) -class ExportTemplateBulkEditForm(BulkEditForm): +class ExportTemplateBulkEditForm(ChangelogMessageMixin, BulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=ExportTemplate.objects.all(), widget=forms.MultipleHiddenInput @@ -174,7 +175,7 @@ class ExportTemplateBulkEditForm(BulkEditForm): nullable_fields = ('description', 'mime_type', 'file_name', 'file_extension') -class SavedFilterBulkEditForm(BulkEditForm): +class SavedFilterBulkEditForm(ChangelogMessageMixin, BulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=SavedFilter.objects.all(), widget=forms.MultipleHiddenInput @@ -294,7 +295,7 @@ class EventRuleBulkEditForm(NetBoxModelBulkEditForm): nullable_fields = ('description', 'conditions') -class TagBulkEditForm(BulkEditForm): +class TagBulkEditForm(ChangelogMessageMixin, BulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=Tag.objects.all(), widget=forms.MultipleHiddenInput @@ -316,7 +317,7 @@ class TagBulkEditForm(BulkEditForm): nullable_fields = ('description',) -class ConfigContextBulkEditForm(BulkEditForm): +class ConfigContextBulkEditForm(ChangelogMessageMixin, BulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=ConfigContext.objects.all(), widget=forms.MultipleHiddenInput @@ -340,7 +341,7 @@ class ConfigContextBulkEditForm(BulkEditForm): nullable_fields = ('description',) -class ConfigTemplateBulkEditForm(BulkEditForm): +class ConfigTemplateBulkEditForm(ChangelogMessageMixin, BulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=ConfigTemplate.objects.all(), widget=forms.MultipleHiddenInput @@ -373,7 +374,7 @@ class ConfigTemplateBulkEditForm(BulkEditForm): nullable_fields = ('description', 'mime_type', 'file_name', 'file_extension') -class JournalEntryBulkEditForm(BulkEditForm): +class JournalEntryBulkEditForm(ChangelogMessageMixin, BulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=JournalEntry.objects.all(), widget=forms.MultipleHiddenInput @@ -386,7 +387,7 @@ class JournalEntryBulkEditForm(BulkEditForm): comments = CommentField() -class NotificationGroupBulkEditForm(BulkEditForm): +class NotificationGroupBulkEditForm(ChangelogMessageMixin, BulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=NotificationGroup.objects.all(), widget=forms.MultipleHiddenInput diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py index 5590dfa1a..13499fc2e 100644 --- a/netbox/extras/forms/model_forms.py +++ b/netbox/extras/forms/model_forms.py @@ -13,6 +13,7 @@ from extras.choices import * from extras.models import * from netbox.events import get_event_type_choices from netbox.forms import NetBoxModelForm +from netbox.forms.mixins import ChangelogMessageMixin from tenancy.models import Tenant, TenantGroup from users.models import Group, User from utilities.forms import get_field_value @@ -45,7 +46,7 @@ __all__ = ( ) -class CustomFieldForm(forms.ModelForm): +class CustomFieldForm(ChangelogMessageMixin, forms.ModelForm): object_types = ContentTypeMultipleChoiceField( label=_('Object types'), queryset=ObjectType.objects.with_feature('custom_fields'), @@ -164,7 +165,7 @@ class CustomFieldForm(forms.ModelForm): del self.fields['choice_set'] -class CustomFieldChoiceSetForm(forms.ModelForm): +class CustomFieldChoiceSetForm(ChangelogMessageMixin, forms.ModelForm): # TODO: The extra_choices field definition diverge from the CustomFieldChoiceSet model extra_choices = forms.CharField( widget=ChoicesWidget(), @@ -217,7 +218,7 @@ class CustomFieldChoiceSetForm(forms.ModelForm): return data -class CustomLinkForm(forms.ModelForm): +class CustomLinkForm(ChangelogMessageMixin, forms.ModelForm): object_types = ContentTypeMultipleChoiceField( label=_('Object types'), queryset=ObjectType.objects.with_feature('custom_links') @@ -249,7 +250,7 @@ class CustomLinkForm(forms.ModelForm): } -class ExportTemplateForm(SyncedDataMixin, forms.ModelForm): +class ExportTemplateForm(ChangelogMessageMixin, SyncedDataMixin, forms.ModelForm): object_types = ContentTypeMultipleChoiceField( label=_('Object types'), queryset=ObjectType.objects.with_feature('export_templates') @@ -291,7 +292,7 @@ class ExportTemplateForm(SyncedDataMixin, forms.ModelForm): return self.cleaned_data -class SavedFilterForm(forms.ModelForm): +class SavedFilterForm(ChangelogMessageMixin, forms.ModelForm): slug = SlugField() object_types = ContentTypeMultipleChoiceField( label=_('Object types'), @@ -388,7 +389,7 @@ class BookmarkForm(forms.ModelForm): fields = ('object_type', 'object_id') -class NotificationGroupForm(forms.ModelForm): +class NotificationGroupForm(ChangelogMessageMixin, forms.ModelForm): groups = DynamicModelMultipleChoiceField( label=_('Groups'), required=False, @@ -561,7 +562,7 @@ class EventRuleForm(NetBoxModelForm): return self.cleaned_data -class TagForm(forms.ModelForm): +class TagForm(ChangelogMessageMixin, forms.ModelForm): slug = SlugField() object_types = ContentTypeMultipleChoiceField( label=_('Object types'), @@ -584,7 +585,7 @@ class TagForm(forms.ModelForm): ] -class ConfigContextForm(SyncedDataMixin, forms.ModelForm): +class ConfigContextForm(ChangelogMessageMixin, SyncedDataMixin, forms.ModelForm): regions = DynamicModelMultipleChoiceField( label=_('Regions'), queryset=Region.objects.all(), @@ -696,7 +697,7 @@ class ConfigContextForm(SyncedDataMixin, forms.ModelForm): return self.cleaned_data -class ConfigTemplateForm(SyncedDataMixin, forms.ModelForm): +class ConfigTemplateForm(ChangelogMessageMixin, SyncedDataMixin, forms.ModelForm): tags = DynamicModelMultipleChoiceField( label=_('Tags'), queryset=Tag.objects.all(), @@ -744,14 +745,17 @@ class ConfigTemplateForm(SyncedDataMixin, forms.ModelForm): class ImageAttachmentForm(forms.ModelForm): fieldsets = ( - FieldSet(ObjectAttribute('parent'), 'name', 'image'), + FieldSet(ObjectAttribute('parent'), 'image', 'name', 'description'), ) class Meta: model = ImageAttachment fields = [ - 'name', 'image', + 'image', 'name', 'description', ] + help_texts = { + 'name': _("If no name is specified, the file name will be used.") + } class JournalEntryForm(NetBoxModelForm): diff --git a/netbox/extras/jobs.py b/netbox/extras/jobs.py index 733654198..c4a1b3b26 100644 --- a/netbox/extras/jobs.py +++ b/netbox/extras/jobs.py @@ -90,7 +90,10 @@ class ScriptJob(JobRunner): request: The WSGI request associated with this execution (if any) commit: Passed through to Script.run() """ - script = ScriptModel.objects.get(pk=self.job.object_id).python_class() + script_model = ScriptModel.objects.get(pk=self.job.object_id) + self.logger.debug(f"Found ScriptModel ID {script_model.pk}") + script = script_model.python_class() + self.logger.debug(f"Loaded script {script.full_name}") # Add files to form data if request: @@ -100,6 +103,7 @@ class ScriptJob(JobRunner): # Add the current request as a property of the script script.request = request + self.logger.debug(f"Request ID: {request.id}") # Execute the script. If commit is True, wrap it with the event_tracking context manager to ensure we process # change logging, event rules, etc. diff --git a/netbox/extras/lookups.py b/netbox/extras/lookups.py index c496cce78..9e1fe4a0b 100644 --- a/netbox/extras/lookups.py +++ b/netbox/extras/lookups.py @@ -18,9 +18,22 @@ class Empty(Lookup): return f"CAST(LENGTH({sql}) AS BOOLEAN) IS TRUE", params +class NetHost(Lookup): + """ + Similar to ipam.lookups.NetHost, but casts the field to INET. + """ + lookup_name = 'net_host' + + def as_sql(self, qn, connection): + lhs, lhs_params = self.process_lhs(qn, connection) + rhs, rhs_params = self.process_rhs(qn, connection) + params = lhs_params + rhs_params + return 'HOST(CAST(%s AS INET)) = HOST(%s)' % (lhs, rhs), params + + class NetContainsOrEquals(Lookup): """ - This lookup has the same functionality as the one from the ipam app except lhs is cast to inet + Similar to ipam.lookups.NetContainsOrEquals, but casts the field to INET. """ lookup_name = 'net_contains_or_equals' @@ -32,4 +45,5 @@ class NetContainsOrEquals(Lookup): CharField.register_lookup(Empty) +CachedValueField.register_lookup(NetHost) CachedValueField.register_lookup(NetContainsOrEquals) diff --git a/netbox/extras/management/commands/housekeeping.py b/netbox/extras/management/commands/housekeeping.py index b8c7eab7d..0d8a7e0b9 100644 --- a/netbox/extras/management/commands/housekeeping.py +++ b/netbox/extras/management/commands/housekeeping.py @@ -14,9 +14,16 @@ from utilities.proxy import resolve_proxies class Command(BaseCommand): - help = "Perform nightly housekeeping tasks. (This command can be run at any time.)" + help = "Perform nightly housekeeping tasks [DEPRECATED]" def handle(self, *args, **options): + self.stdout.write( + "Running this command is no longer necessary: All housekeeping tasks\n" + "are addressed automatically via NetBox's built-in job scheduler. It\n" + "will be removed in a future release.", + self.style.WARNING + ) + config = Config() # Clear expired authentication sessions (essentially replicating the `clearsessions` command) diff --git a/netbox/extras/migrations/0111_rename_content_types.py b/netbox/extras/migrations/0111_rename_content_types.py index a9f80b146..8a5db53ff 100644 --- a/netbox/extras/migrations/0111_rename_content_types.py +++ b/netbox/extras/migrations/0111_rename_content_types.py @@ -24,7 +24,7 @@ class Migration(migrations.Migration): model_name='customfield', name='object_type', field=models.ForeignKey( - blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='core.objecttype' + blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='contenttypes.contenttype' ), ), migrations.RunSQL(( diff --git a/netbox/extras/migrations/0128_tableconfig.py b/netbox/extras/migrations/0128_tableconfig.py index e6d45199d..98048ee27 100644 --- a/netbox/extras/migrations/0128_tableconfig.py +++ b/netbox/extras/migrations/0128_tableconfig.py @@ -37,7 +37,9 @@ class Migration(migrations.Migration): ( 'object_type', models.ForeignKey( - on_delete=django.db.models.deletion.CASCADE, related_name='table_configs', to='core.objecttype' + on_delete=django.db.models.deletion.CASCADE, + related_name='table_configs', + to='contenttypes.contenttype' ), ), ( diff --git a/netbox/extras/migrations/0130_imageattachment_description.py b/netbox/extras/migrations/0130_imageattachment_description.py new file mode 100644 index 000000000..1e6dd8867 --- /dev/null +++ b/netbox/extras/migrations/0130_imageattachment_description.py @@ -0,0 +1,16 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0129_fix_script_paths'), + ] + + operations = [ + migrations.AddField( + model_name='imageattachment', + name='description', + field=models.CharField(blank=True, max_length=200), + ), + ] diff --git a/netbox/extras/migrations/0131_concrete_objecttype.py b/netbox/extras/migrations/0131_concrete_objecttype.py new file mode 100644 index 000000000..6aed4d97d --- /dev/null +++ b/netbox/extras/migrations/0131_concrete_objecttype.py @@ -0,0 +1,42 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('extras', '0130_imageattachment_description'), + ] + + operations = [ + migrations.AlterField( + model_name='customfield', + name='object_types', + field=models.ManyToManyField(related_name='custom_fields', to='contenttypes.contenttype'), + ), + migrations.AlterField( + model_name='customlink', + name='object_types', + field=models.ManyToManyField(related_name='custom_links', to='contenttypes.contenttype'), + ), + migrations.AlterField( + model_name='eventrule', + name='object_types', + field=models.ManyToManyField(related_name='event_rules', to='contenttypes.contenttype'), + ), + migrations.AlterField( + model_name='exporttemplate', + name='object_types', + field=models.ManyToManyField(related_name='export_templates', to='contenttypes.contenttype'), + ), + migrations.AlterField( + model_name='savedfilter', + name='object_types', + field=models.ManyToManyField(related_name='saved_filters', to='contenttypes.contenttype'), + ), + migrations.AlterField( + model_name='tag', + name='object_types', + field=models.ManyToManyField(blank=True, related_name='+', to='contenttypes.contenttype'), + ), + ] diff --git a/netbox/extras/models/configs.py b/netbox/extras/models/configs.py index 8d6b8d999..f92c66632 100644 --- a/netbox/extras/models/configs.py +++ b/netbox/extras/models/configs.py @@ -1,15 +1,16 @@ -from django.apps import apps +from collections import defaultdict + from django.conf import settings from django.core.validators import ValidationError from django.db import models from django.urls import reverse from django.utils.translation import gettext_lazy as _ +from core.models import ObjectType from extras.models.mixins import RenderTemplateMixin from extras.querysets import ConfigContextQuerySet from netbox.models import ChangeLoggedModel from netbox.models.features import CloningMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin -from netbox.registry import registry from utilities.data import deepmerge __all__ = ( @@ -239,15 +240,12 @@ class ConfigTemplate( sync_data.alters_data = True def get_context(self, context=None, queryset=None): - _context = dict() - for app, model_names in registry['models'].items(): - _context.setdefault(app, {}) - for model_name in model_names: - try: - model = apps.get_registered_model(app, model_name) - _context[app][model.__name__] = model - except LookupError: - pass + _context = defaultdict(dict) + + # Populate all public models for reference within the template + for object_type in ObjectType.objects.public(): + if model := object_type.model_class(): + _context[object_type.app_label][model.__name__] = model # Apply the provided context data, if any if context is not None: diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index aeeb15728..88e6abc27 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -72,7 +72,7 @@ class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)): class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): object_types = models.ManyToManyField( - to='core.ObjectType', + to='contenttypes.ContentType', related_name='custom_fields', help_text=_('The object(s) to which this field applies.') ) @@ -84,7 +84,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): help_text=_('The type of data this custom field holds') ) related_object_type = models.ForeignKey( - to='core.ObjectType', + to='contenttypes.ContentType', on_delete=models.PROTECT, blank=True, null=True, diff --git a/netbox/extras/models/mixins.py b/netbox/extras/models/mixins.py index eb017302a..d04220982 100644 --- a/netbox/extras/models/mixins.py +++ b/netbox/extras/models/mixins.py @@ -2,16 +2,17 @@ import importlib.abc import importlib.util import os import sys + from django.core.files.storage import storages from django.db import models -from django.utils.translation import gettext_lazy as _ from django.http import HttpResponse +from django.utils.module_loading import import_string +from django.utils.translation import gettext_lazy as _ -from extras.constants import DEFAULT_MIME_TYPE +from extras.constants import DEFAULT_MIME_TYPE, JINJA_ENV_PARAMS_WITH_PATH_IMPORT from extras.utils import filename_from_model, filename_from_object from utilities.jinja2 import render_jinja2 - __all__ = ( 'PythonModuleMixin', 'RenderTemplateMixin', @@ -125,12 +126,22 @@ class RenderTemplateMixin(models.Model): class_name=self.__class__ )) + def get_environment_params(self): + """ + Pre-processing of any defined Jinja environment parameters (e.g. to support path resolution). + """ + params = self.environment_params or {} + for name, value in params.items(): + if name in JINJA_ENV_PARAMS_WITH_PATH_IMPORT and type(value) is str: + params[name] = import_string(value) + return params + def render(self, context=None, queryset=None): """ Render the template with the provided context. The context is passed to the Jinja2 environment as a dictionary. """ context = self.get_context(context=context, queryset=queryset) - env_params = self.environment_params or {} + env_params = self.get_environment_params() output = render_jinja2(self.template_code, context, env_params, getattr(self, 'data_file', None)) # Replace CRLF-style line terminators diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 9da2a8d9e..d687aa821 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -1,4 +1,5 @@ import json +import os import urllib.parse from django.conf import settings @@ -8,20 +9,21 @@ from django.core.validators import ValidationError from django.db import models from django.urls import reverse from django.utils import timezone +from django.utils.html import escape +from django.utils.safestring import mark_safe from django.utils.translation import gettext_lazy as _ from rest_framework.utils.encoders import JSONEncoder -from core.models import ObjectType from extras.choices import * -from extras.conditions import ConditionSet +from extras.conditions import ConditionSet, InvalidCondition from extras.constants import * -from extras.utils import image_upload from extras.models.mixins import RenderTemplateMixin +from extras.utils import image_upload from netbox.config import get_config from netbox.events import get_event_type_choices from netbox.models import ChangeLoggedModel from netbox.models.features import ( - CloningMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin + CloningMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, SyncedDataMixin, TagsMixin, has_feature ) from utilities.html import clean_html from utilities.jinja2 import render_jinja2 @@ -49,7 +51,7 @@ class EventRule(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLogged webhook or executing a custom script. """ object_types = models.ManyToManyField( - to='core.ObjectType', + to='contenttypes.ContentType', related_name='event_rules', verbose_name=_('object types'), help_text=_("The object(s) to which this rule applies.") @@ -142,7 +144,15 @@ class EventRule(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLogged if not self.conditions: return True - return ConditionSet(self.conditions).eval(data) + logger = logging.getLogger('netbox.event_rules') + + try: + result = ConditionSet(self.conditions).eval(data) + logger.debug(f'{self.name}: Evaluated as {result}') + return result + except InvalidCondition as e: + logger.error(f"{self.name}: Evaluation failed. {e}") + return False class Webhook(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel): @@ -290,7 +300,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): code to be rendered with an object as context. """ object_types = models.ManyToManyField( - to='core.ObjectType', + to='contenttypes.ContentType', related_name='custom_links', help_text=_('The object type(s) to which this link applies.') ) @@ -386,7 +396,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, ChangeLoggedModel, RenderTemplateMixin): object_types = models.ManyToManyField( - to='core.ObjectType', + to='contenttypes.ContentType', related_name='export_templates', help_text=_('The object type(s) to which this template applies.') ) @@ -451,7 +461,7 @@ class SavedFilter(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): A set of predefined keyword parameters that can be reused to filter for specific objects. """ object_types = models.ManyToManyField( - to='core.ObjectType', + to='contenttypes.ContentType', related_name='saved_filters', help_text=_('The object type(s) to which this filter applies.') ) @@ -531,7 +541,7 @@ class TableConfig(CloningMixin, ChangeLoggedModel): A saved configuration of columns and ordering which applies to a specific table. """ object_type = models.ForeignKey( - to='core.ObjectType', + to='contenttypes.ContentType', on_delete=models.CASCADE, related_name='table_configs', help_text=_("The table's object type"), @@ -670,6 +680,11 @@ class ImageAttachment(ChangeLoggedModel): max_length=50, blank=True ) + description = models.CharField( + verbose_name=_('description'), + max_length=200, + blank=True + ) objects = RestrictedQuerySet.as_manager() @@ -684,16 +699,16 @@ class ImageAttachment(ChangeLoggedModel): verbose_name_plural = _('image attachments') def __str__(self): - if self.name: - return self.name - filename = self.image.name.rsplit('/', 1)[-1] - return filename.split('_', 2)[2] + return self.name or self.filename + + def get_absolute_url(self): + return reverse('extras:imageattachment', args=[self.pk]) def clean(self): super().clean() # Validate the assigned object type - if self.object_type not in ObjectType.objects.with_feature('image_attachments'): + if not has_feature(self.object_type, 'image_attachments'): raise ValidationError( _("Image attachments cannot be assigned to this object type ({type}).").format(type=self.object_type) ) @@ -711,6 +726,22 @@ class ImageAttachment(ChangeLoggedModel): # before the request finishes. (For example, to display a message indicating the ImageAttachment was deleted.) self.image.name = _name + @property + def filename(self): + return os.path.basename(self.image.name).split('_', 2)[2] + + @property + def html_tag(self): + """ + Returns a complete tag suitable for embedding in an HTML document. + """ + return mark_safe('{alt_text}'.format( + url=self.image.url, + height=self.image_height, + width=self.image_width, + alt_text=escape(self.description or self.name), + )) + @property def size(self): """ @@ -789,7 +820,7 @@ class JournalEntry(CustomFieldsMixin, CustomLinksMixin, TagsMixin, ExportTemplat super().clean() # Validate the assigned object type - if self.assigned_object_type not in ObjectType.objects.with_feature('journaling'): + if not has_feature(self.assigned_object_type, 'journaling'): raise ValidationError( _("Journaling is not supported for this object type ({type}).").format(type=self.assigned_object_type) ) @@ -845,7 +876,7 @@ class Bookmark(models.Model): super().clean() # Validate the assigned object type - if self.object_type not in ObjectType.objects.with_feature('bookmarks'): + if not has_feature(self.object_type, 'bookmarks'): raise ValidationError( _("Bookmarks cannot be assigned to this object type ({type}).").format(type=self.object_type) ) diff --git a/netbox/extras/models/notifications.py b/netbox/extras/models/notifications.py index 44874a4c8..c8e8c4fd8 100644 --- a/netbox/extras/models/notifications.py +++ b/netbox/extras/models/notifications.py @@ -7,9 +7,9 @@ from django.db import models from django.urls import reverse from django.utils.translation import gettext_lazy as _ -from core.models import ObjectType from extras.querysets import NotificationQuerySet from netbox.models import ChangeLoggedModel +from netbox.models.features import has_feature from netbox.registry import registry from users.models import User from utilities.querysets import RestrictedQuerySet @@ -94,7 +94,7 @@ class Notification(models.Model): super().clean() # Validate the assigned object type - if self.object_type not in ObjectType.objects.with_feature('notifications'): + if not has_feature(self.object_type, 'notifications'): raise ValidationError( _("Objects of this type ({type}) do not support notifications.").format(type=self.object_type) ) @@ -235,7 +235,7 @@ class Subscription(models.Model): super().clean() # Validate the assigned object type - if self.object_type not in ObjectType.objects.with_feature('notifications'): + if not has_feature(self.object_type, 'notifications'): raise ValidationError( _("Objects of this type ({type}) do not support notifications.").format(type=self.object_type) ) diff --git a/netbox/extras/models/tags.py b/netbox/extras/models/tags.py index b40327265..0df76d7b3 100644 --- a/netbox/extras/models/tags.py +++ b/netbox/extras/models/tags.py @@ -35,7 +35,7 @@ class Tag(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel, TagBase): blank=True, ) object_types = models.ManyToManyField( - to='core.ObjectType', + to='contenttypes.ContentType', related_name='+', blank=True, help_text=_("The object type(s) to which this tag can be applied.") @@ -83,3 +83,6 @@ class TaggedItem(GenericTaggedItemBase): indexes = [models.Index(fields=["content_type", "object_id"])] verbose_name = _('tagged item') verbose_name_plural = _('tagged items') + # Note: while there is no ordering applied here (because it would basically be done on fields + # of the related `tag`), there is an ordering applied to extras.api.views.TaggedItemViewSet + # to allow for proper pagination. diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py index f96066fb1..420ef4882 100644 --- a/netbox/extras/scripts.py +++ b/netbox/extras/scripts.py @@ -31,6 +31,7 @@ __all__ = ( 'DateTimeVar', 'FileVar', 'IntegerVar', + 'DecimalVar', 'IPAddressVar', 'IPAddressWithMaskVar', 'IPNetworkVar', @@ -135,6 +136,26 @@ class IntegerVar(ScriptVariable): self.field_attrs['max_value'] = max_value +class DecimalVar(ScriptVariable): + """ + Decimal representation. Can enforce minimum/maximum values, maximum digits and decimal places. + """ + form_field = forms.DecimalField + + def __init__(self, min_value=None, max_value=None, max_digits=None, decimal_places=None, *args, **kwargs,): + super().__init__(*args, **kwargs) + + # Optional constraints + if min_value: + self.field_attrs["min_value"] = min_value + if max_value: + self.field_attrs["max_value"] = max_value + if max_digits: + self.field_attrs["max_digits"] = max_digits + if decimal_places: + self.field_attrs["decimal_places"] = decimal_places + + class BooleanVar(ScriptVariable): """ Boolean representation (true/false). Renders as a checkbox. diff --git a/netbox/extras/search.py b/netbox/extras/search.py index feb235c29..688618690 100644 --- a/netbox/extras/search.py +++ b/netbox/extras/search.py @@ -14,6 +14,17 @@ class CustomFieldIndex(SearchIndex): display_attrs = ('description',) +@register_search +class ImageAttachmentIndex(SearchIndex): + model = models.ImageAttachment + fields = ( + ('name', 100), + ('filename', 110), + ('description', 500), + ) + display_attrs = ('description',) + + @register_search class JournalEntryIndex(SearchIndex): model = models.JournalEntry diff --git a/netbox/extras/signals.py b/netbox/extras/signals.py index 10c3f73c5..7105c38b4 100644 --- a/netbox/extras/signals.py +++ b/netbox/extras/signals.py @@ -3,12 +3,11 @@ from django.db.models.signals import m2m_changed, post_save, pre_delete from django.dispatch import receiver from core.events import * -from core.models import ObjectType from core.signals import job_end, job_start from extras.events import process_event_rules from extras.models import EventRule, Notification, Subscription from netbox.config import get_config -from netbox.registry import registry +from netbox.models.features import has_feature from netbox.signals import post_clean from utilities.exceptions import AbortRequest from .models import CustomField, TaggedItem @@ -82,7 +81,7 @@ def validate_assigned_tags(sender, instance, action, model, pk_set, **kwargs): """ if action != 'pre_add': return - ct = ObjectType.objects.get_for_model(instance) + ct = ContentType.objects.get_for_model(instance) # Retrieve any applied Tags that are restricted to certain object types for tag in model.objects.filter(pk__in=pk_set, object_types__isnull=False).prefetch_related('object_types'): if ct not in tag.object_types.all(): @@ -150,17 +149,25 @@ def notify_object_changed(sender, instance, **kwargs): event_type = OBJECT_DELETED # Skip unsupported object types - ct = ContentType.objects.get_for_model(instance) - if ct.model not in registry['model_features']['notifications'].get(ct.app_label, []): + if not has_feature(instance, 'notifications'): return + ct = ContentType.objects.get_for_model(instance) + # Find all subscribed Users - subscribed_users = Subscription.objects.filter(object_type=ct, object_id=instance.pk).values_list('user', flat=True) + subscribed_users = Subscription.objects.filter( + object_type=ct, + object_id=instance.pk + ).values_list('user', flat=True) if not subscribed_users: return # Delete any existing Notifications for the object - Notification.objects.filter(object_type=ct, object_id=instance.pk, user__in=subscribed_users).delete() + Notification.objects.filter( + object_type=ct, + object_id=instance.pk, + user__in=subscribed_users + ).delete() # Create Notifications for Subscribers Notification.objects.bulk_create([ diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index e6f488fde..2af89bc9a 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -1,6 +1,7 @@ import json import django_tables2 as tables +from django.template.defaultfilters import filesizeformat from django.utils.html import format_html from django.utils.translation import gettext_lazy as _ @@ -38,10 +39,11 @@ __all__ = ( IMAGEATTACHMENT_IMAGE = """ {% if record.image %} - {{ record }} -{% else %} - — + + + {% endif %} +{{ record }} """ NOTIFICATION_ICON = """ @@ -230,29 +232,50 @@ class ImageAttachmentTable(NetBoxTable): verbose_name=_('ID'), linkify=False ) + image = columns.TemplateColumn( + verbose_name=_('Image'), + template_code=IMAGEATTACHMENT_IMAGE, + ) + name = tables.Column( + verbose_name=_('Name'), + linkify=True, + ) + filename = tables.Column( + verbose_name=_('Filename'), + linkify=lambda record: record.image.url, + orderable=False, + ) + dimensions = columns.TemplateColumn( + verbose_name=_('Dimensions'), + orderable=False, + template_code="{{ record.image_width }}×{{ record.image_height }}", + ) object_type = columns.ContentTypeColumn( verbose_name=_('Object Type'), ) parent = tables.Column( verbose_name=_('Parent'), - linkify=True - ) - image = tables.TemplateColumn( - verbose_name=_('Image'), - template_code=IMAGEATTACHMENT_IMAGE, + linkify=True, + orderable=False, ) size = tables.Column( orderable=False, - verbose_name=_('Size (Bytes)') + verbose_name=_('Size') ) class Meta(NetBoxTable.Meta): model = ImageAttachment fields = ( - 'pk', 'object_type', 'parent', 'image', 'name', 'image_height', 'image_width', 'size', 'created', - 'last_updated', + 'pk', 'object_type', 'parent', 'image', 'name', 'filename', 'description', 'image_height', 'image_width', + 'size', 'created', 'last_updated', ) - default_columns = ('object_type', 'parent', 'image', 'name', 'size', 'created') + default_columns = ('image', 'parent', 'description', 'dimensions', 'size') + + def render_size(self, value): + return filesizeformat(value) + + def value_size(self, value): + return value class SavedFilterTable(NetBoxTable): @@ -317,11 +340,12 @@ class TableConfigTable(NetBoxTable): class BookmarkTable(NetBoxTable): object_type = columns.ContentTypeColumn( - verbose_name=_('Object Types'), + verbose_name=_('Object Type'), ) object = tables.Column( verbose_name=_('Object'), - linkify=True + linkify=True, + orderable=False ) actions = columns.ActionsColumn( actions=('delete',) diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index 29af3f96d..b5a6cb018 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -3,7 +3,6 @@ import datetime from django.contrib.contenttypes.models import ContentType from django.urls import reverse from django.utils.timezone import make_aware, now -from rest_framework import status from core.choices import ManagedFileRootPathChoices from core.events import * @@ -580,7 +579,7 @@ class ImageAttachmentTest( APIViewTestCases.GraphQLTestCase ): model = ImageAttachment - brief_fields = ['display', 'id', 'image', 'name', 'url'] + brief_fields = ['description', 'display', 'id', 'image', 'name', 'url'] @classmethod def setUpTestData(cls): @@ -921,22 +920,6 @@ class CreatedUpdatedFilterTest(APITestCase): self.assertEqual(response.data['results'][0]['id'], rack2.pk) -class ObjectTypeTest(APITestCase): - - def test_list_objects(self): - object_type_count = ObjectType.objects.count() - - response = self.client.get(reverse('extras-api:objecttype-list'), **self.header) - self.assertHttpStatus(response, status.HTTP_200_OK) - self.assertEqual(response.data['count'], object_type_count) - - def test_get_object(self): - object_type = ObjectType.objects.first() - - url = reverse('extras-api:objecttype-detail', kwargs={'pk': object_type.pk}) - self.assertHttpStatus(self.client.get(url, **self.header), status.HTTP_200_OK) - - class SubscriptionTest(APIViewTestCases.APIViewTestCase): model = Subscription brief_fields = ['display', 'id', 'object_id', 'object_type', 'url', 'user'] diff --git a/netbox/extras/tests/test_conditions.py b/netbox/extras/tests/test_conditions.py index dfe460f99..236c53eaa 100644 --- a/netbox/extras/tests/test_conditions.py +++ b/netbox/extras/tests/test_conditions.py @@ -4,7 +4,7 @@ from django.test import TestCase from core.events import * from dcim.choices import SiteStatusChoices from dcim.models import Site -from extras.conditions import Condition, ConditionSet +from extras.conditions import Condition, ConditionSet, InvalidCondition from extras.events import serialize_for_event from extras.forms import EventRuleForm from extras.models import EventRule, Webhook @@ -12,16 +12,11 @@ from extras.models import EventRule, Webhook class ConditionTestCase(TestCase): - def test_dotted_path_access(self): - c = Condition('a.b.c', 1, 'eq') - self.assertTrue(c.eval({'a': {'b': {'c': 1}}})) - self.assertFalse(c.eval({'a': {'b': {'c': 2}}})) - self.assertFalse(c.eval({'a': {'b': {'x': 1}}})) - def test_undefined_attr(self): c = Condition('x', 1, 'eq') - self.assertFalse(c.eval({})) self.assertTrue(c.eval({'x': 1})) + with self.assertRaises(InvalidCondition): + c.eval({}) # # Validation tests @@ -37,10 +32,13 @@ class ConditionTestCase(TestCase): # dict type is unsupported Condition('x', 1, dict()) - def test_invalid_op_type(self): + def test_invalid_op_types(self): with self.assertRaises(ValueError): # 'gt' supports only numeric values Condition('x', 'foo', 'gt') + with self.assertRaises(ValueError): + # 'in' supports only iterable values + Condition('x', 123, 'in') # # Nested attrs tests @@ -50,7 +48,10 @@ class ConditionTestCase(TestCase): c = Condition('x.y.z', 1) self.assertTrue(c.eval({'x': {'y': {'z': 1}}})) self.assertFalse(c.eval({'x': {'y': {'z': 2}}})) - self.assertFalse(c.eval({'a': {'b': {'c': 1}}})) + with self.assertRaises(InvalidCondition): + c.eval({'x': {'y': None}}) + with self.assertRaises(InvalidCondition): + c.eval({'x': {'y': {'a': 1}}}) # # Operator tests @@ -74,23 +75,31 @@ class ConditionTestCase(TestCase): c = Condition('x', 1, 'gt') self.assertTrue(c.eval({'x': 2})) self.assertFalse(c.eval({'x': 1})) + with self.assertRaises(InvalidCondition): + c.eval({'x': 'foo'}) # Invalid type def test_gte(self): c = Condition('x', 1, 'gte') self.assertTrue(c.eval({'x': 2})) self.assertTrue(c.eval({'x': 1})) self.assertFalse(c.eval({'x': 0})) + with self.assertRaises(InvalidCondition): + c.eval({'x': 'foo'}) # Invalid type def test_lt(self): c = Condition('x', 2, 'lt') self.assertTrue(c.eval({'x': 1})) self.assertFalse(c.eval({'x': 2})) + with self.assertRaises(InvalidCondition): + c.eval({'x': 'foo'}) # Invalid type def test_lte(self): c = Condition('x', 2, 'lte') self.assertTrue(c.eval({'x': 1})) self.assertTrue(c.eval({'x': 2})) self.assertFalse(c.eval({'x': 3})) + with self.assertRaises(InvalidCondition): + c.eval({'x': 'foo'}) # Invalid type def test_in(self): c = Condition('x', [1, 2, 3], 'in') @@ -106,6 +115,8 @@ class ConditionTestCase(TestCase): c = Condition('x', 1, 'contains') self.assertTrue(c.eval({'x': [1, 2, 3]})) self.assertFalse(c.eval({'x': [2, 3, 4]})) + with self.assertRaises(InvalidCondition): + c.eval({'x': 123}) # Invalid type def test_contains_negated(self): c = Condition('x', 1, 'contains', negate=True) diff --git a/netbox/extras/tests/test_event_rules.py b/netbox/extras/tests/test_event_rules.py index 2565e5bde..0c9c25de3 100644 --- a/netbox/extras/tests/test_event_rules.py +++ b/netbox/extras/tests/test_event_rules.py @@ -135,7 +135,7 @@ class EventRuleTest(APITestCase): job = self.queue.jobs[0] self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(name='Event Rule 1')) self.assertEqual(job.kwargs['event_type'], OBJECT_CREATED) - self.assertEqual(job.kwargs['model_name'], 'site') + self.assertEqual(job.kwargs['object_type'], ObjectType.objects.get_for_model(Site)) self.assertEqual(job.kwargs['data']['id'], response.data['id']) self.assertEqual(job.kwargs['data']['foo'], 1) self.assertEqual(len(job.kwargs['data']['tags']), len(response.data['tags'])) @@ -186,7 +186,7 @@ class EventRuleTest(APITestCase): for i, job in enumerate(self.queue.jobs): self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(name='Event Rule 1')) self.assertEqual(job.kwargs['event_type'], OBJECT_CREATED) - self.assertEqual(job.kwargs['model_name'], 'site') + self.assertEqual(job.kwargs['object_type'], ObjectType.objects.get_for_model(Site)) self.assertEqual(job.kwargs['data']['id'], response.data[i]['id']) self.assertEqual(job.kwargs['data']['foo'], 1) self.assertEqual(len(job.kwargs['data']['tags']), len(response.data[i]['tags'])) @@ -218,7 +218,7 @@ class EventRuleTest(APITestCase): job = self.queue.jobs[0] self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(name='Event Rule 2')) self.assertEqual(job.kwargs['event_type'], OBJECT_UPDATED) - self.assertEqual(job.kwargs['model_name'], 'site') + self.assertEqual(job.kwargs['object_type'], ObjectType.objects.get_for_model(Site)) self.assertEqual(job.kwargs['data']['id'], site.pk) self.assertEqual(job.kwargs['data']['foo'], 2) self.assertEqual(len(job.kwargs['data']['tags']), len(response.data['tags'])) @@ -275,7 +275,7 @@ class EventRuleTest(APITestCase): for i, job in enumerate(self.queue.jobs): self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(name='Event Rule 2')) self.assertEqual(job.kwargs['event_type'], OBJECT_UPDATED) - self.assertEqual(job.kwargs['model_name'], 'site') + self.assertEqual(job.kwargs['object_type'], ObjectType.objects.get_for_model(Site)) self.assertEqual(job.kwargs['data']['id'], data[i]['id']) self.assertEqual(job.kwargs['data']['foo'], 2) self.assertEqual(len(job.kwargs['data']['tags']), len(response.data[i]['tags'])) @@ -302,7 +302,7 @@ class EventRuleTest(APITestCase): job = self.queue.jobs[0] self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(name='Event Rule 3')) self.assertEqual(job.kwargs['event_type'], OBJECT_DELETED) - self.assertEqual(job.kwargs['model_name'], 'site') + self.assertEqual(job.kwargs['object_type'], ObjectType.objects.get_for_model(Site)) self.assertEqual(job.kwargs['data']['id'], site.pk) self.assertEqual(job.kwargs['data']['foo'], 3) self.assertEqual(job.kwargs['snapshots']['prechange']['name'], 'Site 1') @@ -336,7 +336,7 @@ class EventRuleTest(APITestCase): for i, job in enumerate(self.queue.jobs): self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(name='Event Rule 3')) self.assertEqual(job.kwargs['event_type'], OBJECT_DELETED) - self.assertEqual(job.kwargs['model_name'], 'site') + self.assertEqual(job.kwargs['object_type'], ObjectType.objects.get_for_model(Site)) self.assertEqual(job.kwargs['data']['id'], sites[i].pk) self.assertEqual(job.kwargs['data']['foo'], 3) self.assertEqual(job.kwargs['snapshots']['prechange']['name'], sites[i].name) @@ -368,18 +368,23 @@ class EventRuleTest(APITestCase): self.assertEqual(body['request_id'], str(request_id)) self.assertEqual(body['data']['name'], 'Site 1') self.assertEqual(body['data']['foo'], 1) + self.assertEqual(body['context']['foo'], 123) # From netbox.tests.dummy_plugin return HttpResponse() + # Create a dummy request + request = RequestFactory().get(reverse('dcim:site_add')) + request.id = request_id + request.user = self.user + # Enqueue a webhook for processing webhooks_queue = {} site = Site.objects.create(name='Site 1', slug='site-1') enqueue_event( webhooks_queue, instance=site, - user=self.user, - request_id=request_id, - event_type=OBJECT_CREATED + request=request, + event_type=OBJECT_CREATED, ) flush_events(list(webhooks_queue.values())) diff --git a/netbox/extras/tests/test_scripts.py b/netbox/extras/tests/test_scripts.py index 17eb5a31a..4f5d0187a 100644 --- a/netbox/extras/tests/test_scripts.py +++ b/netbox/extras/tests/test_scripts.py @@ -1,6 +1,7 @@ import logging import tempfile from datetime import date, datetime, timezone +from decimal import Decimal from django.core.files.uploadedfile import SimpleUploadedFile from django.test import TestCase @@ -138,6 +139,54 @@ class ScriptVariablesTest(TestCase): self.assertTrue(form.is_valid()) self.assertEqual(form.cleaned_data['var1'], data['var1']) + def test_decimalvar(self): + + class TestScript(Script): + + var1 = DecimalVar( + min_value=-100.500, + max_value=100.500, + max_digits=6, + decimal_places=3, + required=False + ) + + var2 = DecimalVar( + max_digits=3, + decimal_places=1, + required=False + ) + + # Validate min_value enforcement + data = {'var1': -100.501} + form = TestScript().as_form(data, None) + self.assertFalse(form.is_valid()) + self.assertIn('var1', form.errors) + + # Validate max_value enforcement + data = {'var1': 100.501} + form = TestScript().as_form(data, None) + self.assertFalse(form.is_valid()) + self.assertIn('var1', form.errors) + + # Validate max_digits enforcement + data = {'var2': 123.4} + form = TestScript().as_form(data, None) + self.assertFalse(form.is_valid()) + self.assertIn('var2', form.errors) + + # Validate decimal_places + data = {'var2': 1.23} + form = TestScript().as_form(data, None) + self.assertFalse(form.is_valid()) + self.assertIn('var2', form.errors) + + # Validate valid data + data = {'var1': '50.123'} + form = TestScript().as_form(data, None) + self.assertTrue(form.is_valid()) + self.assertEqual(form.cleaned_data['var1'], Decimal(data['var1'])) + def test_booleanvar(self): class TestScript(Script): diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 43172139c..a2664a2c2 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -1040,6 +1040,11 @@ class ImageAttachmentListView(generic.ObjectListView): actions = (BulkExport,) +@register_model_view(ImageAttachment) +class ImageAttachmentView(generic.ObjectView): + queryset = ImageAttachment.objects.all() + + @register_model_view(ImageAttachment, 'add', detail=False) @register_model_view(ImageAttachment, 'edit') class ImageAttachmentEditView(generic.ObjectEditView): @@ -1053,9 +1058,6 @@ class ImageAttachmentEditView(generic.ObjectEditView): instance.parent = get_object_or_404(object_type.model_class(), pk=request.GET.get('object_id')) return instance - def get_return_url(self, request, obj=None): - return obj.parent.get_absolute_url() if obj else super().get_return_url(request) - def get_extra_addanother_params(self, request): return { 'object_type': request.GET.get('object_type'), @@ -1067,9 +1069,6 @@ class ImageAttachmentEditView(generic.ObjectEditView): class ImageAttachmentDeleteView(generic.ObjectDeleteView): queryset = ImageAttachment.objects.all() - def get_return_url(self, request, obj=None): - return obj.parent.get_absolute_url() if obj else super().get_return_url(request) - # # Journal entries diff --git a/netbox/extras/webhooks.py b/netbox/extras/webhooks.py index 368075217..1620d950e 100644 --- a/netbox/extras/webhooks.py +++ b/netbox/extras/webhooks.py @@ -6,12 +6,28 @@ import requests from django_rq import job from jinja2.exceptions import TemplateError +from netbox.registry import registry from utilities.proxy import resolve_proxies from .constants import WEBHOOK_EVENT_TYPES +__all__ = ( + 'generate_signature', + 'register_webhook_callback', + 'send_webhook', +) + logger = logging.getLogger('netbox.webhooks') +def register_webhook_callback(func): + """ + Register a function as a webhook callback. + """ + registry['webhook_callbacks'].append(func) + logger.debug(f'Registered webhook callback {func.__module__}.{func.__name__}') + return func + + def generate_signature(request_body, secret): """ Return a cryptographic signature that can be used to verify the authenticity of webhook data. @@ -25,7 +41,7 @@ def generate_signature(request_body, secret): @job('default') -def send_webhook(event_rule, model_name, event_type, data, timestamp, username, request_id=None, snapshots=None): +def send_webhook(event_rule, object_type, event_type, data, timestamp, username, request=None, snapshots=None): """ Make a POST request to the defined Webhook """ @@ -35,9 +51,9 @@ def send_webhook(event_rule, model_name, event_type, data, timestamp, username, context = { 'event': WEBHOOK_EVENT_TYPES.get(event_type, event_type), 'timestamp': timestamp, - 'model': model_name, + 'model': object_type.model, 'username': username, - 'request_id': request_id, + 'request_id': request.id if request else None, 'data': data, } if snapshots: @@ -45,6 +61,18 @@ def send_webhook(event_rule, model_name, event_type, data, timestamp, username, 'snapshots': snapshots }) + # Add any additional context from plugins + callback_data = {} + for callback in registry['webhook_callbacks']: + try: + if ret := callback(object_type, event_type, data, request): + callback_data.update(**ret) + except Exception as e: + logger.warning(f"Caught exception when processing callback {callback}: {e}") + pass + if callback_data: + context['context'] = callback_data + # Build the headers for the HTTP request headers = { 'Content-Type': webhook.http_content_type, diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 9c351a119..36e195900 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -700,7 +700,7 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFil service_id = django_filters.ModelMultipleChoiceFilter( field_name='services', queryset=Service.objects.all(), - label=_('Service (ID)'), + label=_('Application Service (ID)'), ) nat_inside_id = django_filters.ModelMultipleChoiceFilter( field_name='nat_inside', diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py index 96bfae34c..5ee073bc9 100644 --- a/netbox/ipam/forms/model_forms.py +++ b/netbox/ipam/forms/model_forms.py @@ -20,6 +20,7 @@ from utilities.forms.fields import ( from utilities.forms.rendering import FieldSet, InlineFields, ObjectAttribute, TabbedGroups from utilities.forms.utils import get_field_value from utilities.forms.widgets import DatePicker, HTMXSelect +from django.utils.safestring import mark_safe from utilities.templatetags.builtins.filters import bettertitle from virtualization.models import VMInterface @@ -703,7 +704,15 @@ class VLANForm(TenancyForm, NetBoxModelForm): queryset=Site.objects.all(), required=False, null_option='None', - selector=True + selector=True, + help_text=mark_safe( + ' {text}'.format( + text=_( + 'The direct assignment of VLANs to a site is deprecated and will be removed in a future release. ' + 'Users are encouraged to utilize VLAN groups for this purpose.' + ) + ) + ) ) role = DynamicModelChoiceField( label=_('Role'), @@ -772,7 +781,7 @@ class ServiceTemplateForm(NetBoxModelForm): comments = CommentField() fieldsets = ( - FieldSet('name', 'protocol', 'ports', 'description', 'tags', name=_('Service Template')), + FieldSet('name', 'protocol', 'ports', 'description', 'tags', name=_('Application Service Template')), ) class Meta: @@ -817,7 +826,7 @@ class ServiceForm(NetBoxModelForm): FieldSet( 'parent_object_type', 'parent', 'name', InlineFields('protocol', 'ports', label=_('Port(s)')), - 'ipaddresses', 'description', 'tags', name=_('Service') + 'ipaddresses', 'description', 'tags', name=_('Application Service') ), ) @@ -859,7 +868,7 @@ class ServiceForm(NetBoxModelForm): class ServiceCreateForm(ServiceForm): service_template = DynamicModelChoiceField( - label=_('Service template'), + label=_('Application Service template'), queryset=ServiceTemplate.objects.all(), required=False ) @@ -871,7 +880,7 @@ class ServiceCreateForm(ServiceForm): FieldSet('service_template', name=_('From Template')), FieldSet('name', 'protocol', 'ports', name=_('Custom')), ), - 'ipaddresses', 'description', 'tags', name=_('Service') + 'ipaddresses', 'description', 'tags', name=_('Application Service') ), ) @@ -900,4 +909,6 @@ class ServiceCreateForm(ServiceForm): if not self.cleaned_data['description']: self.cleaned_data['description'] = service_template.description elif not all(self.cleaned_data[f] for f in ('name', 'protocol', 'ports')): - raise forms.ValidationError(_("Must specify name, protocol, and port(s) if not using a service template.")) + raise forms.ValidationError( + _("Must specify name, protocol, and port(s) if not using an application service template.") + ) diff --git a/netbox/ipam/models/asns.py b/netbox/ipam/models/asns.py index c1d251301..ee1a5416e 100644 --- a/netbox/ipam/models/asns.py +++ b/netbox/ipam/models/asns.py @@ -5,6 +5,7 @@ from django.utils.translation import gettext_lazy as _ from ipam.fields import ASNField from ipam.querysets import ASNRangeQuerySet from netbox.models import OrganizationalModel, PrimaryModel +from netbox.models.features import ContactsMixin __all__ = ( 'ASN', @@ -88,7 +89,7 @@ class ASNRange(OrganizationalModel): return available_asns -class ASN(PrimaryModel): +class ASN(ContactsMixin, PrimaryModel): """ An autonomous system (AS) number is typically used to represent an independent routing domain. A site can have one or more ASNs assigned to it. diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index b2ef42941..ea8c86967 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -1,5 +1,6 @@ import netaddr from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.db import models from django.db.models import F @@ -8,7 +9,6 @@ from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ from netaddr.ip import IPNetwork -from core.models import ObjectType from dcim.models.mixins import CachedScopeMixin from ipam.choices import * from ipam.constants import * @@ -163,6 +163,11 @@ class Aggregate(ContactsMixin, GetAvailablePrefixesMixin, PrimaryModel): return self.prefix.version return None + @property + def ipv6_full(self): + if self.prefix and self.prefix.version == 6: + return netaddr.IPAddress(self.prefix).format(netaddr.ipv6_full) + def get_child_prefixes(self): """ Return all Prefixes within this Aggregate @@ -367,6 +372,11 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary @property def mask_length(self): return self.prefix.prefixlen if self.prefix else None + + @property + def ipv6_full(self): + if self.prefix and self.prefix.version == 6: + return netaddr.IPAddress(self.prefix).format(netaddr.ipv6_full) @property def depth_count(self): @@ -906,6 +916,11 @@ class IPAddress(ContactsMixin, PrimaryModel): self._original_assigned_object_id = self.__dict__.get('assigned_object_id') self._original_assigned_object_type_id = self.__dict__.get('assigned_object_type_id') + @property + def ipv6_full(self): + if self.address and self.address.version == 6: + return netaddr.IPAddress(self.address).format(netaddr.ipv6_full) + def get_duplicates(self): return IPAddress.objects.filter( vrf=self.vrf, @@ -1010,7 +1025,7 @@ class IPAddress(ContactsMixin, PrimaryModel): if self._original_assigned_object_id and self._original_assigned_object_type_id: parent = getattr(self.assigned_object, 'parent_object', None) - ct = ObjectType.objects.get_for_id(self._original_assigned_object_type_id) + ct = ContentType.objects.get_for_id(self._original_assigned_object_type_id) original_assigned_object = ct.get_object_for_this_type(pk=self._original_assigned_object_id) original_parent = getattr(original_assigned_object, 'parent_object', None) diff --git a/netbox/ipam/models/services.py b/netbox/ipam/models/services.py index 2afd16076..c2c9ca444 100644 --- a/netbox/ipam/models/services.py +++ b/netbox/ipam/models/services.py @@ -55,8 +55,8 @@ class ServiceTemplate(ServiceBase, PrimaryModel): class Meta: ordering = ('name',) - verbose_name = _('service template') - verbose_name_plural = _('service templates') + verbose_name = _('application service template') + verbose_name_plural = _('application service templates') class Service(ContactsMixin, ServiceBase, PrimaryModel): @@ -84,7 +84,7 @@ class Service(ContactsMixin, ServiceBase, PrimaryModel): related_name='services', blank=True, verbose_name=_('IP addresses'), - help_text=_("The specific IP addresses (if any) to which this service is bound") + help_text=_("The specific IP addresses (if any) to which this application service is bound") ) clone_fields = ['protocol', 'ports', 'description', 'parent', 'ipaddresses', ] @@ -94,5 +94,5 @@ class Service(ContactsMixin, ServiceBase, PrimaryModel): models.Index(fields=('parent_object_type', 'parent_object_id')), ) ordering = ('protocol', 'ports', 'pk') # (protocol, port) may be non-unique - verbose_name = _('service') - verbose_name_plural = _('services') + verbose_name = _('application service') + verbose_name_plural = _('application services') diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index 79d524e87..59a9511e3 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -1111,6 +1111,10 @@ class VLANTranslationRuleTest(APIViewTestCases.APIViewTestCase): name='Policy 2', description='foobar2', ), + VLANTranslationPolicy( + name='Policy 3', + description='foobar2', + ), ) VLANTranslationPolicy.objects.bulk_create(vlan_translation_policies) @@ -1155,7 +1159,8 @@ class VLANTranslationRuleTest(APIViewTestCases.APIViewTestCase): ] cls.bulk_update_data = { - 'policy': vlan_translation_policies[1].pk, + 'policy': vlan_translation_policies[2].pk, + 'description': 'New description', } @@ -1165,6 +1170,7 @@ class ServiceTemplateTest(APIViewTestCases.APIViewTestCase): bulk_update_data = { 'description': 'New description', } + graphql_base_name = 'service_template' @classmethod def setUpTestData(cls): @@ -1200,6 +1206,7 @@ class ServiceTest(APIViewTestCases.APIViewTestCase): bulk_update_data = { 'description': 'New description', } + graphql_base_name = 'service' @classmethod def setUpTestData(cls): diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index e068f8e06..e40b620e1 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/netbox/ipam/tests/test_filtersets.py @@ -1106,6 +1106,9 @@ class IPAddressTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = IPAddress.objects.all() filterset = IPAddressFilterSet ignore_fields = ('fhrpgroup',) + filter_name_map = { + 'application_service': 'service', + } @classmethod def setUpTestData(cls): diff --git a/netbox/media/devicetype-images/.gitignore b/netbox/media/devicetype-images/.gitignore deleted file mode 100644 index d6b7ef32c..000000000 --- a/netbox/media/devicetype-images/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/netbox/media/image-attachments/.gitignore b/netbox/media/image-attachments/.gitignore deleted file mode 100644 index d6b7ef32c..000000000 --- a/netbox/media/image-attachments/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/netbox/netbox/api/exceptions.py b/netbox/netbox/api/exceptions.py index f552b06b5..c4d05d1a5 100644 --- a/netbox/netbox/api/exceptions.py +++ b/netbox/netbox/api/exceptions.py @@ -12,3 +12,7 @@ class SerializerNotFound(Exception): class GraphQLTypeNotFound(Exception): pass + + +class QuerySetNotOrdered(Exception): + pass diff --git a/netbox/netbox/api/pagination.py b/netbox/netbox/api/pagination.py index f1430a9fd..698e0a8dd 100644 --- a/netbox/netbox/api/pagination.py +++ b/netbox/netbox/api/pagination.py @@ -1,6 +1,7 @@ from django.db.models import QuerySet from rest_framework.pagination import LimitOffsetPagination +from netbox.api.exceptions import QuerySetNotOrdered from netbox.config import get_config @@ -15,6 +16,12 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination): def paginate_queryset(self, queryset, request, view=None): + if isinstance(queryset, QuerySet) and not queryset.ordered: + raise QuerySetNotOrdered( + "Paginating over an unordered queryset is unreliable. Ensure that a minimal " + "ordering has been applied to the queryset for this API endpoint." + ) + if isinstance(queryset, QuerySet): self.count = self.get_queryset_count(queryset) else: diff --git a/netbox/netbox/api/serializers/__init__.py b/netbox/netbox/api/serializers/__init__.py index 0ec3ab5f3..d7ad19565 100644 --- a/netbox/netbox/api/serializers/__init__.py +++ b/netbox/netbox/api/serializers/__init__.py @@ -10,7 +10,12 @@ from .nested import * # Base model serializers # -class NetBoxModelSerializer(TaggableModelSerializer, CustomFieldModelSerializer, ValidatedModelSerializer): +class NetBoxModelSerializer( + ChangeLogMessageSerializer, + TaggableModelSerializer, + CustomFieldModelSerializer, + ValidatedModelSerializer +): """ Adds support for custom fields and tags. """ @@ -24,5 +29,5 @@ class NestedGroupModelSerializer(NetBoxModelSerializer): _depth = serializers.IntegerField(source='level', read_only=True) -class BulkOperationSerializer(serializers.Serializer): +class BulkOperationSerializer(ChangeLogMessageSerializer): id = serializers.IntegerField() diff --git a/netbox/netbox/api/serializers/features.py b/netbox/netbox/api/serializers/features.py index 3bd5c8a2d..1ee92e828 100644 --- a/netbox/netbox/api/serializers/features.py +++ b/netbox/netbox/api/serializers/features.py @@ -5,6 +5,7 @@ from extras.api.customfields import CustomFieldsDataField, CustomFieldDefaultVal from .nested import NestedTagSerializer __all__ = ( + 'ChangeLogMessageSerializer', 'CustomFieldModelSerializer', 'TaggableModelSerializer', ) @@ -54,3 +55,24 @@ class TaggableModelSerializer(serializers.Serializer): instance.tags.clear() return instance + + +class ChangeLogMessageSerializer(serializers.Serializer): + changelog_message = serializers.CharField( + write_only=True, + required=False, + ) + + def to_internal_value(self, data): + ret = super().to_internal_value(data) + + # Workaround to bypass requirement to include changelog_message in Meta.fields on every serializer + if type(data) is dict and 'changelog_message' in data: + ret['changelog_message'] = data['changelog_message'] + + return ret + + def save(self, **kwargs): + if self.instance is not None: + self.instance._changelog_message = self.validated_data.get('changelog_message') + return super().save(**kwargs) diff --git a/netbox/netbox/api/views.py b/netbox/netbox/api/views.py index 1befda371..82124e1c5 100644 --- a/netbox/netbox/api/views.py +++ b/netbox/netbox/api/views.py @@ -65,6 +65,7 @@ class StatusView(APIView): return Response({ 'django-version': DJANGO_VERSION, + 'hostname': settings.HOSTNAME, 'installed-apps': installed_apps, 'netbox-version': settings.RELEASE.version, 'netbox-full-version': settings.RELEASE.full_version, diff --git a/netbox/netbox/api/viewsets/__init__.py b/netbox/netbox/api/viewsets/__init__.py index 2039f735b..6241be4cd 100644 --- a/netbox/netbox/api/viewsets/__init__.py +++ b/netbox/netbox/api/viewsets/__init__.py @@ -7,9 +7,11 @@ from django.db.models import ProtectedError, RestrictedError from django_pglocks import advisory_lock from netbox.constants import ADVISORY_LOCK_KEYS from rest_framework import mixins as drf_mixins +from rest_framework import status from rest_framework.response import Response from rest_framework.viewsets import GenericViewSet +from netbox.api.serializers.features import ChangeLogMessageSerializer from utilities.api import get_annotations_for_serializer, get_prefetches_for_serializer from utilities.exceptions import AbortRequest from utilities.query import reapply_model_ordering @@ -199,9 +201,16 @@ class NetBoxModelViewSet( # Deletes def destroy(self, request, *args, **kwargs): - # Hotwire get_object() to ensure we save a pre-change snapshot - self.get_object = self.get_object_with_snapshot - return super().destroy(request, *args, **kwargs) + instance = self.get_object_with_snapshot() + + # Attach changelog message (if any) + serializer = ChangeLogMessageSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + instance._changelog_message = serializer.validated_data.get('changelog_message') + + self.perform_destroy(instance) + + return Response(status=status.HTTP_204_NO_CONTENT) def perform_destroy(self, instance): model = self.queryset.model diff --git a/netbox/netbox/api/viewsets/mixins.py b/netbox/netbox/api/viewsets/mixins.py index 4fedebad5..e74488164 100644 --- a/netbox/netbox/api/viewsets/mixins.py +++ b/netbox/netbox/api/viewsets/mixins.py @@ -149,18 +149,25 @@ class BulkDestroyModelMixin: serializer = BulkOperationSerializer(data=request.data, many=True) serializer.is_valid(raise_exception=True) qs = self.get_bulk_destroy_queryset().filter( - pk__in=[o['id'] for o in serializer.data] + pk__in=[o['id'] for o in serializer.validated_data] ) - self.perform_bulk_destroy(qs) + # Compile any changelog messages to be recorded on the objects being deleted + changelog_messages = { + o['id']: o.get('changelog_message') for o in serializer.validated_data + } + + self.perform_bulk_destroy(qs, changelog_messages) return Response(status=status.HTTP_204_NO_CONTENT) - def perform_bulk_destroy(self, objects): + def perform_bulk_destroy(self, objects, changelog_messages=None): + changelog_messages = changelog_messages or {} with transaction.atomic(using=router.db_for_write(self.queryset.model)): for obj in objects: if hasattr(obj, 'snapshot'): obj.snapshot() + obj._changelog_message = changelog_messages.get(obj.pk) self.perform_destroy(obj) diff --git a/netbox/netbox/constants.py b/netbox/netbox/constants.py index ad96a643d..75e0f4da6 100644 --- a/netbox/netbox/constants.py +++ b/netbox/netbox/constants.py @@ -1,3 +1,18 @@ +CORE_APPS = ( + 'account', + 'circuits', + 'core', + 'dcim', + 'extras', + 'ipam', + 'tenancy', + 'users', + 'utilities', + 'virtualization', + 'vpn', + 'wireless', +) + # RQ queue names RQ_QUEUE_DEFAULT = 'default' RQ_QUEUE_HIGH = 'high' diff --git a/netbox/netbox/forms/base.py b/netbox/netbox/forms/base.py index 632d5ecb1..14916a733 100644 --- a/netbox/netbox/forms/base.py +++ b/netbox/netbox/forms/base.py @@ -8,10 +8,10 @@ from django.utils.translation import gettext_lazy as _ from core.models import ObjectType from extras.choices import * from extras.models import CustomField, Tag -from utilities.forms import CSVModelForm +from utilities.forms import BulkEditForm, CSVModelForm from utilities.forms.fields import CSVModelMultipleChoiceField, DynamicModelMultipleChoiceField from utilities.forms.mixins import CheckLastUpdatedMixin -from .mixins import CustomFieldsMixin, SavedFiltersMixin, TagsMixin +from .mixins import ChangelogMessageMixin, CustomFieldsMixin, SavedFiltersMixin, TagsMixin __all__ = ( 'NetBoxModelForm', @@ -21,7 +21,7 @@ __all__ = ( ) -class NetBoxModelForm(CheckLastUpdatedMixin, CustomFieldsMixin, TagsMixin, forms.ModelForm): +class NetBoxModelForm(ChangelogMessageMixin, CheckLastUpdatedMixin, CustomFieldsMixin, TagsMixin, forms.ModelForm): """ Base form for creating & editing NetBox models. Extends Django's ModelForm to add support for custom fields. @@ -100,7 +100,7 @@ class NetBoxModelImportForm(CSVModelForm, NetBoxModelForm): return customfield.to_form_field(for_csv_import=True) -class NetBoxModelBulkEditForm(CustomFieldsMixin, forms.Form): +class NetBoxModelBulkEditForm(ChangelogMessageMixin, CustomFieldsMixin, BulkEditForm): """ Base form for modifying multiple NetBox objects (of the same type) in bulk via the UI. Adds support for custom fields and adding/removing tags. @@ -108,9 +108,8 @@ class NetBoxModelBulkEditForm(CustomFieldsMixin, forms.Form): Attributes: fieldsets: An iterable of two-tuples which define a heading and field set to display per section of the rendered form (optional). If not defined, the all fields will be rendered as a single section. - nullable_fields: A list of field names indicating which fields support being set to null/empty """ - nullable_fields = () + fieldsets = None pk = forms.ModelMultipleChoiceField( queryset=None, # Set from self.model on init diff --git a/netbox/netbox/forms/mixins.py b/netbox/netbox/forms/mixins.py index c569343ee..84928fcd9 100644 --- a/netbox/netbox/forms/mixins.py +++ b/netbox/netbox/forms/mixins.py @@ -7,12 +7,23 @@ from extras.models import * from utilities.forms.fields import DynamicModelMultipleChoiceField __all__ = ( + 'ChangelogMessageMixin', 'CustomFieldsMixin', 'SavedFiltersMixin', 'TagsMixin', ) +class ChangelogMessageMixin(forms.Form): + """ + Adds an optional field for recording a message on the resulting changelog record(s). + """ + changelog_message = forms.CharField( + required=False, + max_length=200 + ) + + class CustomFieldsMixin: """ Extend a Form to include custom field support. diff --git a/netbox/netbox/jobs.py b/netbox/netbox/jobs.py index 3af3af554..559619ac0 100644 --- a/netbox/netbox/jobs.py +++ b/netbox/netbox/jobs.py @@ -8,11 +8,14 @@ from django_pglocks import advisory_lock from rq.timeouts import JobTimeoutException from core.choices import JobStatusChoices +from core.exceptions import JobFailed from core.models import Job, ObjectType from netbox.constants import ADVISORY_LOCK_KEYS from netbox.registry import registry +from utilities.request import apply_request_processors __all__ = ( + 'AsyncViewJob', 'JobRunner', 'system_job', ) @@ -34,6 +37,19 @@ def system_job(interval): return _wrapper +class JobLogHandler(logging.Handler): + """ + A logging handler which records entries on a Job. + """ + def __init__(self, job, *args, **kwargs): + super().__init__(*args, **kwargs) + self.job = job + + def emit(self, record): + # Enter the record in the log of the associated Job + self.job.log(record) + + class JobRunner(ABC): """ Background Job helper class. @@ -52,6 +68,11 @@ class JobRunner(ABC): """ self.job = job + # Initiate the system logger + self.logger = logging.getLogger(f"netbox.jobs.{self.__class__.__name__}") + self.logger.setLevel(logging.DEBUG) + self.logger.addHandler(JobLogHandler(job)) + @classproperty def name(cls): return getattr(cls.Meta, 'name', cls.__name__) @@ -73,15 +94,21 @@ class JobRunner(ABC): This method is called by the Job Scheduler to handle the execution of all job commands. It will maintain the job's metadata and handle errors. For periodic jobs, a new job is automatically scheduled using its `interval`. """ + logger = logging.getLogger('netbox.jobs') + try: job.start() cls(job).run(*args, **kwargs) job.terminate() + except JobFailed: + logger.warning(f"Job {job} failed") + job.terminate(status=JobStatusChoices.STATUS_FAILED) + except Exception as e: job.terminate(status=JobStatusChoices.STATUS_ERRORED, error=repr(e)) if type(e) is JobTimeoutException: - logging.error(e) + logger.error(e) # If the executed job is a periodic job, schedule its next execution at the specified interval. finally: @@ -154,3 +181,22 @@ class JobRunner(ABC): job.delete() return cls.enqueue(instance=instance, schedule_at=schedule_at, interval=interval, *args, **kwargs) + + +class AsyncViewJob(JobRunner): + """ + Execute a view as a background job. + """ + class Meta: + name = 'Async View' + + def run(self, view_cls, request, **kwargs): + view = view_cls.as_view() + request.job = self + + # Apply all registered request processors (e.g. event_tracking) + with apply_request_processors(request): + view(request) + + if self.job.error: + raise JobFailed() diff --git a/netbox/netbox/metrics.py b/netbox/netbox/metrics.py new file mode 100644 index 000000000..59619d34a --- /dev/null +++ b/netbox/netbox/metrics.py @@ -0,0 +1,40 @@ +from django_prometheus.conf import NAMESPACE +from django_prometheus import middleware +from prometheus_client import Counter + +__all__ = ( + 'Metrics', +) + + +class Metrics(middleware.Metrics): + """ + Expand the stock Metrics class from django_prometheus to add our own counters. + """ + + def register(self): + super().register() + + # REST API metrics + self.rest_api_requests = self.register_metric( + Counter, + "rest_api_requests_total_by_method", + "Count of total REST API requests by method", + ["method"], + namespace=NAMESPACE, + ) + self.rest_api_requests_by_view_method = self.register_metric( + Counter, + "rest_api_requests_total_by_view_method", + "Count of REST API requests by view & method", + ["view", "method"], + namespace=NAMESPACE, + ) + + # GraphQL API metrics + self.graphql_api_requests = self.register_metric( + Counter, + "graphql_api_requests_total", + "Count of total GraphQL API requests", + namespace=NAMESPACE, + ) diff --git a/netbox/netbox/middleware.py b/netbox/netbox/middleware.py index 3a9cb0f78..66c980778 100644 --- a/netbox/netbox/middleware.py +++ b/netbox/netbox/middleware.py @@ -1,8 +1,5 @@ -from contextlib import ExitStack - import logging import uuid -import warnings from django.conf import settings from django.contrib import auth, messages @@ -11,16 +8,20 @@ from django.core.exceptions import ImproperlyConfigured from django.db import connection, ProgrammingError from django.db.utils import InternalError from django.http import Http404, HttpResponseRedirect +from django_prometheus import middleware from netbox.config import clear_config, get_config -from netbox.registry import registry +from netbox.metrics import Metrics from netbox.views import handler_500 -from utilities.api import is_api_request +from utilities.api import is_api_request, is_graphql_request from utilities.error_handlers import handle_rest_api_exception +from utilities.request import apply_request_processors __all__ = ( 'CoreMiddleware', 'MaintenanceModeMiddleware', + 'PrometheusAfterMiddleware', + 'PrometheusBeforeMiddleware', 'RemoteUserMiddleware', ) @@ -36,12 +37,7 @@ class CoreMiddleware: request.id = uuid.uuid4() # Apply all registered request processors - with ExitStack() as stack: - for request_processor in registry['request_processors']: - try: - stack.enter_context(request_processor(request)) - except Exception as e: - warnings.warn(f'Failed to initialize request processor {request_processor}: {e}') + with apply_request_processors(request): response = self.get_response(request) # Check if language cookie should be renewed @@ -188,6 +184,30 @@ class RemoteUserMiddleware(RemoteUserMiddleware_): return groups +class PrometheusBeforeMiddleware(middleware.PrometheusBeforeMiddleware): + metrics_cls = Metrics + + +class PrometheusAfterMiddleware(middleware.PrometheusAfterMiddleware): + metrics_cls = Metrics + + def process_response(self, request, response): + response = super().process_response(request, response) + + # Increment REST API request counters + if is_api_request(request): + method = self._method(request) + name = self._get_view_name(request) + self.label_metric(self.metrics.rest_api_requests, request, method=method).inc() + self.label_metric(self.metrics.rest_api_requests_by_view_method, request, method=method, view=name).inc() + + # Increment GraphQL API request counters + elif is_graphql_request(request): + self.metrics.graphql_api_requests.inc() + + return response + + class MaintenanceModeMiddleware: """ Middleware that checks if the application is in maintenance mode diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index 79145ce70..1e0ad1229 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -3,6 +3,7 @@ from collections import defaultdict from functools import cached_property from django.contrib.contenttypes.fields import GenericRelation +from django.contrib.contenttypes.models import ContentType from django.core.validators import ValidationError from django.db import models from django.db.models import Q @@ -16,7 +17,9 @@ from extras.choices import * from extras.constants import CUSTOMFIELD_EMPTY_VALUES from extras.utils import is_taggable from netbox.config import get_config +from netbox.constants import CORE_APPS from netbox.models.deletion import DeleteMixin +from netbox.plugins import PluginConfig from netbox.registry import registry from netbox.signals import post_clean from utilities.json import CustomFieldJSONEncoder @@ -32,12 +35,16 @@ __all__ = ( 'CustomValidationMixin', 'EventRulesMixin', 'ExportTemplatesMixin', + 'FEATURES_MAP', 'ImageAttachmentsMixin', 'JobsMixin', 'JournalingMixin', 'NotificationsMixin', 'SyncedDataMixin', 'TagsMixin', + 'get_model_features', + 'has_feature', + 'model_is_public', 'register_models', ) @@ -66,6 +73,11 @@ class ChangeLoggingMixin(DeleteMixin, models.Model): class Meta: abstract = True + def __init__(self, *args, **kwargs): + changelog_message = kwargs.pop('changelog_message', None) + super().__init__(*args, **kwargs) + self._changelog_message = changelog_message + def serialize_object(self, exclude=None): """ Return a JSON representation of the instance. Models can override this method to replace or extend the default @@ -103,7 +115,8 @@ class ChangeLoggingMixin(DeleteMixin, models.Model): objectchange = ObjectChange( changed_object=self, object_repr=str(self)[:200], - action=action + action=action, + message=self._changelog_message or '', ) if hasattr(self, '_prechange_snapshot'): objectchange.prechange_data = self._prechange_snapshot @@ -633,11 +646,50 @@ FEATURES_MAP = { 'tags': TagsMixin, } +# TODO: Remove in NetBox v4.5 registry['model_features'].update({ feature: defaultdict(set) for feature in FEATURES_MAP.keys() }) +def model_is_public(model): + """ + Return True if the model is considered "public use;" otherwise return False. + + All non-core and non-plugin models are excluded. + """ + opts = model._meta + if opts.app_label not in CORE_APPS and not isinstance(opts.app_config, PluginConfig): + return False + return not getattr(model, '_netbox_private', False) + + +def get_model_features(model): + return [ + feature for feature, cls in FEATURES_MAP.items() if issubclass(model, cls) + ] + + +def has_feature(model_or_ct, feature): + """ + Returns True if the model supports the specified feature. + """ + # If an ObjectType was passed, we can use it directly + if type(model_or_ct) is ObjectType: + ot = model_or_ct + # If a ContentType was passed, resolve its model class + elif type(model_or_ct) is ContentType: + model_class = model_or_ct.model_class() + ot = ObjectType.objects.get_for_model(model_class) if model_class else None + # For anything else, look up the ObjectType + else: + ot = ObjectType.objects.get_for_model(model_or_ct) + # ObjectType is invalid/deleted + if ot is None: + return False + return feature in ot.features + + def register_models(*models): """ Register one or more models in NetBox. This entails: @@ -653,10 +705,12 @@ def register_models(*models): for model in models: app_label, model_name = model._meta.label_lower.split('.') + # TODO: Remove in NetBox v4.5 # Register public models if not getattr(model, '_netbox_private', False): registry['models'][app_label].add(model_name) + # TODO: Remove in NetBox v4.5 # Record each applicable feature for the model in the registry features = { feature for feature, cls in FEATURES_MAP.items() if issubclass(model, cls) @@ -686,6 +740,10 @@ def register_models(*models): register_model_view(model, 'jobs', kwargs={'model': model})( 'netbox.views.generic.ObjectJobsView' ) + if issubclass(model, ImageAttachmentsMixin): + register_model_view(model, 'image-attachments', kwargs={'model': model})( + 'netbox.views.generic.ObjectImageAttachmentsView' + ) if issubclass(model, SyncedDataMixin): register_model_view(model, 'sync', kwargs={'model': model})( 'netbox.views.generic.ObjectSyncDataView' diff --git a/netbox/netbox/navigation/__init__.py b/netbox/netbox/navigation/__init__.py index 75ca8f440..9b61968bc 100644 --- a/netbox/netbox/navigation/__init__.py +++ b/netbox/netbox/navigation/__init__.py @@ -1,6 +1,8 @@ from dataclasses import dataclass from typing import Sequence, Optional +from django.urls import reverse_lazy + __all__ = ( 'get_model_item', @@ -22,20 +24,46 @@ class MenuItemButton: link: str title: str icon_class: str + _url: Optional[str] = None permissions: Optional[Sequence[str]] = () color: Optional[str] = None + def __post_init__(self): + if self.link: + self._url = reverse_lazy(self.link) + + @property + def url(self): + return self._url + + @url.setter + def url(self, value): + self._url = value + @dataclass class MenuItem: link: str link_text: str + _url: Optional[str] = None permissions: Optional[Sequence[str]] = () auth_required: Optional[bool] = False staff_only: Optional[bool] = False buttons: Optional[Sequence[MenuItemButton]] = () + def __post_init__(self): + if self.link: + self._url = reverse_lazy(self.link) + + @property + def url(self): + return self._url + + @url.setter + def url(self, value): + self._url = value + @dataclass class MenuGroup: diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index 982d40829..ea8d886c4 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -209,8 +209,8 @@ IPAM_MENU = Menu( label=_('Other'), items=( get_model_item('ipam', 'fhrpgroup', _('FHRP Groups')), - get_model_item('ipam', 'servicetemplate', _('Service Templates')), - get_model_item('ipam', 'service', _('Services')), + get_model_item('ipam', 'servicetemplate', _('Application Service Templates')), + get_model_item('ipam', 'service', _('Application Services')), ), ), ), diff --git a/netbox/netbox/object_actions.py b/netbox/netbox/object_actions.py index 73315ce4c..51687eee1 100644 --- a/netbox/netbox/object_actions.py +++ b/netbox/netbox/object_actions.py @@ -1,3 +1,4 @@ +from django.template import loader from django.urls import reverse from django.urls.exceptions import NoReverseMatch from django.utils.translation import gettext as _ @@ -5,6 +6,7 @@ from django.utils.translation import gettext as _ from core.models import ObjectType from extras.models import ExportTemplate from utilities.querydict import prepare_cloned_fields +from utilities.views import get_viewname __all__ = ( 'AddObject', @@ -27,19 +29,21 @@ class ObjectAction: Params: name: The action name appended to the module for view resolution label: Human-friendly label for the rendered button + template_name: Name of the HTML template which renders the button multi: Set to True if this action is performed by selecting multiple objects (i.e. using a table) permissions_required: The set of permissions a user must have to perform the action url_kwargs: The set of URL keyword arguments to pass when resolving the view's URL """ name = '' label = None + template_name = None multi = False permissions_required = set() url_kwargs = [] @classmethod def get_url(cls, obj): - viewname = f'{obj._meta.app_label}:{obj._meta.model_name}_{cls.name}' + viewname = get_viewname(obj, action=cls.name) kwargs = { kwarg: getattr(obj, kwarg) for kwarg in cls.url_kwargs } @@ -49,11 +53,13 @@ class ObjectAction: return @classmethod - def get_context(cls, context, obj): - return { + def render(cls, obj, **kwargs): + context = { 'url': cls.get_url(obj), 'label': cls.label, + **kwargs, } + return loader.render_to_string(cls.template_name, context) class AddObject(ObjectAction): diff --git a/netbox/netbox/plugins/navigation.py b/netbox/netbox/plugins/navigation.py index fc86b134a..2b18a4a0e 100644 --- a/netbox/netbox/plugins/navigation.py +++ b/netbox/netbox/plugins/navigation.py @@ -1,3 +1,4 @@ +from django.urls import reverse_lazy from django.utils.text import slugify from django.utils.translation import gettext as _ @@ -32,17 +33,23 @@ class PluginMenuItem: This class represents a navigation menu item. This constitutes primary link and its text, but also allows for specifying additional link buttons that appear to the right of the item in the van menu. - Links are specified as Django reverse URL strings. + Links are specified as Django reverse URL strings suitable for rendering via {% url item.link %}. + Alternatively, a pre-generated url can be set on the object which will be rendered literally. Buttons are each specified as a list of PluginMenuButton instances. """ permissions = [] buttons = [] + _url = None - def __init__(self, link, link_text, auth_required=False, staff_only=False, permissions=None, buttons=None): + def __init__( + self, link, link_text, auth_required=False, staff_only=False, permissions=None, buttons=None + ): self.link = link self.link_text = link_text self.auth_required = auth_required self.staff_only = staff_only + if link: + self._url = reverse_lazy(link) if permissions is not None: if type(permissions) not in (list, tuple): raise TypeError(_("Permissions must be passed as a tuple or list.")) @@ -52,6 +59,14 @@ class PluginMenuItem: raise TypeError(_("Buttons must be passed as a tuple or list.")) self.buttons = buttons + @property + def url(self): + return self._url + + @url.setter + def url(self, value): + self._url = value + class PluginMenuButton: """ @@ -60,11 +75,14 @@ class PluginMenuButton: """ color = ButtonColorChoices.DEFAULT permissions = [] + _url = None def __init__(self, link, title, icon_class, color=None, permissions=None): self.link = link self.title = title self.icon_class = icon_class + if link: + self._url = reverse_lazy(link) if permissions is not None: if type(permissions) not in (list, tuple): raise TypeError(_("Permissions must be passed as a tuple or list.")) @@ -73,3 +91,11 @@ class PluginMenuButton: if color not in ButtonColorChoices.values(): raise ValueError(_("Button color must be a choice within ButtonColorChoices.")) self.color = color + + @property + def url(self): + return self._url + + @url.setter + def url(self, value): + self._url = value diff --git a/netbox/netbox/registry.py b/netbox/netbox/registry.py index 02b741779..fe5ce4301 100644 --- a/netbox/netbox/registry.py +++ b/netbox/netbox/registry.py @@ -34,5 +34,6 @@ registry = Registry({ 'system_jobs': dict(), 'tables': collections.defaultdict(dict), 'views': collections.defaultdict(dict), + 'webhook_callbacks': list(), 'widgets': dict(), }) diff --git a/netbox/netbox/search/backends.py b/netbox/netbox/search/backends.py index 12243e9b6..cb08ab4af 100644 --- a/netbox/netbox/search/backends.py +++ b/netbox/netbox/search/backends.py @@ -115,11 +115,13 @@ class CachedValueSearchBackend(SearchBackend): if lookup in (LookupTypes.STARTSWITH, LookupTypes.ENDSWITH): # "Starts/ends with" matches are valid only on string values query_filter &= Q(type=FieldTypes.STRING) - elif lookup == LookupTypes.PARTIAL: + elif lookup in (LookupTypes.PARTIAL, LookupTypes.EXACT): try: - # If the value looks like an IP address, add an extra match for CIDR values + # If the value looks like an IP address, add extra filters for CIDR/INET values address = str(netaddr.IPNetwork(value.strip()).cidr) - query_filter |= Q(type=FieldTypes.CIDR) & Q(value__net_contains_or_equals=address) + query_filter |= Q(type=FieldTypes.INET) & Q(value__net_host=address) + if lookup == LookupTypes.PARTIAL: + query_filter |= Q(type=FieldTypes.CIDR) & Q(value__net_contains_or_equals=address) except (AddrFormatError, ValueError): pass diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 239b5978f..c9eed75e1 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -27,7 +27,6 @@ from utilities.string import trailing_slash RELEASE = load_release_data() VERSION = RELEASE.full_version # Retained for backward compatibility -HOSTNAME = platform.node() # Set the base directory two levels up BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) @@ -125,6 +124,7 @@ EXEMPT_VIEW_PERMISSIONS = getattr(configuration, 'EXEMPT_VIEW_PERMISSIONS', []) FIELD_CHOICES = getattr(configuration, 'FIELD_CHOICES', {}) FILE_UPLOAD_MAX_MEMORY_SIZE = getattr(configuration, 'FILE_UPLOAD_MAX_MEMORY_SIZE', 2621440) GRAPHQL_MAX_ALIASES = getattr(configuration, 'GRAPHQL_MAX_ALIASES', 10) +HOSTNAME = getattr(configuration, 'HOSTNAME', platform.node()) HTTP_PROXIES = getattr(configuration, 'HTTP_PROXIES', {}) INTERNAL_IPS = getattr(configuration, 'INTERNAL_IPS', ('127.0.0.1', '::1')) ISOLATED_DEPLOYMENT = getattr(configuration, 'ISOLATED_DEPLOYMENT', False) @@ -424,6 +424,7 @@ INSTALLED_APPS = [ 'mptt', 'rest_framework', 'social_django', + 'sorl.thumbnail', 'taggit', 'timezone_field', 'core', @@ -471,9 +472,9 @@ if DEBUG: if METRICS_ENABLED: # If metrics are enabled, add the before & after Prometheus middleware MIDDLEWARE = [ - 'django_prometheus.middleware.PrometheusBeforeMiddleware', + 'netbox.middleware.PrometheusBeforeMiddleware', *MIDDLEWARE, - 'django_prometheus.middleware.PrometheusAfterMiddleware', + 'netbox.middleware.PrometheusAfterMiddleware', ] # URLs diff --git a/netbox/netbox/tests/dummy_plugin/__init__.py b/netbox/netbox/tests/dummy_plugin/__init__.py index 2ca7c290c..01eb0baa5 100644 --- a/netbox/netbox/tests/dummy_plugin/__init__.py +++ b/netbox/netbox/tests/dummy_plugin/__init__.py @@ -24,7 +24,7 @@ class DummyPluginConfig(PluginConfig): def ready(self): super().ready() - from . import jobs # noqa: F401 + from . import jobs, webhook_callbacks # noqa: F401 config = DummyPluginConfig diff --git a/netbox/netbox/tests/dummy_plugin/urls.py b/netbox/netbox/tests/dummy_plugin/urls.py index 6cdd48f7e..ff6b1cee6 100644 --- a/netbox/netbox/tests/dummy_plugin/urls.py +++ b/netbox/netbox/tests/dummy_plugin/urls.py @@ -7,5 +7,8 @@ urlpatterns = ( path('models/', views.DummyModelsView.as_view(), name='dummy_model_list'), path('models/add/', views.DummyModelAddView.as_view(), name='dummy_model_add'), + path('netboxmodel/', views.DummyNetBoxModelView.as_view(), name='dummynetboxmodel_list'), + path('netboxmodel/add/', views.DummyNetBoxModelView.as_view(), name='dummynetboxmodel_add'), + path('netboxmodel/import/', views.DummyNetBoxModelView.as_view(), name='dummynetboxmodel_bulk_import'), path('netboxmodel//', views.DummyNetBoxModelView.as_view(), name='dummynetboxmodel'), ) diff --git a/netbox/netbox/tests/dummy_plugin/webhook_callbacks.py b/netbox/netbox/tests/dummy_plugin/webhook_callbacks.py new file mode 100644 index 000000000..095f545b5 --- /dev/null +++ b/netbox/netbox/tests/dummy_plugin/webhook_callbacks.py @@ -0,0 +1,8 @@ +from extras.webhooks import register_webhook_callback + + +@register_webhook_callback +def set_context(object_type, event_type, data, request): + return { + 'foo': 123, + } diff --git a/netbox/netbox/tests/test_api.py b/netbox/netbox/tests/test_api.py index d087910b5..61bbcd4c6 100644 --- a/netbox/netbox/tests/test_api.py +++ b/netbox/netbox/tests/test_api.py @@ -1,8 +1,13 @@ import uuid +from django.test import RequestFactory, TestCase from django.urls import reverse +from rest_framework.request import Request +from netbox.api.exceptions import QuerySetNotOrdered +from netbox.api.pagination import OptionalLimitOffsetPagination from utilities.testing import APITestCase +from users.models import Token class AppTest(APITestCase): @@ -26,3 +31,40 @@ class AppTest(APITestCase): response = self.client.get(f'{url}?format=api', **self.header) self.assertEqual(response.status_code, 200) + + +class OptionalLimitOffsetPaginationTest(TestCase): + + def setUp(self): + self.paginator = OptionalLimitOffsetPagination() + self.factory = RequestFactory() + + def _make_drf_request(self, path='/', query_params=None): + """Helper to create a proper DRF Request object""" + return Request(self.factory.get(path, query_params or {})) + + def test_raises_exception_for_unordered_queryset(self): + """Should raise QuerySetNotOrdered for unordered QuerySet""" + queryset = Token.objects.all().order_by() + request = self._make_drf_request() + + with self.assertRaises(QuerySetNotOrdered) as cm: + self.paginator.paginate_queryset(queryset, request) + + error_msg = str(cm.exception) + self.assertIn("Paginating over an unordered queryset is unreliable", error_msg) + self.assertIn("Ensure that a minimal ordering has been applied", error_msg) + + def test_allows_ordered_queryset(self): + """Should not raise exception for ordered QuerySet""" + queryset = Token.objects.all().order_by('created') + request = self._make_drf_request() + + self.paginator.paginate_queryset(queryset, request) # Should not raise exception + + def test_allows_non_queryset_iterables(self): + """Should not raise exception for non-QuerySet iterables""" + iterable = [1, 2, 3, 4, 5] + request = self._make_drf_request() + + self.paginator.paginate_queryset(iterable, request) # Should not raise exception diff --git a/netbox/netbox/tests/test_jobs.py b/netbox/netbox/tests/test_jobs.py index e3e24a235..dea8b30d2 100644 --- a/netbox/netbox/tests/test_jobs.py +++ b/netbox/netbox/tests/test_jobs.py @@ -7,11 +7,19 @@ from django_rq import get_queue from ..jobs import * from core.models import DataSource, Job from core.choices import JobStatusChoices +from core.exceptions import JobFailed +from utilities.testing import disable_warnings class TestJobRunner(JobRunner): + def run(self, *args, **kwargs): - pass + if kwargs.get('make_fail', False): + raise JobFailed() + self.logger.debug("Debug message") + self.logger.info("Info message") + self.logger.warning("Warning message") + self.logger.error("Error message") class JobRunnerTestCase(TestCase): @@ -47,8 +55,22 @@ class JobRunnerTest(JobRunnerTestCase): def test_handle(self): job = TestJobRunner.enqueue(immediate=True) + # Check job status self.assertEqual(job.status, JobStatusChoices.STATUS_COMPLETED) + # Check logging + self.assertEqual(len(job.log_entries), 4) + self.assertEqual(job.log_entries[0]['message'], "Debug message") + self.assertEqual(job.log_entries[1]['message'], "Info message") + self.assertEqual(job.log_entries[2]['message'], "Warning message") + self.assertEqual(job.log_entries[3]['message'], "Error message") + + def test_handle_failed(self): + with disable_warnings('netbox.jobs'): + job = TestJobRunner.enqueue(immediate=True, make_fail=True) + + self.assertEqual(job.status, JobStatusChoices.STATUS_FAILED) + def test_handle_errored(self): class ErroredJobRunner(TestJobRunner): EXP = Exception('Test error') diff --git a/netbox/netbox/tests/test_model_features.py b/netbox/netbox/tests/test_model_features.py new file mode 100644 index 000000000..190c177ea --- /dev/null +++ b/netbox/netbox/tests/test_model_features.py @@ -0,0 +1,53 @@ +from django.test import TestCase + +from core.models import AutoSyncRecord, DataSource +from extras.models import CustomLink +from netbox.models.features import get_model_features, has_feature, model_is_public +from netbox.tests.dummy_plugin.models import DummyModel +from taggit.models import Tag + + +class ModelFeaturesTestCase(TestCase): + + def test_model_is_public(self): + """ + Test that the is_public() utility function returns True for public models only. + """ + # Public model + self.assertFalse(hasattr(DataSource, '_netbox_private')) + self.assertTrue(model_is_public(DataSource)) + + # Private model + self.assertTrue(getattr(AutoSyncRecord, '_netbox_private')) + self.assertFalse(model_is_public(AutoSyncRecord)) + + # Plugin model + self.assertFalse(hasattr(DummyModel, '_netbox_private')) + self.assertTrue(model_is_public(DummyModel)) + + # Non-core model + self.assertFalse(hasattr(Tag, '_netbox_private')) + self.assertFalse(model_is_public(Tag)) + + def test_has_feature(self): + """ + Test the functionality of the has_feature() utility function. + """ + # Sanity checking + self.assertTrue(hasattr(DataSource, 'bookmarks'), "Invalid test?") + self.assertFalse(hasattr(AutoSyncRecord, 'bookmarks'), "Invalid test?") + + self.assertTrue(has_feature(DataSource, 'bookmarks')) + self.assertFalse(has_feature(AutoSyncRecord, 'bookmarks')) + + def test_get_model_features(self): + """ + Check that get_model_features() returns the expected features for a model. + """ + # Sanity checking + self.assertTrue(hasattr(CustomLink, 'clone'), "Invalid test?") + self.assertFalse(hasattr(CustomLink, 'bookmarks'), "Invalid test?") + + features = get_model_features(CustomLink) + self.assertIn('cloning', features) + self.assertNotIn('bookmarks', features) diff --git a/netbox/netbox/tests/test_object_actions.py b/netbox/netbox/tests/test_object_actions.py new file mode 100644 index 000000000..7e3b16bf1 --- /dev/null +++ b/netbox/netbox/tests/test_object_actions.py @@ -0,0 +1,32 @@ +from unittest import skipIf + +from django.conf import settings +from django.test import TestCase + +from dcim.models import Device +from netbox.object_actions import AddObject, BulkImport +from netbox.tests.dummy_plugin.models import DummyNetBoxModel + + +class ObjectActionTest(TestCase): + + def test_get_url_core_model(self): + """Test URL generation for core NetBox models""" + obj = Device() + + url = AddObject.get_url(obj) + self.assertEqual(url, '/dcim/devices/add/') + + url = BulkImport.get_url(obj) + self.assertEqual(url, '/dcim/devices/import/') + + @skipIf('netbox.tests.dummy_plugin' not in settings.PLUGINS, "dummy_plugin not in settings.PLUGINS") + def test_get_url_plugin_model(self): + """Test URL generation for plugin models includes plugins: namespace""" + obj = DummyNetBoxModel() + + url = AddObject.get_url(obj) + self.assertEqual(url, '/plugins/dummy-plugin/netboxmodel/add/') + + url = BulkImport.get_url(obj) + self.assertEqual(url, '/plugins/dummy-plugin/netboxmodel/import/') diff --git a/netbox/netbox/tests/test_plugins.py b/netbox/netbox/tests/test_plugins.py index 264c8e6f9..550dca514 100644 --- a/netbox/netbox/tests/test_plugins.py +++ b/netbox/netbox/tests/test_plugins.py @@ -6,9 +6,11 @@ from django.test import Client, TestCase, override_settings from django.urls import reverse from core.choices import JobIntervalChoices +from core.models import ObjectType from netbox.tests.dummy_plugin import config as dummy_config from netbox.tests.dummy_plugin.data_backends import DummyBackend from netbox.tests.dummy_plugin.jobs import DummySystemJob +from netbox.tests.dummy_plugin.webhook_callbacks import set_context from netbox.plugins.navigation import PluginMenu from netbox.plugins.utils import get_plugin_config from netbox.graphql.schema import Query @@ -23,8 +25,9 @@ class PluginTest(TestCase): self.assertIn('netbox.tests.dummy_plugin.DummyPluginConfig', settings.INSTALLED_APPS) def test_model_registration(self): - self.assertIn('dummy_plugin', registry['models']) - self.assertIn('dummymodel', registry['models']['dummy_plugin']) + self.assertTrue( + ObjectType.objects.filter(app_label='dummy_plugin', model='dummymodel').exists() + ) def test_models(self): from netbox.tests.dummy_plugin.models import DummyModel @@ -218,3 +221,9 @@ class PluginTest(TestCase): Check that events pipeline is registered. """ self.assertIn('netbox.tests.dummy_plugin.events.process_events_queue', settings.EVENTS_PIPELINE) + + def test_webhook_callbacks(self): + """ + Test the registration of webhook callbacks. + """ + self.assertIn(set_context, registry['webhook_callbacks']) diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index 11d7bafee..aa77507de 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -17,17 +17,19 @@ from django.utils.safestring import mark_safe from django.utils.translation import gettext as _ from mptt.models import MPTTModel +from core.exceptions import JobFailed from core.models import ObjectType from core.signals import clear_events from extras.choices import CustomFieldUIEditableChoices from extras.models import CustomField, ExportTemplate from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport, BulkRename from utilities.error_handlers import handle_protectederror -from utilities.exceptions import AbortRequest, AbortTransaction, PermissionsViolation +from utilities.exceptions import AbortRequest, PermissionsViolation from utilities.export import TableExport -from utilities.forms import BulkRenameForm, ConfirmationForm, restrict_form_fields +from utilities.forms import BulkDeleteForm, BulkRenameForm, restrict_form_fields from utilities.forms.bulk_import import BulkImportForm from utilities.htmx import htmx_partial +from utilities.jobs import is_background_request, process_request_as_job from utilities.permissions import get_permission_for_model from utilities.query import reapply_model_ordering from utilities.request import safe_for_redirect @@ -356,7 +358,18 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView): return {**required_fields, **optional_fields} - def _save_object(self, import_form, model_form, request): + def _compile_form_errors(self, errors, index, prefix=None): + error_messages = [] + for field_name, errors in errors.items(): + prefix = f'{prefix}.' if prefix else '' + if field_name == '__all__': + field_name = '' + for err in errors: + error_messages.append(f"Record {index} {prefix}{field_name}: {err}") + return error_messages + + def _save_object(self, model_form, request): + _action = 'Updated' if model_form.instance.pk else 'Created' # Save the primary object obj = self.save_object(model_form, request) @@ -382,20 +395,18 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView): related_obj_pks.append(related_obj.pk) else: # Replicate errors on the related object form to the import form for display and abort - for subfield_name, errors in f.errors.items(): - for err in errors: - if subfield_name == '__all__': - err_msg = f"{field_name}[{i}]: {err}" - else: - err_msg = f"{field_name}[{i}] {subfield_name}: {err}" - import_form.add_error(None, err_msg) - raise AbortTransaction() + raise ValidationError( + self._compile_form_errors(f.errors, index=i, prefix=f'{field_name}[{i}]') + ) # Enforce object-level permissions on related objects model = related_object_form.Meta.model if model.objects.filter(pk__in=related_obj_pks).count() != len(related_obj_pks): raise ObjectDoesNotExist + if is_background_request(request): + request.job.logger.info(f'{_action} {obj}') + return obj def save_object(self, object_form, request): @@ -421,7 +432,6 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView): } if prefetch_ids else {} for i, record in enumerate(records, start=1): - instance = None object_id = int(record.pop('id')) if record.get('id') else None # Determine whether this object is being created or updated @@ -437,6 +447,8 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView): instance.snapshot() else: + instance = self.queryset.model() + # For newly created objects, apply any default custom field values custom_fields = CustomField.objects.filter( object_types=ContentType.objects.get_for_model(self.queryset.model), @@ -447,6 +459,9 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView): if field_name not in record: record[field_name] = cf.default + # Record changelog message (if any) + instance._changelog_message = form.cleaned_data.get('changelog_message', '') + # Instantiate the model form for the object model_form_kwargs = { 'data': record, @@ -466,18 +481,13 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView): restrict_form_fields(model_form, request.user) if model_form.is_valid(): - obj = self._save_object(form, model_form, request) + obj = self._save_object(model_form, request) saved_objects.append(obj) else: - # Replicate model form errors for display - for field, errors in model_form.errors.items(): - for err in errors: - if field == '__all__': - form.add_error(None, f'Record {i}: {err}') - else: - form.add_error(None, f'Record {i} {field}: {err}') - - raise ValidationError("") + # Raise model form errors + raise ValidationError( + self._compile_form_errors(model_form.errors, index=i) + ) return saved_objects @@ -503,32 +513,51 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView): if form.is_valid(): logger.debug("Import form validation was successful") + redirect_url = reverse(get_viewname(model, action='list')) + + # If indicated, defer this request to a background job & redirect the user + if form.cleaned_data['background_job']: + job_name = _('Bulk import {count} {object_type}').format( + count=len(form.cleaned_data['data']), + object_type=model._meta.verbose_name_plural, + ) + if process_request_as_job(self.__class__, request, name=job_name): + return redirect(redirect_url) try: # Iterate through data and bind each record to a new model form instance. with transaction.atomic(using=router.db_for_write(model)): - new_objs = self.create_and_update_objects(form, request) + new_objects = self.create_and_update_objects(form, request) # Enforce object-level permissions - if self.queryset.filter(pk__in=[obj.pk for obj in new_objs]).count() != len(new_objs): + if self.queryset.filter(pk__in=[obj.pk for obj in new_objects]).count() != len(new_objects): raise PermissionsViolation - if new_objs: - msg = f"Imported {len(new_objs)} {model._meta.verbose_name_plural}" - logger.info(msg) - messages.success(request, msg) + msg = _('Imported {count} {object_type}').format( + count=len(new_objects), + object_type=model._meta.verbose_name_plural + ) + logger.info(msg) - view_name = get_viewname(model, action='list') - results_url = f"{reverse(view_name)}?modified_by_request={request.id}" - return redirect(results_url) + # Handle background job + if is_background_request(request): + request.job.logger.info(msg) + return - except (AbortTransaction, ValidationError): - clear_events.send(sender=self) - - except (AbortRequest, PermissionsViolation) as e: - logger.debug(e.message) - form.add_error(None, e.message) + messages.success(request, msg) + return redirect(f"{redirect_url}?modified_by_request={request.id}") + + except (AbortRequest, PermissionsViolation, ValidationError) as e: + err_messages = e.messages if type(e) is ValidationError else [e.message] + for msg in err_messages: + logger.debug(msg) + form.add_error(None, msg) + if is_background_request(request): + request.job.logger.error(msg) + request.job.logger.warning("Bulk import aborted") clear_events.send(sender=self) + if is_background_request(request): + raise JobFailed else: logger.debug("Form validation failed") @@ -599,6 +628,9 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView): if hasattr(obj, 'snapshot'): obj.snapshot() + # Attach the changelog message (if any) to the object + obj._changelog_message = form.cleaned_data.get('changelog_message') + # Update standard fields. If a field is listed in _nullify, delete its value. for name, model_field in model_fields.items(): # Handle nullification @@ -641,6 +673,9 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView): self.post_save_operations(form, obj) + if is_background_request(request): + request.job.logger.info(f"Updated {obj}") + # Rebuild the tree for MPTT models if issubclass(self.queryset.model, MPTTModel): self.queryset.model.objects.rebuild() @@ -685,6 +720,16 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView): if '_apply' in request.POST: if form.is_valid(): logger.debug("Form validation was successful") + + # If indicated, defer this request to a background job & redirect the user + if form.cleaned_data['background_job']: + job_name = _('Bulk edit {count} {object_type}').format( + count=len(form.cleaned_data['pk']), + object_type=model._meta.verbose_name_plural, + ) + if process_request_as_job(self.__class__, request, name=job_name): + return redirect(self.get_return_url(request)) + try: with transaction.atomic(using=router.db_for_write(model)): updated_objects = self._update_objects(form, request) @@ -694,21 +739,30 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView): if object_count != len(updated_objects): raise PermissionsViolation - if updated_objects: - msg = f'Updated {len(updated_objects)} {model._meta.verbose_name_plural}' - logger.info(msg) - messages.success(self.request, msg) + msg = _('Updated {count} {object_type}').format( + count=len(updated_objects), + object_type=model._meta.verbose_name_plural, + ) + logger.info(msg) + # Handle background job + if is_background_request(request): + request.job.logger.info(msg) + return + + messages.success(self.request, msg) return redirect(self.get_return_url(request)) - except ValidationError as e: - messages.error(self.request, ", ".join(e.messages)) - clear_events.send(sender=self) - - except (AbortRequest, PermissionsViolation) as e: - logger.debug(e.message) - form.add_error(None, e.message) + except (AbortRequest, PermissionsViolation, ValidationError) as e: + err_messages = e.messages if type(e) is ValidationError else [e.message] + for msg in err_messages: + logger.debug(msg) + form.add_error(None, msg) + if is_background_request(request): + request.job.logger.error(msg) clear_events.send(sender=self) + if is_background_request(request): + raise JobFailed else: logger.debug("Form validation failed") @@ -845,15 +899,6 @@ class BulkDeleteView(GetReturnURLMixin, BaseMultiObjectView): def get_required_permission(self): return get_permission_for_model(self.queryset.model, 'delete') - def get_form(self): - """ - Provide a standard bulk delete form if none has been specified for the view - """ - class BulkDeleteForm(ConfirmationForm): - pk = ModelMultipleChoiceField(queryset=self.queryset, widget=MultipleHiddenInput) - - return BulkDeleteForm - # # Request handlers # @@ -874,47 +919,76 @@ class BulkDeleteView(GetReturnURLMixin, BaseMultiObjectView): else: pk_list = [int(pk) for pk in request.POST.getlist('pk')] - form_cls = self.get_form() - if '_confirm' in request.POST: - form = form_cls(request.POST) + form = BulkDeleteForm(model, request.POST) if form.is_valid(): logger.debug("Form validation was successful") + # If indicated, defer this request to a background job & redirect the user + if form.cleaned_data['background_job']: + job_name = _('Bulk delete {count} {object_type}').format( + count=len(form.cleaned_data['pk']), + object_type=model._meta.verbose_name_plural, + ) + if process_request_as_job(self.__class__, request, name=job_name): + return redirect(self.get_return_url(request)) + # Delete objects queryset = self.queryset.filter(pk__in=pk_list) deleted_count = queryset.count() try: with transaction.atomic(using=router.db_for_write(model)): for obj in queryset: + # Take a snapshot of change-logged models if hasattr(obj, 'snapshot'): obj.snapshot() + + # Attach the changelog message (if any) to the object + obj._changelog_message = form.cleaned_data.get('changelog_message') + + # Delete the object obj.delete() + if is_background_request(request): + request.job.logger.info(f"Deleted {obj}") + + msg = _('Deleted {count} {object_type}').format( + count=deleted_count, + object_type=model._meta.verbose_name_plural + ) + logger.info(msg) + + # Handle background job + if is_background_request(request): + request.job.logger.info(msg) + return + + messages.success(request, msg) + except (ProtectedError, RestrictedError) as e: - logger.info(f"Caught {type(e)} while attempting to delete objects") + logger.warning(f"Caught {type(e)} while attempting to delete objects") + if is_background_request(request): + request.job.logger.error( + _("Deletion failed due to the presence of one or more dependent objects.") + ) + raise JobFailed handle_protectederror(queryset, request, e) - return redirect(self.get_return_url(request)) except AbortRequest as e: logger.debug(e.message) + if is_background_request(request): + request.job.logger.error(e.message) + raise JobFailed messages.error(request, mark_safe(e.message)) - return redirect(self.get_return_url(request)) - msg = _("Deleted {count} {object_type}").format( - count=deleted_count, - object_type=model._meta.verbose_name_plural - ) - logger.info(msg) - messages.success(request, msg) return redirect(self.get_return_url(request)) else: logger.debug("Form validation failed") else: - form = form_cls(initial={ + form = BulkDeleteForm(model, initial={ 'pk': pk_list, 'return_url': self.get_return_url(request), }) diff --git a/netbox/netbox/views/generic/feature_views.py b/netbox/netbox/views/generic/feature_views.py index d8ba2b475..338ed0628 100644 --- a/netbox/netbox/views/generic/feature_views.py +++ b/netbox/netbox/views/generic/feature_views.py @@ -10,7 +10,7 @@ from django.views.generic import View from core.models import Job, ObjectChange from core.tables import JobTable, ObjectChangeTable from extras.forms import JournalEntryForm -from extras.models import JournalEntry +from extras.models import ImageAttachment, JournalEntry from extras.tables import JournalEntryTable from tenancy.models import ContactAssignment from tenancy.tables import ContactAssignmentTable @@ -25,6 +25,7 @@ __all__ = ( 'BulkSyncDataView', 'ObjectChangeLogView', 'ObjectContactsView', + 'ObjectImageAttachmentsView', 'ObjectJobsView', 'ObjectJournalView', 'ObjectSyncDataView', @@ -84,6 +85,41 @@ class ObjectChangeLogView(ConditionalLoginRequiredMixin, View): }) +class ObjectImageAttachmentsView(ConditionalLoginRequiredMixin, View): + """ + Render all images attached to the object as linked thumbnails. + + Attributes: + base_template: The name of the template to extend. If not provided, "{app}/{model}.html" will be used. + """ + base_template = None + tab = ViewTab( + label=_('Images'), + badge=lambda obj: obj.images.count(), + permission='extras.view_imageattachment', + weight=6000 + ) + + def get(self, request, model, **kwargs): + obj = get_object_or_404(model.objects.restrict(request.user, 'view'), **kwargs) + image_attachments = ImageAttachment.objects.filter( + object_type=ContentType.objects.get_for_model(obj), + object_id=obj.pk, + ) + + # Default to using "/.html" as the template, if it exists. Otherwise, + # fall back to using base.html. + if self.base_template is None: + self.base_template = f"{model._meta.app_label}/{model._meta.model_name}.html" + + return render(request, 'extras/object_imageattachments.html', { + 'object': obj, + 'image_attachments': image_attachments, + 'base_template': self.base_template, + 'tab': self.tab, + }) + + class ObjectJournalView(ConditionalLoginRequiredMixin, View): """ Show all journal entries for an object. The model class must be passed as a keyword argument when referencing this diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index 5bc79d962..efb1b58d0 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -19,7 +19,7 @@ from netbox.object_actions import ( ) from utilities.error_handlers import handle_protectederror from utilities.exceptions import AbortRequest, PermissionsViolation -from utilities.forms import ConfirmationForm, restrict_form_fields +from utilities.forms import DeleteForm, restrict_form_fields from utilities.htmx import htmx_partial from utilities.permissions import get_permission_for_model from utilities.querydict import normalize_querydict, prepare_cloned_fields @@ -288,6 +288,9 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView): if form.is_valid(): logger.debug("Form validation was successful") + # Record changelog message (if any) + obj._changelog_message = form.cleaned_data.pop('changelog_message', '') + try: with transaction.atomic(using=router.db_for_write(model)): object_created = form.instance.pk is None @@ -422,7 +425,7 @@ class ObjectDeleteView(GetReturnURLMixin, BaseObjectView): request: The current request """ obj = self.get_object(**kwargs) - form = ConfirmationForm(initial=request.GET) + form = DeleteForm(instance=obj, initial=request.GET) try: dependent_objects = self._get_dependent_objects(obj) @@ -461,23 +464,25 @@ class ObjectDeleteView(GetReturnURLMixin, BaseObjectView): """ logger = logging.getLogger('netbox.views.ObjectDeleteView') obj = self.get_object(**kwargs) - form = ConfirmationForm(request.POST) - - # Take a snapshot of change-logged models - if hasattr(obj, 'snapshot'): - obj.snapshot() + form = DeleteForm(request.POST, instance=obj) if form.is_valid(): logger.debug("Form validation was successful") + # Take a snapshot of change-logged models + if hasattr(obj, 'snapshot'): + obj.snapshot() + + # Record changelog message (if any) + obj._changelog_message = form.cleaned_data.pop('changelog_message', '') + + # Delete the object try: obj.delete() - except (ProtectedError, RestrictedError) as e: logger.info(f"Caught {type(e)} while attempting to delete objects") handle_protectederror([obj], request, e) return redirect(obj.get_absolute_url()) - except AbortRequest as e: logger.debug(e.message) messages.error(request, mark_safe(e.message)) diff --git a/netbox/project-static/dist/netbox.css b/netbox/project-static/dist/netbox.css index 3cfdc2e5d..91d3ea25c 100644 Binary files a/netbox/project-static/dist/netbox.css and b/netbox/project-static/dist/netbox.css differ diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index 2f4422fd6..ed5971014 100644 Binary files a/netbox/project-static/dist/netbox.js and b/netbox/project-static/dist/netbox.js differ diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map index 6e4020dcb..295f03aca 100644 Binary files a/netbox/project-static/dist/netbox.js.map and b/netbox/project-static/dist/netbox.js.map differ diff --git a/netbox/project-static/package.json b/netbox/project-static/package.json index bf75a5e38..7eeffd48a 100644 --- a/netbox/project-static/package.json +++ b/netbox/project-static/package.json @@ -23,13 +23,13 @@ }, "dependencies": { "@mdi/font": "7.4.47", - "@tabler/core": "1.3.2", + "@tabler/core": "1.4.0", "bootstrap": "5.3.7", "clipboard": "2.0.11", "flatpickr": "4.6.13", - "gridstack": "12.2.1", - "htmx.org": "2.0.5", - "query-string": "9.2.1", + "gridstack": "12.2.2", + "htmx.org": "2.0.6", + "query-string": "9.2.2", "sass": "1.89.2", "tom-select": "2.4.3", "typeface-inter": "3.18.1", @@ -39,15 +39,15 @@ "@types/bootstrap": "5.2.10", "@types/cookie": "^0.6.0", "@types/node": "^22.3.0", - "@typescript-eslint/eslint-plugin": "^8.1.0", - "@typescript-eslint/parser": "^8.1.0", - "esbuild": "^0.25.3", + "@typescript-eslint/eslint-plugin": "^8.37.0", + "@typescript-eslint/parser": "^8.37.0", + "esbuild": "^0.25.6", "esbuild-sass-plugin": "^3.3.1", "eslint": "<9.0", "eslint-config-prettier": "^9.1.0", "eslint-import-resolver-typescript": "^3.6.3", - "eslint-plugin-import": "^2.30.0", - "eslint-plugin-prettier": "^5.2.1", + "eslint-plugin-import": "^2.32.0", + "eslint-plugin-prettier": "^5.5.1", "prettier": "^3.3.3", "typescript": "<5.5" }, diff --git a/netbox/project-static/src/colorMode.ts b/netbox/project-static/src/colorMode.ts index 453617740..1d05c955d 100644 --- a/netbox/project-static/src/colorMode.ts +++ b/netbox/project-static/src/colorMode.ts @@ -43,6 +43,11 @@ function updateElements(targetMode: ColorMode): void { export function setColorMode(mode: ColorMode): void { storeColorMode(mode); updateElements(mode); + window.dispatchEvent( + new CustomEvent('netbox.colorModeChanged', { + detail: { netboxColorMode: mode }, + }), + ); } /** diff --git a/netbox/project-static/src/global.d.ts b/netbox/project-static/src/global.d.ts index d7244339a..5b75be1ca 100644 --- a/netbox/project-static/src/global.d.ts +++ b/netbox/project-static/src/global.d.ts @@ -79,3 +79,6 @@ type FormControls = HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement; type ColorMode = 'light' | 'dark'; type ColorModePreference = ColorMode | 'none'; +type ColorModeData = { + netboxColorMode: ColorMode; +}; diff --git a/netbox/project-static/src/racks.ts b/netbox/project-static/src/racks.ts index cae9897fc..54b144363 100644 --- a/netbox/project-static/src/racks.ts +++ b/netbox/project-static/src/racks.ts @@ -35,7 +35,7 @@ function showRackElements( selector: string, elevation: HTMLObjectElement, ): void { - const elements = elevation.contentDocument?.querySelectorAll(selector) ?? []; + const elements = elevation.querySelectorAll(selector) ?? []; for (const element of elements) { element.classList.remove('hidden'); } @@ -45,7 +45,7 @@ function hideRackElements( selector: string, elevation: HTMLObjectElement, ): void { - const elements = elevation.contentDocument?.querySelectorAll(selector) ?? []; + const elements = elevation.querySelectorAll(selector) ?? []; for (const element of elements) { element.classList.add('hidden'); } diff --git a/netbox/project-static/styles/custom/_misc.scss b/netbox/project-static/styles/custom/_misc.scss index 2ba3262f6..5233616ac 100644 --- a/netbox/project-static/styles/custom/_misc.scss +++ b/netbox/project-static/styles/custom/_misc.scss @@ -81,6 +81,14 @@ img.plugin-icon { height: auto; } +// Image attachment thumbnails +.thumbnail { + max-width: 200px; + img { + border: 1px solid #606060; + } +} + body[data-bs-theme=dark] { // Assuming icon is black/white line art, invert it and tone down brightness img.plugin-icon { diff --git a/netbox/project-static/styles/custom/racks.scss b/netbox/project-static/styles/custom/racks.scss new file mode 100644 index 000000000..3dd20738d --- /dev/null +++ b/netbox/project-static/styles/custom/racks.scss @@ -0,0 +1,4 @@ +.rack-loading-container { + min-height: 200px; + margin-left: 30px; +} diff --git a/netbox/project-static/styles/netbox.scss b/netbox/project-static/styles/netbox.scss index ab7634cc1..6dbd34846 100644 --- a/netbox/project-static/styles/netbox.scss +++ b/netbox/project-static/styles/netbox.scss @@ -27,3 +27,4 @@ @import 'custom/markdown'; @import 'custom/misc'; @import 'custom/notifications'; +@import 'custom/racks'; diff --git a/netbox/project-static/yarn.lock b/netbox/project-static/yarn.lock index 4304afddb..ba9923943 100644 --- a/netbox/project-static/yarn.lock +++ b/netbox/project-static/yarn.lock @@ -19,139 +19,156 @@ resolved "https://registry.yarnpkg.com/@emotion/memoize/-/memoize-0.7.4.tgz#19bf0f5af19149111c40d98bb0cf82119f5d9eeb" integrity sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw== -"@esbuild/aix-ppc64@0.25.3": - version "0.25.3" - resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.3.tgz#014180d9a149cffd95aaeead37179433f5ea5437" - integrity sha512-W8bFfPA8DowP8l//sxjJLSLkD8iEjMc7cBVyP+u4cEv9sM7mdUCkgsj+t0n/BWPFtv7WWCN5Yzj0N6FJNUUqBQ== +"@esbuild/aix-ppc64@0.25.8": + version "0.25.8" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.8.tgz#a1414903bb38027382f85f03dda6065056757727" + integrity sha512-urAvrUedIqEiFR3FYSLTWQgLu5tb+m0qZw0NBEasUeo6wuqatkMDaRT+1uABiGXEu5vqgPd7FGE1BhsAIy9QVA== -"@esbuild/android-arm64@0.25.3": - version "0.25.3" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.25.3.tgz#649e47e04ddb24a27dc05c395724bc5f4c55cbfe" - integrity sha512-XelR6MzjlZuBM4f5z2IQHK6LkK34Cvv6Rj2EntER3lwCBFdg6h2lKbtRjpTTsdEjD/WSe1q8UyPBXP1x3i/wYQ== +"@esbuild/android-arm64@0.25.8": + version "0.25.8" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.25.8.tgz#c859994089e9767224269884061f89dae6fb51c6" + integrity sha512-OD3p7LYzWpLhZEyATcTSJ67qB5D+20vbtr6vHlHWSQYhKtzUYrETuWThmzFpZtFsBIxRvhO07+UgVA9m0i/O1w== -"@esbuild/android-arm@0.25.3": - version "0.25.3" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.25.3.tgz#8a0f719c8dc28a4a6567ef7328c36ea85f568ff4" - integrity sha512-PuwVXbnP87Tcff5I9ngV0lmiSu40xw1At6i3GsU77U7cjDDB4s0X2cyFuBiDa1SBk9DnvWwnGvVaGBqoFWPb7A== +"@esbuild/android-arm@0.25.8": + version "0.25.8" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.25.8.tgz#96a8f2ca91c6cd29ea90b1af79d83761c8ba0059" + integrity sha512-RONsAvGCz5oWyePVnLdZY/HHwA++nxYWIX1atInlaW6SEkwq6XkP3+cb825EUcRs5Vss/lGh/2YxAb5xqc07Uw== -"@esbuild/android-x64@0.25.3": - version "0.25.3" - resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.25.3.tgz#e2ab182d1fd06da9bef0784a13c28a7602d78009" - integrity sha512-ogtTpYHT/g1GWS/zKM0cc/tIebFjm1F9Aw1boQ2Y0eUQ+J89d0jFY//s9ei9jVIlkYi8AfOjiixcLJSGNSOAdQ== +"@esbuild/android-x64@0.25.8": + version "0.25.8" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.25.8.tgz#a3a626c4fec4a024a9fa8c7679c39996e92916f0" + integrity sha512-yJAVPklM5+4+9dTeKwHOaA+LQkmrKFX96BM0A/2zQrbS6ENCmxc4OVoBs5dPkCCak2roAD+jKCdnmOqKszPkjA== -"@esbuild/darwin-arm64@0.25.3": - version "0.25.3" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.3.tgz#c7f3166fcece4d158a73dcfe71b2672ca0b1668b" - integrity sha512-eESK5yfPNTqpAmDfFWNsOhmIOaQA59tAcF/EfYvo5/QWQCzXn5iUSOnqt3ra3UdzBv073ykTtmeLJZGt3HhA+w== +"@esbuild/darwin-arm64@0.25.8": + version "0.25.8" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.25.8.tgz#a5e1252ca2983d566af1c0ea39aded65736fc66d" + integrity sha512-Jw0mxgIaYX6R8ODrdkLLPwBqHTtYHJSmzzd+QeytSugzQ0Vg4c5rDky5VgkoowbZQahCbsv1rT1KW72MPIkevw== -"@esbuild/darwin-x64@0.25.3": - version "0.25.3" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.25.3.tgz#d8c5342ec1a4bf4b1915643dfe031ba4b173a87a" - integrity sha512-Kd8glo7sIZtwOLcPbW0yLpKmBNWMANZhrC1r6K++uDR2zyzb6AeOYtI6udbtabmQpFaxJ8uduXMAo1gs5ozz8A== +"@esbuild/darwin-x64@0.25.8": + version "0.25.8" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.25.8.tgz#5271b0df2bb12ce8df886704bfdd1c7cc01385d2" + integrity sha512-Vh2gLxxHnuoQ+GjPNvDSDRpoBCUzY4Pu0kBqMBDlK4fuWbKgGtmDIeEC081xi26PPjn+1tct+Bh8FjyLlw1Zlg== -"@esbuild/freebsd-arm64@0.25.3": - version "0.25.3" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.3.tgz#9f7d789e2eb7747d4868817417cc968ffa84f35b" - integrity sha512-EJiyS70BYybOBpJth3M0KLOus0n+RRMKTYzhYhFeMwp7e/RaajXvP+BWlmEXNk6uk+KAu46j/kaQzr6au+JcIw== +"@esbuild/freebsd-arm64@0.25.8": + version "0.25.8" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.8.tgz#d0a0e7fdf19733b8bb1566b81df1aa0bb7e46ada" + integrity sha512-YPJ7hDQ9DnNe5vxOm6jaie9QsTwcKedPvizTVlqWG9GBSq+BuyWEDazlGaDTC5NGU4QJd666V0yqCBL2oWKPfA== -"@esbuild/freebsd-x64@0.25.3": - version "0.25.3" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.3.tgz#8ad35c51d084184a8e9e76bb4356e95350a64709" - integrity sha512-Q+wSjaLpGxYf7zC0kL0nDlhsfuFkoN+EXrx2KSB33RhinWzejOd6AvgmP5JbkgXKmjhmpfgKZq24pneodYqE8Q== +"@esbuild/freebsd-x64@0.25.8": + version "0.25.8" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.8.tgz#2de8b2e0899d08f1cb1ef3128e159616e7e85343" + integrity sha512-MmaEXxQRdXNFsRN/KcIimLnSJrk2r5H8v+WVafRWz5xdSVmWLoITZQXcgehI2ZE6gioE6HirAEToM/RvFBeuhw== -"@esbuild/linux-arm64@0.25.3": - version "0.25.3" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.25.3.tgz#3af0da3d9186092a9edd4e28fa342f57d9e3cd30" - integrity sha512-xCUgnNYhRD5bb1C1nqrDV1PfkwgbswTTBRbAd8aH5PhYzikdf/ddtsYyMXFfGSsb/6t6QaPSzxtbfAZr9uox4A== +"@esbuild/linux-arm64@0.25.8": + version "0.25.8" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.25.8.tgz#a4209efadc0c2975716458484a4e90c237c48ae9" + integrity sha512-WIgg00ARWv/uYLU7lsuDK00d/hHSfES5BzdWAdAig1ioV5kaFNrtK8EqGcUBJhYqotlUByUKz5Qo6u8tt7iD/w== -"@esbuild/linux-arm@0.25.3": - version "0.25.3" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.25.3.tgz#e91cafa95e4474b3ae3d54da12e006b782e57225" - integrity sha512-dUOVmAUzuHy2ZOKIHIKHCm58HKzFqd+puLaS424h6I85GlSDRZIA5ycBixb3mFgM0Jdh+ZOSB6KptX30DD8YOQ== +"@esbuild/linux-arm@0.25.8": + version "0.25.8" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.25.8.tgz#ccd9e291c24cd8d9142d819d463e2e7200d25b19" + integrity sha512-FuzEP9BixzZohl1kLf76KEVOsxtIBFwCaLupVuk4eFVnOZfU+Wsn+x5Ryam7nILV2pkq2TqQM9EZPsOBuMC+kg== -"@esbuild/linux-ia32@0.25.3": - version "0.25.3" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.25.3.tgz#81025732d85b68ee510161b94acdf7e3007ea177" - integrity sha512-yplPOpczHOO4jTYKmuYuANI3WhvIPSVANGcNUeMlxH4twz/TeXuzEP41tGKNGWJjuMhotpGabeFYGAOU2ummBw== +"@esbuild/linux-ia32@0.25.8": + version "0.25.8" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.25.8.tgz#006ad1536d0c2b28fb3a1cf0b53bcb85aaf92c4d" + integrity sha512-A1D9YzRX1i+1AJZuFFUMP1E9fMaYY+GnSQil9Tlw05utlE86EKTUA7RjwHDkEitmLYiFsRd9HwKBPEftNdBfjg== -"@esbuild/linux-loong64@0.25.3": - version "0.25.3" - resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.25.3.tgz#3c744e4c8d5e1148cbe60a71a11b58ed8ee5deb8" - integrity sha512-P4BLP5/fjyihmXCELRGrLd793q/lBtKMQl8ARGpDxgzgIKJDRJ/u4r1A/HgpBpKpKZelGct2PGI4T+axcedf6g== +"@esbuild/linux-loong64@0.25.8": + version "0.25.8" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.25.8.tgz#127b3fbfb2c2e08b1397e985932f718f09a8f5c4" + integrity sha512-O7k1J/dwHkY1RMVvglFHl1HzutGEFFZ3kNiDMSOyUrB7WcoHGf96Sh+64nTRT26l3GMbCW01Ekh/ThKM5iI7hQ== -"@esbuild/linux-mips64el@0.25.3": - version "0.25.3" - resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.3.tgz#1dfe2a5d63702db9034cc6b10b3087cc0424ec26" - integrity sha512-eRAOV2ODpu6P5divMEMa26RRqb2yUoYsuQQOuFUexUoQndm4MdpXXDBbUoKIc0iPa4aCO7gIhtnYomkn2x+bag== +"@esbuild/linux-mips64el@0.25.8": + version "0.25.8" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.8.tgz#837d1449517791e3fa7d82675a2d06d9f56cb340" + integrity sha512-uv+dqfRazte3BzfMp8PAQXmdGHQt2oC/y2ovwpTteqrMx2lwaksiFZ/bdkXJC19ttTvNXBuWH53zy/aTj1FgGw== -"@esbuild/linux-ppc64@0.25.3": - version "0.25.3" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.3.tgz#2e85d9764c04a1ebb346dc0813ea05952c9a5c56" - integrity sha512-ZC4jV2p7VbzTlnl8nZKLcBkfzIf4Yad1SJM4ZMKYnJqZFD4rTI+pBG65u8ev4jk3/MPwY9DvGn50wi3uhdaghg== +"@esbuild/linux-ppc64@0.25.8": + version "0.25.8" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.8.tgz#aa2e3bd93ab8df084212f1895ca4b03c42d9e0fe" + integrity sha512-GyG0KcMi1GBavP5JgAkkstMGyMholMDybAf8wF5A70CALlDM2p/f7YFE7H92eDeH/VBtFJA5MT4nRPDGg4JuzQ== -"@esbuild/linux-riscv64@0.25.3": - version "0.25.3" - resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.3.tgz#a9ea3334556b09f85ccbfead58c803d305092415" - integrity sha512-LDDODcFzNtECTrUUbVCs6j9/bDVqy7DDRsuIXJg6so+mFksgwG7ZVnTruYi5V+z3eE5y+BJZw7VvUadkbfg7QA== +"@esbuild/linux-riscv64@0.25.8": + version "0.25.8" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.8.tgz#a340620e31093fef72767dd28ab04214b3442083" + integrity sha512-rAqDYFv3yzMrq7GIcen3XP7TUEG/4LK86LUPMIz6RT8A6pRIDn0sDcvjudVZBiiTcZCY9y2SgYX2lgK3AF+1eg== -"@esbuild/linux-s390x@0.25.3": - version "0.25.3" - resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.25.3.tgz#f6a7cb67969222b200974de58f105dfe8e99448d" - integrity sha512-s+w/NOY2k0yC2p9SLen+ymflgcpRkvwwa02fqmAwhBRI3SC12uiS10edHHXlVWwfAagYSY5UpmT/zISXPMW3tQ== +"@esbuild/linux-s390x@0.25.8": + version "0.25.8" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.25.8.tgz#ddfed266c8c13f5efb3105a0cd47f6dcd0e79e71" + integrity sha512-Xutvh6VjlbcHpsIIbwY8GVRbwoviWT19tFhgdA7DlenLGC/mbc3lBoVb7jxj9Z+eyGqvcnSyIltYUrkKzWqSvg== -"@esbuild/linux-x64@0.25.3": - version "0.25.3" - resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.25.3.tgz#a237d3578ecdd184a3066b1f425e314ade0f8033" - integrity sha512-nQHDz4pXjSDC6UfOE1Fw9Q8d6GCAd9KdvMZpfVGWSJztYCarRgSDfOVBY5xwhQXseiyxapkiSJi/5/ja8mRFFA== +"@esbuild/linux-x64@0.25.8": + version "0.25.8" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.25.8.tgz#9a4f78c75c051e8c060183ebb39a269ba936a2ac" + integrity sha512-ASFQhgY4ElXh3nDcOMTkQero4b1lgubskNlhIfJrsH5OKZXDpUAKBlNS0Kx81jwOBp+HCeZqmoJuihTv57/jvQ== -"@esbuild/netbsd-arm64@0.25.3": - version "0.25.3" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.3.tgz#4c15c68d8149614ddb6a56f9c85ae62ccca08259" - integrity sha512-1QaLtOWq0mzK6tzzp0jRN3eccmN3hezey7mhLnzC6oNlJoUJz4nym5ZD7mDnS/LZQgkrhEbEiTn515lPeLpgWA== +"@esbuild/netbsd-arm64@0.25.8": + version "0.25.8" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.8.tgz#902c80e1d678047926387230bc037e63e00697d0" + integrity sha512-d1KfruIeohqAi6SA+gENMuObDbEjn22olAR7egqnkCD9DGBG0wsEARotkLgXDu6c4ncgWTZJtN5vcgxzWRMzcw== -"@esbuild/netbsd-x64@0.25.3": - version "0.25.3" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.3.tgz#12f6856f8c54c2d7d0a8a64a9711c01a743878d5" - integrity sha512-i5Hm68HXHdgv8wkrt+10Bc50zM0/eonPb/a/OFVfB6Qvpiirco5gBA5bz7S2SHuU+Y4LWn/zehzNX14Sp4r27g== +"@esbuild/netbsd-x64@0.25.8": + version "0.25.8" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.8.tgz#2d9eb4692add2681ff05a14ce99de54fbed7079c" + integrity sha512-nVDCkrvx2ua+XQNyfrujIG38+YGyuy2Ru9kKVNyh5jAys6n+l44tTtToqHjino2My8VAY6Lw9H7RI73XFi66Cg== -"@esbuild/openbsd-arm64@0.25.3": - version "0.25.3" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.3.tgz#ca078dad4a34df192c60233b058db2ca3d94bc5c" - integrity sha512-zGAVApJEYTbOC6H/3QBr2mq3upG/LBEXr85/pTtKiv2IXcgKV0RT0QA/hSXZqSvLEpXeIxah7LczB4lkiYhTAQ== +"@esbuild/openbsd-arm64@0.25.8": + version "0.25.8" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.8.tgz#89c3b998c6de739db38ab7fb71a8a76b3fa84a45" + integrity sha512-j8HgrDuSJFAujkivSMSfPQSAa5Fxbvk4rgNAS5i3K+r8s1X0p1uOO2Hl2xNsGFppOeHOLAVgYwDVlmxhq5h+SQ== -"@esbuild/openbsd-x64@0.25.3": - version "0.25.3" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.3.tgz#c9178adb60e140e03a881d0791248489c79f95b2" - integrity sha512-fpqctI45NnCIDKBH5AXQBsD0NDPbEFczK98hk/aa6HJxbl+UtLkJV2+Bvy5hLSLk3LHmqt0NTkKNso1A9y1a4w== +"@esbuild/openbsd-x64@0.25.8": + version "0.25.8" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.8.tgz#2f01615cf472b0e48c077045cfd96b5c149365cc" + integrity sha512-1h8MUAwa0VhNCDp6Af0HToI2TJFAn1uqT9Al6DJVzdIBAd21m/G0Yfc77KDM3uF3T/YaOgQq3qTJHPbTOInaIQ== -"@esbuild/sunos-x64@0.25.3": - version "0.25.3" - resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.25.3.tgz#03765eb6d4214ff27e5230af779e80790d1ee09f" - integrity sha512-ROJhm7d8bk9dMCUZjkS8fgzsPAZEjtRJqCAmVgB0gMrvG7hfmPmz9k1rwO4jSiblFjYmNvbECL9uhaPzONMfgA== +"@esbuild/openharmony-arm64@0.25.8": + version "0.25.8" + resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.8.tgz#a201f720cd2c3ebf9a6033fcc3feb069a54b509a" + integrity sha512-r2nVa5SIK9tSWd0kJd9HCffnDHKchTGikb//9c7HX+r+wHYCpQrSgxhlY6KWV1nFo1l4KFbsMlHk+L6fekLsUg== -"@esbuild/win32-arm64@0.25.3": - version "0.25.3" - resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.25.3.tgz#f1c867bd1730a9b8dfc461785ec6462e349411ea" - integrity sha512-YWcow8peiHpNBiIXHwaswPnAXLsLVygFwCB3A7Bh5jRkIBFWHGmNQ48AlX4xDvQNoMZlPYzjVOQDYEzWCqufMQ== +"@esbuild/sunos-x64@0.25.8": + version "0.25.8" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.25.8.tgz#07046c977985a3334667f19e6ab3a01a80862afb" + integrity sha512-zUlaP2S12YhQ2UzUfcCuMDHQFJyKABkAjvO5YSndMiIkMimPmxA+BYSBikWgsRpvyxuRnow4nS5NPnf9fpv41w== -"@esbuild/win32-ia32@0.25.3": - version "0.25.3" - resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.25.3.tgz#77491f59ef6c9ddf41df70670d5678beb3acc322" - integrity sha512-qspTZOIGoXVS4DpNqUYUs9UxVb04khS1Degaw/MnfMe7goQ3lTfQ13Vw4qY/Nj0979BGvMRpAYbs/BAxEvU8ew== +"@esbuild/win32-arm64@0.25.8": + version "0.25.8" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.25.8.tgz#4a5470caf0d16127c05d4833d4934213c69392d1" + integrity sha512-YEGFFWESlPva8hGL+zvj2z/SaK+pH0SwOM0Nc/d+rVnW7GSTFlLBGzZkuSU9kFIGIo8q9X3ucpZhu8PDN5A2sQ== -"@esbuild/win32-x64@0.25.3": - version "0.25.3" - resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.25.3.tgz#b17a2171f9074df9e91bfb07ef99a892ac06412a" - integrity sha512-ICgUR+kPimx0vvRzf+N/7L7tVSQeE3BYY+NhHRHXS1kBuPO7z2+7ea2HbhDyZdTephgvNvKrlDDKUexuCVBVvg== +"@esbuild/win32-ia32@0.25.8": + version "0.25.8" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.25.8.tgz#3de3e8470b7b328d99dbc3e9ec1eace207e5bbc4" + integrity sha512-hiGgGC6KZ5LZz58OL/+qVVoZiuZlUYlYHNAmczOm7bs2oE1XriPFi5ZHHrS8ACpV5EjySrnoCKmcbQMN+ojnHg== -"@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.4.0": +"@esbuild/win32-x64@0.25.8": + version "0.25.8" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.25.8.tgz#610d7ea539d2fcdbe39237b5cc175eb2c4451f9c" + integrity sha512-cn3Yr7+OaaZq1c+2pe+8yxC8E144SReCQjN6/2ynubzYjvyqZjTXfQJpAcQpsdJq3My7XADANiYGHoFC69pLQw== + +"@eslint-community/eslint-utils@^4.2.0": version "4.4.0" resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz#a23514e8fb9af1269d5f7788aa556798d61c6b59" integrity sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA== dependencies: eslint-visitor-keys "^3.3.0" -"@eslint-community/regexpp@^4.10.0", "@eslint-community/regexpp@^4.6.1": +"@eslint-community/eslint-utils@^4.7.0": + version "4.7.0" + resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz#607084630c6c033992a082de6e6fbc1a8b52175a" + integrity sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw== + dependencies: + eslint-visitor-keys "^3.4.3" + +"@eslint-community/regexpp@^4.10.0": + version "4.12.1" + resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.1.tgz#cfc6cffe39df390a3841cde2abccf92eaa7ae0e0" + integrity sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ== + +"@eslint-community/regexpp@^4.6.1": version "4.11.0" resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.11.0.tgz#b0ffd0312b4a3fd2d6f77237e7248a5ad3a680ae" integrity sha512-G/M/tIiMrTAxEWRfLfQJMmGNX28IxBg4PBz8XqQhqUHLFI6TL2htpIB1iQCj144V5ee/JaKyT9/WZ0MGZWfA7A== @@ -452,10 +469,10 @@ "@parcel/watcher-win32-ia32" "2.4.1" "@parcel/watcher-win32-x64" "2.4.1" -"@pkgr/core@^0.1.0": - version "0.1.1" - resolved "https://registry.yarnpkg.com/@pkgr/core/-/core-0.1.1.tgz#1ec17e2edbec25c8306d424ecfbf13c7de1aaa31" - integrity sha512-cq8o4cWH0ibXh9VGi5P20Tu9XF/0fFXl9EUinr9QfTM7a7p0oTA4iJRCQWppXR1Pg8dSM0UCItCkPwsk9qWWYA== +"@pkgr/core@^0.2.9": + version "0.2.9" + resolved "https://registry.yarnpkg.com/@pkgr/core/-/core-0.2.9.tgz#d229a7b7f9dac167a156992ef23c7f023653f53b" + integrity sha512-QNqXyfVS2wm9hweSYD2O7F0G06uurj9kZ96TRQE5Y9hU7+tgdZwIkbAKc5Ocy1HxEY2kuDQa6cQ1WRs/O5LFKA== "@popperjs/core@^2.11.6", "@popperjs/core@^2.11.8", "@popperjs/core@^2.9.2": version "2.11.8" @@ -757,13 +774,13 @@ resolved "https://registry.yarnpkg.com/@rtsao/scc/-/scc-1.1.0.tgz#927dd2fae9bc3361403ac2c7a00c32ddce9ad7e8" integrity sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g== -"@tabler/core@1.3.2": - version "1.3.2" - resolved "https://registry.yarnpkg.com/@tabler/core/-/core-1.3.2.tgz#85e4a47b661bca4cd50e26039fc25c4bdb4aff34" - integrity sha512-QDVJbv48YJrahBLdxYkLi6NutQv4jGbkUWyzxE2NcNJ3s3GGpRx98JmbAoN92NZKNmf26vZdW6k2Q5haVKlS4A== +"@tabler/core@1.4.0": + version "1.4.0" + resolved "https://registry.yarnpkg.com/@tabler/core/-/core-1.4.0.tgz#9e011289d92cf6120655793cef6576fd67eb07f3" + integrity sha512-5BigzOlbOH9N0Is4u0rYNRCiwtnUXWO57K9zwuscygcicAa8UV9MGaS4zTgQsZEtZ9tsNANhN/YD8gCBGKYCiw== dependencies: "@popperjs/core" "^2.11.8" - bootstrap "5.3.6" + bootstrap "5.3.7" "@tanstack/react-virtual@^3.0.0-beta.60": version "3.5.0" @@ -827,86 +844,103 @@ dependencies: "@types/estree" "*" -"@typescript-eslint/eslint-plugin@^8.1.0": - version "8.1.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.1.0.tgz#3c020deeaaba82a6f741d00dacf172c53be4911f" - integrity sha512-LlNBaHFCEBPHyD4pZXb35mzjGkuGKXU5eeCA1SxvHfiRES0E82dOounfVpL4DCqYvJEKab0bZIA0gCRpdLKkCw== +"@typescript-eslint/eslint-plugin@^8.37.0": + version "8.38.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.38.0.tgz#6e5220d16f2691ab6d983c1737dd5b36e17641b7" + integrity sha512-CPoznzpuAnIOl4nhj4tRr4gIPj5AfKgkiJmGQDaq+fQnRJTYlcBjbX3wbciGmpoPf8DREufuPRe1tNMZnGdanA== dependencies: "@eslint-community/regexpp" "^4.10.0" - "@typescript-eslint/scope-manager" "8.1.0" - "@typescript-eslint/type-utils" "8.1.0" - "@typescript-eslint/utils" "8.1.0" - "@typescript-eslint/visitor-keys" "8.1.0" + "@typescript-eslint/scope-manager" "8.38.0" + "@typescript-eslint/type-utils" "8.38.0" + "@typescript-eslint/utils" "8.38.0" + "@typescript-eslint/visitor-keys" "8.38.0" graphemer "^1.4.0" - ignore "^5.3.1" + ignore "^7.0.0" natural-compare "^1.4.0" - ts-api-utils "^1.3.0" + ts-api-utils "^2.1.0" -"@typescript-eslint/parser@^8.1.0": - version "8.1.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.1.0.tgz#b7e77f5fa212df59eba51ecd4986f194bccc2303" - integrity sha512-U7iTAtGgJk6DPX9wIWPPOlt1gO57097G06gIcl0N0EEnNw8RGD62c+2/DiP/zL7KrkqnnqF7gtFGR7YgzPllTA== +"@typescript-eslint/parser@^8.37.0": + version "8.38.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.38.0.tgz#6723a5ea881e1777956b1045cba30be5ea838293" + integrity sha512-Zhy8HCvBUEfBECzIl1PKqF4p11+d0aUJS1GeUiuqK9WmOug8YCmC4h4bjyBvMyAMI9sbRczmrYL5lKg/YMbrcQ== dependencies: - "@typescript-eslint/scope-manager" "8.1.0" - "@typescript-eslint/types" "8.1.0" - "@typescript-eslint/typescript-estree" "8.1.0" - "@typescript-eslint/visitor-keys" "8.1.0" + "@typescript-eslint/scope-manager" "8.38.0" + "@typescript-eslint/types" "8.38.0" + "@typescript-eslint/typescript-estree" "8.38.0" + "@typescript-eslint/visitor-keys" "8.38.0" debug "^4.3.4" -"@typescript-eslint/scope-manager@8.1.0": - version "8.1.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.1.0.tgz#dd8987d2efebb71d230a1c71d82e84a7aead5c3d" - integrity sha512-DsuOZQji687sQUjm4N6c9xABJa7fjvfIdjqpSIIVOgaENf2jFXiM9hIBZOL3hb6DHK9Nvd2d7zZnoMLf9e0OtQ== +"@typescript-eslint/project-service@8.38.0": + version "8.38.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.38.0.tgz#4900771f943163027fd7d2020a062892056b5e2f" + integrity sha512-dbK7Jvqcb8c9QfH01YB6pORpqX1mn5gDZc9n63Ak/+jD67oWXn3Gs0M6vddAN+eDXBCS5EmNWzbSxsn9SzFWWg== dependencies: - "@typescript-eslint/types" "8.1.0" - "@typescript-eslint/visitor-keys" "8.1.0" - -"@typescript-eslint/type-utils@8.1.0": - version "8.1.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.1.0.tgz#dbf5a4308166dfc37a36305390dea04a3a3b5048" - integrity sha512-oLYvTxljVvsMnldfl6jIKxTaU7ok7km0KDrwOt1RHYu6nxlhN3TIx8k5Q52L6wR33nOwDgM7VwW1fT1qMNfFIA== - dependencies: - "@typescript-eslint/typescript-estree" "8.1.0" - "@typescript-eslint/utils" "8.1.0" + "@typescript-eslint/tsconfig-utils" "^8.38.0" + "@typescript-eslint/types" "^8.38.0" debug "^4.3.4" - ts-api-utils "^1.3.0" -"@typescript-eslint/types@8.1.0": - version "8.1.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.1.0.tgz#fbf1eaa668a7e444ac507732ca9d3c3468e5db9c" - integrity sha512-q2/Bxa0gMOu/2/AKALI0tCKbG2zppccnRIRCW6BaaTlRVaPKft4oVYPp7WOPpcnsgbr0qROAVCVKCvIQ0tbWog== - -"@typescript-eslint/typescript-estree@8.1.0": - version "8.1.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.1.0.tgz#c44e5667683c0bb5caa43192e27de6a994f4e4c4" - integrity sha512-NTHhmufocEkMiAord/g++gWKb0Fr34e9AExBRdqgWdVBaKoei2dIyYKD9Q0jBnvfbEA5zaf8plUFMUH6kQ0vGg== +"@typescript-eslint/scope-manager@8.38.0": + version "8.38.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.38.0.tgz#5a0efcb5c9cf6e4121b58f87972f567c69529226" + integrity sha512-WJw3AVlFFcdT9Ri1xs/lg8LwDqgekWXWhH3iAF+1ZM+QPd7oxQ6jvtW/JPwzAScxitILUIFs0/AnQ/UWHzbATQ== dependencies: - "@typescript-eslint/types" "8.1.0" - "@typescript-eslint/visitor-keys" "8.1.0" + "@typescript-eslint/types" "8.38.0" + "@typescript-eslint/visitor-keys" "8.38.0" + +"@typescript-eslint/tsconfig-utils@8.38.0", "@typescript-eslint/tsconfig-utils@^8.38.0": + version "8.38.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.38.0.tgz#6de4ce224a779601a8df667db56527255c42c4d0" + integrity sha512-Lum9RtSE3EroKk/bYns+sPOodqb2Fv50XOl/gMviMKNvanETUuUcC9ObRbzrJ4VSd2JalPqgSAavwrPiPvnAiQ== + +"@typescript-eslint/type-utils@8.38.0": + version "8.38.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.38.0.tgz#a56cd84765fa6ec135fe252b5db61e304403a85b" + integrity sha512-c7jAvGEZVf0ao2z+nnz8BUaHZD09Agbh+DY7qvBQqLiz8uJzRgVPj5YvOh8I8uEiH8oIUGIfHzMwUcGVco/SJg== + dependencies: + "@typescript-eslint/types" "8.38.0" + "@typescript-eslint/typescript-estree" "8.38.0" + "@typescript-eslint/utils" "8.38.0" debug "^4.3.4" - globby "^11.1.0" + ts-api-utils "^2.1.0" + +"@typescript-eslint/types@8.38.0", "@typescript-eslint/types@^8.38.0": + version "8.38.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.38.0.tgz#297351c994976b93c82ac0f0e206c8143aa82529" + integrity sha512-wzkUfX3plUqij4YwWaJyqhiPE5UCRVlFpKn1oCRn2O1bJ592XxWJj8ROQ3JD5MYXLORW84063z3tZTb/cs4Tyw== + +"@typescript-eslint/typescript-estree@8.38.0": + version "8.38.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.38.0.tgz#82262199eb6778bba28a319e25ad05b1158957df" + integrity sha512-fooELKcAKzxux6fA6pxOflpNS0jc+nOQEEOipXFNjSlBS6fqrJOVY/whSn70SScHrcJ2LDsxWrneFoWYSVfqhQ== + dependencies: + "@typescript-eslint/project-service" "8.38.0" + "@typescript-eslint/tsconfig-utils" "8.38.0" + "@typescript-eslint/types" "8.38.0" + "@typescript-eslint/visitor-keys" "8.38.0" + debug "^4.3.4" + fast-glob "^3.3.2" is-glob "^4.0.3" minimatch "^9.0.4" semver "^7.6.0" - ts-api-utils "^1.3.0" + ts-api-utils "^2.1.0" -"@typescript-eslint/utils@8.1.0": - version "8.1.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.1.0.tgz#a922985a43d2560ce0d293be79148fa80c1325e0" - integrity sha512-ypRueFNKTIFwqPeJBfeIpxZ895PQhNyH4YID6js0UoBImWYoSjBsahUn9KMiJXh94uOjVBgHD9AmkyPsPnFwJA== +"@typescript-eslint/utils@8.38.0": + version "8.38.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.38.0.tgz#5f10159899d30eb92ba70e642ca6f754bddbf15a" + integrity sha512-hHcMA86Hgt+ijJlrD8fX0j1j8w4C92zue/8LOPAFioIno+W0+L7KqE8QZKCcPGc/92Vs9x36w/4MPTJhqXdyvg== dependencies: - "@eslint-community/eslint-utils" "^4.4.0" - "@typescript-eslint/scope-manager" "8.1.0" - "@typescript-eslint/types" "8.1.0" - "@typescript-eslint/typescript-estree" "8.1.0" + "@eslint-community/eslint-utils" "^4.7.0" + "@typescript-eslint/scope-manager" "8.38.0" + "@typescript-eslint/types" "8.38.0" + "@typescript-eslint/typescript-estree" "8.38.0" -"@typescript-eslint/visitor-keys@8.1.0": - version "8.1.0" - resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.1.0.tgz#ab2b3a9699a8ddebf0c205e133f114c1fed9daad" - integrity sha512-ba0lNI19awqZ5ZNKh6wCModMwoZs457StTebQ0q1NP58zSi2F6MOZRXwfKZy+jB78JNJ/WH8GSh2IQNzXX8Nag== +"@typescript-eslint/visitor-keys@8.38.0": + version "8.38.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.38.0.tgz#a9765a527b082cb8fc60fd8a16e47c7ad5b60ea5" + integrity sha512-pWrTcoFNWuwHlA9CvlfSsGWs14JxfN1TH25zM5L7o0pRLhsoZkDnTsXfQRJBEWJoV5DL0jf+Z+sxiud+K0mq1g== dependencies: - "@typescript-eslint/types" "8.1.0" - eslint-visitor-keys "^3.4.3" + "@typescript-eslint/types" "8.38.0" + eslint-visitor-keys "^4.2.1" "@ungap/structured-clone@^1.2.0": version "1.2.0" @@ -973,54 +1007,60 @@ array-buffer-byte-length@^1.0.1: call-bind "^1.0.5" is-array-buffer "^3.0.4" -array-includes@^3.1.8: - version "3.1.8" - resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.8.tgz#5e370cbe172fdd5dd6530c1d4aadda25281ba97d" - integrity sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ== +array-buffer-byte-length@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz#384d12a37295aec3769ab022ad323a18a51ccf8b" + integrity sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw== dependencies: - call-bind "^1.0.7" - define-properties "^1.2.1" - es-abstract "^1.23.2" - es-object-atoms "^1.0.0" - get-intrinsic "^1.2.4" - is-string "^1.0.7" + call-bound "^1.0.3" + is-array-buffer "^3.0.5" -array-union@^2.1.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/array-union/-/array-union-2.1.0.tgz#b798420adbeb1de828d84acd8a2e23d3efe85e8d" - integrity sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw== - -array.prototype.findlastindex@^1.2.5: - version "1.2.5" - resolved "https://registry.yarnpkg.com/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.5.tgz#8c35a755c72908719453f87145ca011e39334d0d" - integrity sha512-zfETvRFA8o7EiNn++N5f/kaCw221hrpGsDmcpndVupkPzEc1Wuf3VgC0qby1BbHs7f5DVYjgtEU2LLh5bqeGfQ== +array-includes@^3.1.9: + version "3.1.9" + resolved "https://registry.yarnpkg.com/array-includes/-/array-includes-3.1.9.tgz#1f0ccaa08e90cdbc3eb433210f903ad0f17c3f3a" + integrity sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ== dependencies: - call-bind "^1.0.7" + call-bind "^1.0.8" + call-bound "^1.0.4" define-properties "^1.2.1" - es-abstract "^1.23.2" + es-abstract "^1.24.0" + es-object-atoms "^1.1.1" + get-intrinsic "^1.3.0" + is-string "^1.1.1" + math-intrinsics "^1.1.0" + +array.prototype.findlastindex@^1.2.6: + version "1.2.6" + resolved "https://registry.yarnpkg.com/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz#cfa1065c81dcb64e34557c9b81d012f6a421c564" + integrity sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.4" + define-properties "^1.2.1" + es-abstract "^1.23.9" es-errors "^1.3.0" - es-object-atoms "^1.0.0" + es-object-atoms "^1.1.1" + es-shim-unscopables "^1.1.0" + +array.prototype.flat@^1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz#534aaf9e6e8dd79fb6b9a9917f839ef1ec63afe5" + integrity sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg== + dependencies: + call-bind "^1.0.8" + define-properties "^1.2.1" + es-abstract "^1.23.5" es-shim-unscopables "^1.0.2" -array.prototype.flat@^1.3.2: - version "1.3.2" - resolved "https://registry.yarnpkg.com/array.prototype.flat/-/array.prototype.flat-1.3.2.tgz#1476217df8cff17d72ee8f3ba06738db5b387d18" - integrity sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA== +array.prototype.flatmap@^1.3.3: + version "1.3.3" + resolved "https://registry.yarnpkg.com/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz#712cc792ae70370ae40586264629e33aab5dd38b" + integrity sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg== dependencies: - call-bind "^1.0.2" - define-properties "^1.2.0" - es-abstract "^1.22.1" - es-shim-unscopables "^1.0.0" - -array.prototype.flatmap@^1.3.2: - version "1.3.2" - resolved "https://registry.yarnpkg.com/array.prototype.flatmap/-/array.prototype.flatmap-1.3.2.tgz#c9a7c6831db8e719d6ce639190146c24bbd3e527" - integrity sha512-Ewyx0c9PmpcsByhSW4r+9zDU7sGjFc86qf/kKtuSCRdhfbk0SNLLkaT5qvcHnRGgc5NP/ly/y+qkXkqONX54CQ== - dependencies: - call-bind "^1.0.2" - define-properties "^1.2.0" - es-abstract "^1.22.1" - es-shim-unscopables "^1.0.0" + call-bind "^1.0.8" + define-properties "^1.2.1" + es-abstract "^1.23.5" + es-shim-unscopables "^1.0.2" arraybuffer.prototype.slice@^1.0.3: version "1.0.3" @@ -1036,6 +1076,24 @@ arraybuffer.prototype.slice@^1.0.3: is-array-buffer "^3.0.4" is-shared-array-buffer "^1.0.2" +arraybuffer.prototype.slice@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz#9d760d84dbdd06d0cbf92c8849615a1a7ab3183c" + integrity sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ== + dependencies: + array-buffer-byte-length "^1.0.1" + call-bind "^1.0.8" + define-properties "^1.2.1" + es-abstract "^1.23.5" + es-errors "^1.3.0" + get-intrinsic "^1.2.6" + is-array-buffer "^3.0.4" + +async-function@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/async-function/-/async-function-1.0.0.tgz#509c9fca60eaf85034c6829838188e4e4c8ffb2b" + integrity sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA== + available-typed-arrays@^1.0.7: version "1.0.7" resolved "https://registry.yarnpkg.com/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz#a5cc375d6a03c2efc87a553f3e0b1522def14846" @@ -1053,11 +1111,6 @@ binary-extensions@^2.0.0: resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.3.0.tgz#f6e14a97858d327252200242d4ccfe522c445522" integrity sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw== -bootstrap@5.3.6: - version "5.3.6" - resolved "https://registry.yarnpkg.com/bootstrap/-/bootstrap-5.3.6.tgz#fbd91ebaff093f5b191a1c01a8c866d24f9fa6e1" - integrity sha512-jX0GAcRzvdwISuvArXn3m7KZscWWFAf1MKBcnzaN02qWMb3jpMoUX4/qgeiGzqyIb4ojulRzs89UCUmGcFSzTA== - bootstrap@5.3.7: version "5.3.7" resolved "https://registry.yarnpkg.com/bootstrap/-/bootstrap-5.3.7.tgz#8640065036124d961d885d80b5945745e1154d90" @@ -1072,9 +1125,9 @@ brace-expansion@^1.1.7: concat-map "0.0.1" brace-expansion@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.1.tgz#1edc459e0f0c548486ecf9fc99f2221364b9a0ae" - integrity sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA== + version "2.0.2" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-2.0.2.tgz#54fc53237a613d854c7bd37463aad17df87214e7" + integrity sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ== dependencies: balanced-match "^1.0.0" @@ -1085,6 +1138,14 @@ braces@^3.0.3, braces@~3.0.2: dependencies: fill-range "^7.1.1" +call-bind-apply-helpers@^1.0.0, call-bind-apply-helpers@^1.0.1, call-bind-apply-helpers@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz#4b5428c222be985d79c3d82657479dbe0b59b2d6" + integrity sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ== + dependencies: + es-errors "^1.3.0" + function-bind "^1.1.2" + call-bind@^1.0.2, call-bind@^1.0.5, call-bind@^1.0.6, call-bind@^1.0.7: version "1.0.7" resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.7.tgz#06016599c40c56498c18769d2730be242b6fa3b9" @@ -1096,6 +1157,24 @@ call-bind@^1.0.2, call-bind@^1.0.5, call-bind@^1.0.6, call-bind@^1.0.7: get-intrinsic "^1.2.4" set-function-length "^1.2.1" +call-bind@^1.0.8: + version "1.0.8" + resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.8.tgz#0736a9660f537e3388826f440d5ec45f744eaa4c" + integrity sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww== + dependencies: + call-bind-apply-helpers "^1.0.0" + es-define-property "^1.0.0" + get-intrinsic "^1.2.4" + set-function-length "^1.2.2" + +call-bound@^1.0.2, call-bound@^1.0.3, call-bound@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/call-bound/-/call-bound-1.0.4.tgz#238de935d2a2a692928c538c7ccfa91067fd062a" + integrity sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg== + dependencies: + call-bind-apply-helpers "^1.0.2" + get-intrinsic "^1.3.0" + callsites@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" @@ -1205,6 +1284,15 @@ data-view-buffer@^1.0.1: es-errors "^1.3.0" is-data-view "^1.0.1" +data-view-buffer@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/data-view-buffer/-/data-view-buffer-1.0.2.tgz#211a03ba95ecaf7798a8c7198d79536211f88570" + integrity sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ== + dependencies: + call-bound "^1.0.3" + es-errors "^1.3.0" + is-data-view "^1.0.2" + data-view-byte-length@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/data-view-byte-length/-/data-view-byte-length-1.0.1.tgz#90721ca95ff280677eb793749fce1011347669e2" @@ -1214,6 +1302,15 @@ data-view-byte-length@^1.0.1: es-errors "^1.3.0" is-data-view "^1.0.1" +data-view-byte-length@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz#9e80f7ca52453ce3e93d25a35318767ea7704735" + integrity sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ== + dependencies: + call-bound "^1.0.3" + es-errors "^1.3.0" + is-data-view "^1.0.2" + data-view-byte-offset@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/data-view-byte-offset/-/data-view-byte-offset-1.0.0.tgz#5e0bbfb4828ed2d1b9b400cd8a7d119bca0ff18a" @@ -1223,6 +1320,15 @@ data-view-byte-offset@^1.0.0: es-errors "^1.3.0" is-data-view "^1.0.1" +data-view-byte-offset@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz#068307f9b71ab76dbbe10291389e020856606191" + integrity sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + is-data-view "^1.0.1" + debounce-promise@^3.1.2: version "3.1.2" resolved "https://registry.yarnpkg.com/debounce-promise/-/debounce-promise-3.1.2.tgz#320fb8c7d15a344455cd33cee5ab63530b6dc7c5" @@ -1243,11 +1349,11 @@ debug@^4.3.1, debug@^4.3.2, debug@^4.3.5: ms "2.1.2" debug@^4.3.4: - version "4.3.4" - resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" - integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== + version "4.4.1" + resolved "https://registry.yarnpkg.com/debug/-/debug-4.4.1.tgz#e5a8bc6cbc4c6cd3e64308b0693a3d4fa550189b" + integrity sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ== dependencies: - ms "2.1.2" + ms "^2.1.3" decode-uri-component@^0.4.1: version "0.4.1" @@ -1292,13 +1398,6 @@ detect-node-es@^1.1.0: resolved "https://registry.yarnpkg.com/detect-node-es/-/detect-node-es-1.1.0.tgz#163acdf643330caa0b4cd7c21e7ee7755d6fa493" integrity sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ== -dir-glob@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f" - integrity sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA== - dependencies: - path-type "^4.0.0" - doctrine@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/doctrine/-/doctrine-2.1.0.tgz#5cd01fc101621b42c4cd7f5d1a66243716d3f39d" @@ -1313,6 +1412,15 @@ doctrine@^3.0.0: dependencies: esutils "^2.0.2" +dunder-proto@^1.0.0, dunder-proto@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/dunder-proto/-/dunder-proto-1.0.1.tgz#d7ae667e1dc83482f8b70fd0f6eefc50da30f58a" + integrity sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A== + dependencies: + call-bind-apply-helpers "^1.0.1" + es-errors "^1.3.0" + gopd "^1.2.0" + enhanced-resolve@^5.15.0: version "5.17.1" resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz#67bfbbcc2f81d511be77d686a90267ef7f898a15" @@ -1378,6 +1486,66 @@ es-abstract@^1.22.1, es-abstract@^1.22.3, es-abstract@^1.23.0, es-abstract@^1.23 unbox-primitive "^1.0.2" which-typed-array "^1.1.15" +es-abstract@^1.23.5, es-abstract@^1.23.9, es-abstract@^1.24.0: + version "1.24.0" + resolved "https://registry.yarnpkg.com/es-abstract/-/es-abstract-1.24.0.tgz#c44732d2beb0acc1ed60df840869e3106e7af328" + integrity sha512-WSzPgsdLtTcQwm4CROfS5ju2Wa1QQcVeT37jFjYzdFz1r9ahadC8B8/a4qxJxM+09F18iumCdRmlr96ZYkQvEg== + dependencies: + array-buffer-byte-length "^1.0.2" + arraybuffer.prototype.slice "^1.0.4" + available-typed-arrays "^1.0.7" + call-bind "^1.0.8" + call-bound "^1.0.4" + data-view-buffer "^1.0.2" + data-view-byte-length "^1.0.2" + data-view-byte-offset "^1.0.1" + es-define-property "^1.0.1" + es-errors "^1.3.0" + es-object-atoms "^1.1.1" + es-set-tostringtag "^2.1.0" + es-to-primitive "^1.3.0" + function.prototype.name "^1.1.8" + get-intrinsic "^1.3.0" + get-proto "^1.0.1" + get-symbol-description "^1.1.0" + globalthis "^1.0.4" + gopd "^1.2.0" + has-property-descriptors "^1.0.2" + has-proto "^1.2.0" + has-symbols "^1.1.0" + hasown "^2.0.2" + internal-slot "^1.1.0" + is-array-buffer "^3.0.5" + is-callable "^1.2.7" + is-data-view "^1.0.2" + is-negative-zero "^2.0.3" + is-regex "^1.2.1" + is-set "^2.0.3" + is-shared-array-buffer "^1.0.4" + is-string "^1.1.1" + is-typed-array "^1.1.15" + is-weakref "^1.1.1" + math-intrinsics "^1.1.0" + object-inspect "^1.13.4" + object-keys "^1.1.1" + object.assign "^4.1.7" + own-keys "^1.0.1" + regexp.prototype.flags "^1.5.4" + safe-array-concat "^1.1.3" + safe-push-apply "^1.0.0" + safe-regex-test "^1.1.0" + set-proto "^1.0.0" + stop-iteration-iterator "^1.1.0" + string.prototype.trim "^1.2.10" + string.prototype.trimend "^1.0.9" + string.prototype.trimstart "^1.0.8" + typed-array-buffer "^1.0.3" + typed-array-byte-length "^1.0.3" + typed-array-byte-offset "^1.0.4" + typed-array-length "^1.0.7" + unbox-primitive "^1.1.0" + which-typed-array "^1.1.19" + es-define-property@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.0.tgz#c7faefbdff8b2696cf5f46921edfb77cc4ba3845" @@ -1385,6 +1553,11 @@ es-define-property@^1.0.0: dependencies: get-intrinsic "^1.2.4" +es-define-property@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.1.tgz#983eb2f9a6724e9303f61addf011c72e09e0b0fa" + integrity sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g== + es-errors@^1.2.1, es-errors@^1.3.0: version "1.3.0" resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" @@ -1397,6 +1570,13 @@ es-object-atoms@^1.0.0: dependencies: es-errors "^1.3.0" +es-object-atoms@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz#1c4f2c4837327597ce69d2ca190a7fdd172338c1" + integrity sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA== + dependencies: + es-errors "^1.3.0" + es-set-tostringtag@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/es-set-tostringtag/-/es-set-tostringtag-2.0.3.tgz#8bb60f0a440c2e4281962428438d58545af39777" @@ -1406,13 +1586,30 @@ es-set-tostringtag@^2.0.3: has-tostringtag "^1.0.2" hasown "^2.0.1" -es-shim-unscopables@^1.0.0, es-shim-unscopables@^1.0.2: +es-set-tostringtag@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz#f31dbbe0c183b00a6d26eb6325c810c0fd18bd4d" + integrity sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA== + dependencies: + es-errors "^1.3.0" + get-intrinsic "^1.2.6" + has-tostringtag "^1.0.2" + hasown "^2.0.2" + +es-shim-unscopables@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/es-shim-unscopables/-/es-shim-unscopables-1.0.2.tgz#1f6942e71ecc7835ed1c8a83006d8771a63a3763" integrity sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw== dependencies: hasown "^2.0.0" +es-shim-unscopables@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz#438df35520dac5d105f3943d927549ea3b00f4b5" + integrity sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw== + dependencies: + hasown "^2.0.2" + es-to-primitive@^1.2.1: version "1.2.1" resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.2.1.tgz#e55cd4c9cdc188bcefb03b366c736323fc5c898a" @@ -1422,6 +1619,15 @@ es-to-primitive@^1.2.1: is-date-object "^1.0.1" is-symbol "^1.0.2" +es-to-primitive@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/es-to-primitive/-/es-to-primitive-1.3.0.tgz#96c89c82cc49fd8794a24835ba3e1ff87f214e18" + integrity sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g== + dependencies: + is-callable "^1.2.7" + is-date-object "^1.0.5" + is-symbol "^1.0.4" + esbuild-sass-plugin@^3.3.1: version "3.3.1" resolved "https://registry.yarnpkg.com/esbuild-sass-plugin/-/esbuild-sass-plugin-3.3.1.tgz#74d096127bff10072042de30119235f276655566" @@ -1431,36 +1637,37 @@ esbuild-sass-plugin@^3.3.1: safe-identifier "^0.4.2" sass "^1.71.1" -esbuild@^0.25.3: - version "0.25.3" - resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.25.3.tgz#371f7cb41283e5b2191a96047a7a89562965a285" - integrity sha512-qKA6Pvai73+M2FtftpNKRxJ78GIjmFXFxd/1DVBqGo/qNhLSfv+G12n9pNoWdytJC8U00TrViOwpjT0zgqQS8Q== +esbuild@^0.25.6: + version "0.25.8" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.25.8.tgz#482d42198b427c9c2f3a81b63d7663aecb1dda07" + integrity sha512-vVC0USHGtMi8+R4Kz8rt6JhEWLxsv9Rnu/lGYbPR8u47B+DCBksq9JarW0zOO7bs37hyOK1l2/oqtbciutL5+Q== optionalDependencies: - "@esbuild/aix-ppc64" "0.25.3" - "@esbuild/android-arm" "0.25.3" - "@esbuild/android-arm64" "0.25.3" - "@esbuild/android-x64" "0.25.3" - "@esbuild/darwin-arm64" "0.25.3" - "@esbuild/darwin-x64" "0.25.3" - "@esbuild/freebsd-arm64" "0.25.3" - "@esbuild/freebsd-x64" "0.25.3" - "@esbuild/linux-arm" "0.25.3" - "@esbuild/linux-arm64" "0.25.3" - "@esbuild/linux-ia32" "0.25.3" - "@esbuild/linux-loong64" "0.25.3" - "@esbuild/linux-mips64el" "0.25.3" - "@esbuild/linux-ppc64" "0.25.3" - "@esbuild/linux-riscv64" "0.25.3" - "@esbuild/linux-s390x" "0.25.3" - "@esbuild/linux-x64" "0.25.3" - "@esbuild/netbsd-arm64" "0.25.3" - "@esbuild/netbsd-x64" "0.25.3" - "@esbuild/openbsd-arm64" "0.25.3" - "@esbuild/openbsd-x64" "0.25.3" - "@esbuild/sunos-x64" "0.25.3" - "@esbuild/win32-arm64" "0.25.3" - "@esbuild/win32-ia32" "0.25.3" - "@esbuild/win32-x64" "0.25.3" + "@esbuild/aix-ppc64" "0.25.8" + "@esbuild/android-arm" "0.25.8" + "@esbuild/android-arm64" "0.25.8" + "@esbuild/android-x64" "0.25.8" + "@esbuild/darwin-arm64" "0.25.8" + "@esbuild/darwin-x64" "0.25.8" + "@esbuild/freebsd-arm64" "0.25.8" + "@esbuild/freebsd-x64" "0.25.8" + "@esbuild/linux-arm" "0.25.8" + "@esbuild/linux-arm64" "0.25.8" + "@esbuild/linux-ia32" "0.25.8" + "@esbuild/linux-loong64" "0.25.8" + "@esbuild/linux-mips64el" "0.25.8" + "@esbuild/linux-ppc64" "0.25.8" + "@esbuild/linux-riscv64" "0.25.8" + "@esbuild/linux-s390x" "0.25.8" + "@esbuild/linux-x64" "0.25.8" + "@esbuild/netbsd-arm64" "0.25.8" + "@esbuild/netbsd-x64" "0.25.8" + "@esbuild/openbsd-arm64" "0.25.8" + "@esbuild/openbsd-x64" "0.25.8" + "@esbuild/openharmony-arm64" "0.25.8" + "@esbuild/sunos-x64" "0.25.8" + "@esbuild/win32-arm64" "0.25.8" + "@esbuild/win32-ia32" "0.25.8" + "@esbuild/win32-x64" "0.25.8" escape-string-regexp@^4.0.0: version "4.0.0" @@ -1495,44 +1702,52 @@ eslint-import-resolver-typescript@^3.6.3: is-bun-module "^1.0.2" is-glob "^4.0.3" -eslint-module-utils@^2.8.1, eslint-module-utils@^2.9.0: +eslint-module-utils@^2.12.1: + version "2.12.1" + resolved "https://registry.yarnpkg.com/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz#f76d3220bfb83c057651359295ab5854eaad75ff" + integrity sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw== + dependencies: + debug "^3.2.7" + +eslint-module-utils@^2.8.1: version "2.9.0" resolved "https://registry.yarnpkg.com/eslint-module-utils/-/eslint-module-utils-2.9.0.tgz#95d4ac038a68cd3f63482659dffe0883900eb342" integrity sha512-McVbYmwA3NEKwRQY5g4aWMdcZE5xZxV8i8l7CqJSrameuGSQJtSWaL/LxTEzSKKaCcOhlpDR8XEfYXWPrdo/ZQ== dependencies: debug "^3.2.7" -eslint-plugin-import@^2.30.0: - version "2.30.0" - resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.30.0.tgz#21ceea0fc462657195989dd780e50c92fe95f449" - integrity sha512-/mHNE9jINJfiD2EKkg1BKyPyUk4zdnT54YgbOgfjSakWT5oyX/qQLVNTkehyfpcMxZXMy1zyonZ2v7hZTX43Yw== +eslint-plugin-import@^2.32.0: + version "2.32.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz#602b55faa6e4caeaa5e970c198b5c00a37708980" + integrity sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA== dependencies: "@rtsao/scc" "^1.1.0" - array-includes "^3.1.8" - array.prototype.findlastindex "^1.2.5" - array.prototype.flat "^1.3.2" - array.prototype.flatmap "^1.3.2" + array-includes "^3.1.9" + array.prototype.findlastindex "^1.2.6" + array.prototype.flat "^1.3.3" + array.prototype.flatmap "^1.3.3" debug "^3.2.7" doctrine "^2.1.0" eslint-import-resolver-node "^0.3.9" - eslint-module-utils "^2.9.0" + eslint-module-utils "^2.12.1" hasown "^2.0.2" - is-core-module "^2.15.1" + is-core-module "^2.16.1" is-glob "^4.0.3" minimatch "^3.1.2" object.fromentries "^2.0.8" object.groupby "^1.0.3" - object.values "^1.2.0" + object.values "^1.2.1" semver "^6.3.1" + string.prototype.trimend "^1.0.9" tsconfig-paths "^3.15.0" -eslint-plugin-prettier@^5.2.1: - version "5.2.1" - resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-5.2.1.tgz#d1c8f972d8f60e414c25465c163d16f209411f95" - integrity sha512-gH3iR3g4JfF+yYPaJYkN7jEl9QbweL/YfkoRlNnuIEHEz1vHVlCmWOS+eGGiRuzHQXdJFCOTxRgvju9b8VUmrw== +eslint-plugin-prettier@^5.5.1: + version "5.5.3" + resolved "https://registry.yarnpkg.com/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.3.tgz#1f88e9220a72ac8be171eec5f9d4e4d529b5f4a0" + integrity sha512-NAdMYww51ehKfDyDhv59/eIItUVzU0Io9H2E8nHNGKEeeqlnci+1gCvrHib6EmZdf6GxF+LCV5K7UC65Ezvw7w== dependencies: prettier-linter-helpers "^1.0.0" - synckit "^0.9.1" + synckit "^0.11.7" eslint-scope@^7.2.2: version "7.2.2" @@ -1547,6 +1762,11 @@ eslint-visitor-keys@^3.3.0, eslint-visitor-keys@^3.4.1, eslint-visitor-keys@^3.4 resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz#0cd72fe8550e3c2eae156a96a4dddcd1c8ac5800" integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== +eslint-visitor-keys@^4.2.1: + version "4.2.1" + resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz#4cfea60fe7dd0ad8e816e1ed026c1d5251b512c1" + integrity sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ== + eslint@<9.0: version "8.57.0" resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.57.0.tgz#c786a6fd0e0b68941aaf624596fb987089195668" @@ -1634,16 +1854,16 @@ fast-diff@^1.1.2: resolved "https://registry.yarnpkg.com/fast-diff/-/fast-diff-1.3.0.tgz#ece407fa550a64d638536cd727e129c61616e0f0" integrity sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw== -fast-glob@^3.2.9, fast-glob@^3.3.2: - version "3.3.2" - resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.2.tgz#a904501e57cfdd2ffcded45e99a54fef55e46129" - integrity sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow== +fast-glob@^3.3.2: + version "3.3.3" + resolved "https://registry.yarnpkg.com/fast-glob/-/fast-glob-3.3.3.tgz#d06d585ce8dba90a16b0505c543c3ccfb3aeb818" + integrity sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg== dependencies: "@nodelib/fs.stat" "^2.0.2" "@nodelib/fs.walk" "^1.2.3" glob-parent "^5.1.2" merge2 "^1.3.0" - micromatch "^4.0.4" + micromatch "^4.0.8" fast-json-stable-stringify@^2.0.0: version "2.1.0" @@ -1656,9 +1876,9 @@ fast-levenshtein@^2.0.6: integrity sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw== fastq@^1.6.0: - version "1.17.1" - resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.17.1.tgz#2a523f07a4e7b1e81a42b91b8bf2254107753b47" - integrity sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w== + version "1.19.1" + resolved "https://registry.yarnpkg.com/fastq/-/fastq-1.19.1.tgz#d50eaba803c8846a883c16492821ebcd2cda55f5" + integrity sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ== dependencies: reusify "^1.0.4" @@ -1715,6 +1935,13 @@ for-each@^0.3.3: dependencies: is-callable "^1.1.3" +for-each@^0.3.5: + version "0.3.5" + resolved "https://registry.yarnpkg.com/for-each/-/for-each-0.3.5.tgz#d650688027826920feeb0af747ee7b9421a41d47" + integrity sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg== + dependencies: + is-callable "^1.2.7" + framer-motion@^6.5.1: version "6.5.1" resolved "https://registry.yarnpkg.com/framer-motion/-/framer-motion-6.5.1.tgz#802448a16a6eb764124bf36d8cbdfa6dd6b931a7" @@ -1761,6 +1988,18 @@ function.prototype.name@^1.1.6: es-abstract "^1.22.1" functions-have-names "^1.2.3" +function.prototype.name@^1.1.8: + version "1.1.8" + resolved "https://registry.yarnpkg.com/function.prototype.name/-/function.prototype.name-1.1.8.tgz#e68e1df7b259a5c949eeef95cdbde53edffabb78" + integrity sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.3" + define-properties "^1.2.1" + functions-have-names "^1.2.3" + hasown "^2.0.2" + is-callable "^1.2.7" + functions-have-names@^1.2.3: version "1.2.3" resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834" @@ -1777,11 +2016,35 @@ get-intrinsic@^1.1.3, get-intrinsic@^1.2.1, get-intrinsic@^1.2.3, get-intrinsic@ has-symbols "^1.0.3" hasown "^2.0.0" +get-intrinsic@^1.2.5, get-intrinsic@^1.2.6, get-intrinsic@^1.2.7, get-intrinsic@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz#743f0e3b6964a93a5491ed1bffaae054d7f98d01" + integrity sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ== + dependencies: + call-bind-apply-helpers "^1.0.2" + es-define-property "^1.0.1" + es-errors "^1.3.0" + es-object-atoms "^1.1.1" + function-bind "^1.1.2" + get-proto "^1.0.1" + gopd "^1.2.0" + has-symbols "^1.1.0" + hasown "^2.0.2" + math-intrinsics "^1.1.0" + get-nonce@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/get-nonce/-/get-nonce-1.0.1.tgz#fdf3f0278073820d2ce9426c18f07481b1e0cdf3" integrity sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q== +get-proto@^1.0.0, get-proto@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/get-proto/-/get-proto-1.0.1.tgz#150b3f2743869ef3e851ec0c49d15b1d14d00ee1" + integrity sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g== + dependencies: + dunder-proto "^1.0.1" + es-object-atoms "^1.0.0" + get-symbol-description@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/get-symbol-description/-/get-symbol-description-1.0.2.tgz#533744d5aa20aca4e079c8e5daf7fd44202821f5" @@ -1791,6 +2054,15 @@ get-symbol-description@^1.0.2: es-errors "^1.3.0" get-intrinsic "^1.2.4" +get-symbol-description@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/get-symbol-description/-/get-symbol-description-1.1.0.tgz#7bdd54e0befe8ffc9f3b4e203220d9f1e881b6ee" + integrity sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg== + dependencies: + call-bound "^1.0.3" + es-errors "^1.3.0" + get-intrinsic "^1.2.6" + get-tsconfig@^4.7.5: version "4.8.0" resolved "https://registry.yarnpkg.com/get-tsconfig/-/get-tsconfig-4.8.0.tgz#125dc13a316f61650a12b20c97c11b8fd996fedd" @@ -1838,7 +2110,7 @@ globals@^13.19.0: dependencies: type-fest "^0.20.2" -globalthis@^1.0.3: +globalthis@^1.0.3, globalthis@^1.0.4: version "1.0.4" resolved "https://registry.yarnpkg.com/globalthis/-/globalthis-1.0.4.tgz#7430ed3a975d97bfb59bcce41f5cabbafa651236" integrity sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ== @@ -1846,18 +2118,6 @@ globalthis@^1.0.3: define-properties "^1.2.1" gopd "^1.0.1" -globby@^11.1.0: - version "11.1.0" - resolved "https://registry.yarnpkg.com/globby/-/globby-11.1.0.tgz#bd4be98bb042f83d796f7e3811991fbe82a0d34b" - integrity sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g== - dependencies: - array-union "^2.1.0" - dir-glob "^3.0.1" - fast-glob "^3.2.9" - ignore "^5.2.0" - merge2 "^1.4.1" - slash "^3.0.0" - good-listener@^1.2.2: version "1.2.2" resolved "https://registry.yarnpkg.com/good-listener/-/good-listener-1.2.2.tgz#d53b30cdf9313dffb7dc9a0d477096aa6d145c50" @@ -1872,6 +2132,11 @@ gopd@^1.0.1: dependencies: get-intrinsic "^1.1.3" +gopd@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/gopd/-/gopd-1.2.0.tgz#89f56b8217bdbc8802bd299df6d7f1081d7e51a1" + integrity sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg== + graceful-fs@^4.2.4: version "4.2.11" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" @@ -1908,10 +2173,10 @@ graphql@16.10.0: resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.10.0.tgz#24c01ae0af6b11ea87bf55694429198aaa8e220c" integrity sha512-AjqGKbDGUFRKIRCP9tCKiIGHyriz2oHEbPIbEtcSLSs4YjReZOIPQQWek4+6hjw62H9QShXHyaGivGiYVLeYFQ== -gridstack@12.2.1: - version "12.2.1" - resolved "https://registry.yarnpkg.com/gridstack/-/gridstack-12.2.1.tgz#0e82e3d9d11e5229388d73bd57f8ef1a0e7059c4" - integrity sha512-xU69tThmmVxgMHTuM/z3rIKiiGm0zW4tcB6yRcuwiOUUBiwb3tslzFOrUjWz+PwaxoAW+JChT4fqOLl+oKAxZA== +gridstack@12.2.2: + version "12.2.2" + resolved "https://registry.yarnpkg.com/gridstack/-/gridstack-12.2.2.tgz#a9ec300cefc93516bcb8dd966510a728027be358" + integrity sha512-eK9XAbBWQp+QniqL6ipvofWSrCelm46j5USag73LNq8tOWSL2DeeGBWU9mTibLI6i66n0r7xYS+1/g2qqTqKcw== has-bigints@^1.0.1, has-bigints@^1.0.2: version "1.0.2" @@ -1935,11 +2200,23 @@ has-proto@^1.0.1, has-proto@^1.0.3: resolved "https://registry.yarnpkg.com/has-proto/-/has-proto-1.0.3.tgz#b31ddfe9b0e6e9914536a6ab286426d0214f77fd" integrity sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q== +has-proto@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/has-proto/-/has-proto-1.2.0.tgz#5de5a6eabd95fdffd9818b43055e8065e39fe9d5" + integrity sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ== + dependencies: + dunder-proto "^1.0.0" + has-symbols@^1.0.2, has-symbols@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8" integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A== +has-symbols@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.1.0.tgz#fc9c6a783a084951d0b971fe1018de813707a338" + integrity sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ== + has-tostringtag@^1.0.0, has-tostringtag@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz#2cdc42d40bef2e5b4eeab7c01a73c54ce7ab5abc" @@ -1959,16 +2236,21 @@ hey-listen@^1.0.8: resolved "https://registry.yarnpkg.com/hey-listen/-/hey-listen-1.0.8.tgz#8e59561ff724908de1aa924ed6ecc84a56a9aa68" integrity sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q== -htmx.org@2.0.5: - version "2.0.5" - resolved "https://registry.yarnpkg.com/htmx.org/-/htmx.org-2.0.5.tgz#88e8d89078b3059d74ac4eb653d80451c144820c" - integrity sha512-ocgvtHCShWFW0DvSV1NbJC7Y5EzUMy2eo5zeWvGj2Ac4LOr7sv9YKg4jzCZJdXN21fXACmCViwKSy+cm6i2dWQ== +htmx.org@2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/htmx.org/-/htmx.org-2.0.6.tgz#42573483c72112e7e332dfe93043cd0eb32cda01" + integrity sha512-7ythjYneGSk3yCHgtCnQeaoF+D+o7U2LF37WU3O0JYv3gTZSicdEFiI/Ai/NJyC5ZpYJWMpUb11OC5Lr6AfAqA== -ignore@^5.2.0, ignore@^5.3.1: +ignore@^5.2.0: version "5.3.2" resolved "https://registry.yarnpkg.com/ignore/-/ignore-5.3.2.tgz#3cd40e729f3643fd87cb04e50bf0eb722bc596f5" integrity sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g== +ignore@^7.0.0: + version "7.0.5" + resolved "https://registry.yarnpkg.com/ignore/-/ignore-7.0.5.tgz#4cb5f6cd7d4c7ab0365738c7aea888baa6d7efd9" + integrity sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg== + immutable@^4.0.0: version "4.3.7" resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.3.7.tgz#c70145fc90d89fb02021e65c84eb0226e4e5a381" @@ -2014,6 +2296,15 @@ internal-slot@^1.0.7: hasown "^2.0.0" side-channel "^1.0.4" +internal-slot@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/internal-slot/-/internal-slot-1.1.0.tgz#1eac91762947d2f7056bc838d93e13b2e9604961" + integrity sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw== + dependencies: + es-errors "^1.3.0" + hasown "^2.0.2" + side-channel "^1.1.0" + invariant@^2.2.4: version "2.2.4" resolved "https://registry.yarnpkg.com/invariant/-/invariant-2.2.4.tgz#610f3c92c9359ce1db616e538008d23ff35158e6" @@ -2029,6 +2320,26 @@ is-array-buffer@^3.0.4: call-bind "^1.0.2" get-intrinsic "^1.2.1" +is-array-buffer@^3.0.5: + version "3.0.5" + resolved "https://registry.yarnpkg.com/is-array-buffer/-/is-array-buffer-3.0.5.tgz#65742e1e687bd2cc666253068fd8707fe4d44280" + integrity sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.3" + get-intrinsic "^1.2.6" + +is-async-function@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/is-async-function/-/is-async-function-2.1.1.tgz#3e69018c8e04e73b738793d020bfe884b9fd3523" + integrity sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ== + dependencies: + async-function "^1.0.0" + call-bound "^1.0.3" + get-proto "^1.0.1" + has-tostringtag "^1.0.2" + safe-regex-test "^1.1.0" + is-bigint@^1.0.1: version "1.0.4" resolved "https://registry.yarnpkg.com/is-bigint/-/is-bigint-1.0.4.tgz#08147a1875bc2b32005d41ccd8291dffc6691df3" @@ -2036,6 +2347,13 @@ is-bigint@^1.0.1: dependencies: has-bigints "^1.0.1" +is-bigint@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-bigint/-/is-bigint-1.1.0.tgz#dda7a3445df57a42583db4228682eba7c4170672" + integrity sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ== + dependencies: + has-bigints "^1.0.2" + is-binary-path@~2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09" @@ -2051,6 +2369,14 @@ is-boolean-object@^1.1.0: call-bind "^1.0.2" has-tostringtag "^1.0.0" +is-boolean-object@^1.2.1: + version "1.2.2" + resolved "https://registry.yarnpkg.com/is-boolean-object/-/is-boolean-object-1.2.2.tgz#7067f47709809a393c71ff5bb3e135d8a9215d9e" + integrity sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A== + dependencies: + call-bound "^1.0.3" + has-tostringtag "^1.0.2" + is-bun-module@^1.0.2: version "1.1.0" resolved "https://registry.yarnpkg.com/is-bun-module/-/is-bun-module-1.1.0.tgz#a66b9830869437f6cdad440ba49ab6e4dc837269" @@ -2070,10 +2396,10 @@ is-core-module@^2.13.0: dependencies: hasown "^2.0.0" -is-core-module@^2.15.1: - version "2.15.1" - resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.15.1.tgz#a7363a25bee942fefab0de13bf6aa372c82dcc37" - integrity sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ== +is-core-module@^2.16.1: + version "2.16.1" + resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.16.1.tgz#2a98801a849f43e2add644fbb6bc6229b19a4ef4" + integrity sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w== dependencies: hasown "^2.0.2" @@ -2084,6 +2410,15 @@ is-data-view@^1.0.1: dependencies: is-typed-array "^1.1.13" +is-data-view@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/is-data-view/-/is-data-view-1.0.2.tgz#bae0a41b9688986c2188dda6657e56b8f9e63b8e" + integrity sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw== + dependencies: + call-bound "^1.0.2" + get-intrinsic "^1.2.6" + is-typed-array "^1.1.13" + is-date-object@^1.0.1: version "1.0.5" resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.0.5.tgz#0841d5536e724c25597bf6ea62e1bd38298df31f" @@ -2091,11 +2426,36 @@ is-date-object@^1.0.1: dependencies: has-tostringtag "^1.0.0" +is-date-object@^1.0.5, is-date-object@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.1.0.tgz#ad85541996fc7aa8b2729701d27b7319f95d82f7" + integrity sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg== + dependencies: + call-bound "^1.0.2" + has-tostringtag "^1.0.2" + is-extglob@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== +is-finalizationregistry@^1.1.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz#eefdcdc6c94ddd0674d9c85887bf93f944a97c90" + integrity sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg== + dependencies: + call-bound "^1.0.3" + +is-generator-function@^1.0.10: + version "1.1.0" + resolved "https://registry.yarnpkg.com/is-generator-function/-/is-generator-function-1.1.0.tgz#bf3eeda931201394f57b5dba2800f91a238309ca" + integrity sha512-nPUB5km40q9e8UfN/Zc24eLlzdSf9OfKByBw9CIdw4H1giPMeA0OIJvbchsCu4npfI2QcMVBsGEBHKZ7wLTWmQ== + dependencies: + call-bound "^1.0.3" + get-proto "^1.0.0" + has-tostringtag "^1.0.2" + safe-regex-test "^1.1.0" + is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1: version "4.0.3" resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" @@ -2103,6 +2463,11 @@ is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3, is-glob@~4.0.1: dependencies: is-extglob "^2.1.1" +is-map@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/is-map/-/is-map-2.0.3.tgz#ede96b7fe1e270b3c4465e3a465658764926d62e" + integrity sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw== + is-negative-zero@^2.0.3: version "2.0.3" resolved "https://registry.yarnpkg.com/is-negative-zero/-/is-negative-zero-2.0.3.tgz#ced903a027aca6381b777a5743069d7376a49747" @@ -2115,6 +2480,14 @@ is-number-object@^1.0.4: dependencies: has-tostringtag "^1.0.0" +is-number-object@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-number-object/-/is-number-object-1.1.1.tgz#144b21e95a1bc148205dcc2814a9134ec41b2541" + integrity sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw== + dependencies: + call-bound "^1.0.3" + has-tostringtag "^1.0.2" + is-number@^7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" @@ -2145,6 +2518,21 @@ is-regex@^1.1.4: call-bind "^1.0.2" has-tostringtag "^1.0.0" +is-regex@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.2.1.tgz#76d70a3ed10ef9be48eb577887d74205bf0cad22" + integrity sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g== + dependencies: + call-bound "^1.0.2" + gopd "^1.2.0" + has-tostringtag "^1.0.2" + hasown "^2.0.2" + +is-set@^2.0.3: + version "2.0.3" + resolved "https://registry.yarnpkg.com/is-set/-/is-set-2.0.3.tgz#8ab209ea424608141372ded6e0cb200ef1d9d01d" + integrity sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg== + is-shared-array-buffer@^1.0.2, is-shared-array-buffer@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/is-shared-array-buffer/-/is-shared-array-buffer-1.0.3.tgz#1237f1cba059cdb62431d378dcc37d9680181688" @@ -2152,6 +2540,13 @@ is-shared-array-buffer@^1.0.2, is-shared-array-buffer@^1.0.3: dependencies: call-bind "^1.0.7" +is-shared-array-buffer@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz#9b67844bd9b7f246ba0708c3a93e34269c774f6f" + integrity sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A== + dependencies: + call-bound "^1.0.3" + is-string@^1.0.5, is-string@^1.0.7: version "1.0.7" resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.0.7.tgz#0dd12bf2006f255bb58f695110eff7491eebc0fd" @@ -2159,6 +2554,14 @@ is-string@^1.0.5, is-string@^1.0.7: dependencies: has-tostringtag "^1.0.0" +is-string@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-string/-/is-string-1.1.1.tgz#92ea3f3d5c5b6e039ca8677e5ac8d07ea773cbb9" + integrity sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA== + dependencies: + call-bound "^1.0.3" + has-tostringtag "^1.0.2" + is-symbol@^1.0.2, is-symbol@^1.0.3: version "1.0.4" resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.0.4.tgz#a6dac93b635b063ca6872236de88910a57af139c" @@ -2166,6 +2569,15 @@ is-symbol@^1.0.2, is-symbol@^1.0.3: dependencies: has-symbols "^1.0.2" +is-symbol@^1.0.4, is-symbol@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-symbol/-/is-symbol-1.1.1.tgz#f47761279f532e2b05a7024a7506dbbedacd0634" + integrity sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w== + dependencies: + call-bound "^1.0.2" + has-symbols "^1.1.0" + safe-regex-test "^1.1.0" + is-typed-array@^1.1.13: version "1.1.13" resolved "https://registry.yarnpkg.com/is-typed-array/-/is-typed-array-1.1.13.tgz#d6c5ca56df62334959322d7d7dd1cca50debe229" @@ -2173,6 +2585,18 @@ is-typed-array@^1.1.13: dependencies: which-typed-array "^1.1.14" +is-typed-array@^1.1.14, is-typed-array@^1.1.15: + version "1.1.15" + resolved "https://registry.yarnpkg.com/is-typed-array/-/is-typed-array-1.1.15.tgz#4bfb4a45b61cee83a5a46fba778e4e8d59c0ce0b" + integrity sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ== + dependencies: + which-typed-array "^1.1.16" + +is-weakmap@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/is-weakmap/-/is-weakmap-2.0.2.tgz#bf72615d649dfe5f699079c54b83e47d1ae19cfd" + integrity sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w== + is-weakref@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/is-weakref/-/is-weakref-1.0.2.tgz#9529f383a9338205e89765e0392efc2f100f06f2" @@ -2180,6 +2604,21 @@ is-weakref@^1.0.2: dependencies: call-bind "^1.0.2" +is-weakref@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/is-weakref/-/is-weakref-1.1.1.tgz#eea430182be8d64174bd96bffbc46f21bf3f9293" + integrity sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew== + dependencies: + call-bound "^1.0.3" + +is-weakset@^2.0.3: + version "2.0.4" + resolved "https://registry.yarnpkg.com/is-weakset/-/is-weakset-2.0.4.tgz#c9f5deb0bc1906c6d6f1027f284ddf459249daca" + integrity sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ== + dependencies: + call-bound "^1.0.3" + get-intrinsic "^1.2.6" + isarray@^2.0.5: version "2.0.5" resolved "https://registry.yarnpkg.com/isarray/-/isarray-2.0.5.tgz#8af1e4c1221244cc62459faf38940d4e644a5723" @@ -2287,12 +2726,17 @@ markdown-it@^14.1.0: punycode.js "^2.3.1" uc.micro "^2.1.0" +math-intrinsics@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9" + integrity sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g== + mdurl@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/mdurl/-/mdurl-2.0.0.tgz#80676ec0433025dd3e17ee983d0fe8de5a2237e0" integrity sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w== -merge2@^1.3.0, merge2@^1.4.1: +merge2@^1.3.0: version "1.4.1" resolved "https://registry.yarnpkg.com/merge2/-/merge2-1.4.1.tgz#4368892f885e907455a6fd7dc55c0c9d404990ae" integrity sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg== @@ -2302,7 +2746,7 @@ meros@^1.1.4: resolved "https://registry.yarnpkg.com/meros/-/meros-1.3.0.tgz#c617d2092739d55286bf618129280f362e6242f2" integrity sha512-2BNGOimxEz5hmjUG2FwoxCt5HN7BXdaWyFqEwxPTrJzVdABtrL4TiHTcsWSFAxPQ/tOnEaQEJh3qWq71QRMY+w== -micromatch@^4.0.4, micromatch@^4.0.5: +micromatch@^4.0.5, micromatch@^4.0.8: version "4.0.8" resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202" integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA== @@ -2334,7 +2778,7 @@ ms@2.1.2: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== -ms@^2.1.1: +ms@^2.1.1, ms@^2.1.3: version "2.1.3" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== @@ -2364,6 +2808,11 @@ object-inspect@^1.13.1: resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.1.tgz#b96c6109324ccfef6b12216a956ca4dc2ff94bc2" integrity sha512-5qoj1RUiKOMsCCNLV1CBiPYE10sziTsnmNxkAI/rZhiD63CF7IqdFGC/XzjWjpSgLf0LxXX3bDFIh0E18f6UhQ== +object-inspect@^1.13.3, object-inspect@^1.13.4: + version "1.13.4" + resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.13.4.tgz#8375265e21bc20d0fa582c22e1b13485d6e00213" + integrity sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew== + object-keys@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" @@ -2379,6 +2828,18 @@ object.assign@^4.1.5: has-symbols "^1.0.3" object-keys "^1.1.1" +object.assign@^4.1.7: + version "4.1.7" + resolved "https://registry.yarnpkg.com/object.assign/-/object.assign-4.1.7.tgz#8c14ca1a424c6a561b0bb2a22f66f5049a945d3d" + integrity sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.3" + define-properties "^1.2.1" + es-object-atoms "^1.0.0" + has-symbols "^1.1.0" + object-keys "^1.1.1" + object.fromentries@^2.0.8: version "2.0.8" resolved "https://registry.yarnpkg.com/object.fromentries/-/object.fromentries-2.0.8.tgz#f7195d8a9b97bd95cbc1999ea939ecd1a2b00c65" @@ -2398,12 +2859,13 @@ object.groupby@^1.0.3: define-properties "^1.2.1" es-abstract "^1.23.2" -object.values@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.2.0.tgz#65405a9d92cee68ac2d303002e0b8470a4d9ab1b" - integrity sha512-yBYjY9QX2hnRmZHAjG/f13MzmBzxzYgQhFrke06TTyKY5zSTEqkOeukBzIdVA3j3ulu8Qa3MbVFShV7T2RmGtQ== +object.values@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/object.values/-/object.values-1.2.1.tgz#deed520a50809ff7f75a7cfd4bc64c7a038c6216" + integrity sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA== dependencies: - call-bind "^1.0.7" + call-bind "^1.0.8" + call-bound "^1.0.3" define-properties "^1.2.1" es-object-atoms "^1.0.0" @@ -2426,6 +2888,15 @@ optionator@^0.9.3: type-check "^0.4.0" word-wrap "^1.2.5" +own-keys@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/own-keys/-/own-keys-1.0.1.tgz#e4006910a2bf913585289676eebd6f390cf51358" + integrity sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg== + dependencies: + get-intrinsic "^1.2.6" + object-keys "^1.1.1" + safe-push-apply "^1.0.0" + p-limit@^3.0.2: version "3.1.0" resolved "https://registry.yarnpkg.com/p-limit/-/p-limit-3.1.0.tgz#e1daccbe78d0d1388ca18c64fea38e3e57e3706b" @@ -2467,11 +2938,6 @@ path-parse@^1.0.7: resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== -path-type@^4.0.0: - version "4.0.0" - resolved "https://registry.yarnpkg.com/path-type/-/path-type-4.0.0.tgz#84ed01c0a7ba380afe09d90a8c180dcd9d03043b" - integrity sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw== - picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.1: version "2.3.1" resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" @@ -2505,9 +2971,9 @@ prettier-linter-helpers@^1.0.0: fast-diff "^1.1.2" prettier@^3.3.3: - version "3.3.3" - resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.3.3.tgz#30c54fe0be0d8d12e6ae61dbb10109ea00d53105" - integrity sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew== + version "3.6.2" + resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.6.2.tgz#ccda02a1003ebbb2bfda6f83a074978f608b9393" + integrity sha512-I7AIg5boAr5R0FFtJ6rCfD+LFsWHp81dolrFD8S79U9tb8Az2nGrJncnMSnys+bpQJfRUzqs9hnA81OAA3hCuQ== punycode.js@^2.3.1: version "2.3.1" @@ -2519,10 +2985,10 @@ punycode@^2.1.0: resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5" integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg== -query-string@9.2.1: - version "9.2.1" - resolved "https://registry.yarnpkg.com/query-string/-/query-string-9.2.1.tgz#67bd95f6e2cb64eafecfb0504be7cc38bcd4dd11" - integrity sha512-3jTGGLRzlhu/1ws2zlr4Q+GVMLCQTLFOj8CMX5x44cdZG9FQE07x2mQhaNxaKVPNmIDu0mvJ/cEwtY7Pim7hqA== +query-string@9.2.2: + version "9.2.2" + resolved "https://registry.yarnpkg.com/query-string/-/query-string-9.2.2.tgz#a0104824edfdd2c1db2f18af71cef7abf6a3b20f" + integrity sha512-pDSIZJ9sFuOp6VnD+5IkakSVf+rICAuuU88Hcsr6AKL0QtxSIfVuKiVP2oahFI7tk3CRSexwV+Ya6MOoTxzg9g== dependencies: decode-uri-component "^0.4.1" filter-obj "^5.1.0" @@ -2593,6 +3059,20 @@ readdirp@~3.6.0: dependencies: picomatch "^2.2.1" +reflect.getprototypeof@^1.0.6, reflect.getprototypeof@^1.0.9: + version "1.0.10" + resolved "https://registry.yarnpkg.com/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz#c629219e78a3316d8b604c765ef68996964e7bf9" + integrity sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw== + dependencies: + call-bind "^1.0.8" + define-properties "^1.2.1" + es-abstract "^1.23.9" + es-errors "^1.3.0" + es-object-atoms "^1.0.0" + get-intrinsic "^1.2.7" + get-proto "^1.0.1" + which-builtin-type "^1.2.1" + regexp.prototype.flags@^1.5.2: version "1.5.2" resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.5.2.tgz#138f644a3350f981a858c44f6bb1a61ff59be334" @@ -2603,6 +3083,18 @@ regexp.prototype.flags@^1.5.2: es-errors "^1.3.0" set-function-name "^2.0.1" +regexp.prototype.flags@^1.5.4: + version "1.5.4" + resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz#1ad6c62d44a259007e55b3970e00f746efbcaa19" + integrity sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA== + dependencies: + call-bind "^1.0.8" + define-properties "^1.2.1" + es-errors "^1.3.0" + get-proto "^1.0.1" + gopd "^1.2.0" + set-function-name "^2.0.2" + resolve-from@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/resolve-from/-/resolve-from-4.0.0.tgz#4abcd852ad32dd7baabfe9b40e00a36db5f392e6" @@ -2623,9 +3115,9 @@ resolve@^1.22.4, resolve@^1.22.8: supports-preserve-symlinks-flag "^1.0.0" reusify@^1.0.4: - version "1.0.4" - resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76" - integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw== + version "1.1.0" + resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.1.0.tgz#0fe13b9522e1473f51b558ee796e08f11f9b489f" + integrity sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw== rimraf@^3.0.2: version "3.0.2" @@ -2651,11 +3143,30 @@ safe-array-concat@^1.1.2: has-symbols "^1.0.3" isarray "^2.0.5" +safe-array-concat@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/safe-array-concat/-/safe-array-concat-1.1.3.tgz#c9e54ec4f603b0bbb8e7e5007a5ee7aecd1538c3" + integrity sha512-AURm5f0jYEOydBj7VQlVvDrjeFgthDdEF5H1dP+6mNpoXOMo1quQqJ4wvJDyRZ9+pO3kGWoOdmV08cSv2aJV6Q== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.2" + get-intrinsic "^1.2.6" + has-symbols "^1.1.0" + isarray "^2.0.5" + safe-identifier@^0.4.2: version "0.4.2" resolved "https://registry.yarnpkg.com/safe-identifier/-/safe-identifier-0.4.2.tgz#cf6bfca31c2897c588092d1750d30ef501d59fcb" integrity sha512-6pNbSMW6OhAi9j+N8V+U715yBQsaWJ7eyEUaOrawX+isg5ZxhUlV1NipNtgaKHmFGiABwt+ZF04Ii+3Xjkg+8w== +safe-push-apply@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/safe-push-apply/-/safe-push-apply-1.0.0.tgz#01850e981c1602d398c85081f360e4e6d03d27f5" + integrity sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA== + dependencies: + es-errors "^1.3.0" + isarray "^2.0.5" + safe-regex-test@^1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/safe-regex-test/-/safe-regex-test-1.0.3.tgz#a5b4c0f06e0ab50ea2c395c14d8371232924c377" @@ -2665,6 +3176,15 @@ safe-regex-test@^1.0.3: es-errors "^1.3.0" is-regex "^1.1.4" +safe-regex-test@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/safe-regex-test/-/safe-regex-test-1.1.0.tgz#7f87dfb67a3150782eaaf18583ff5d1711ac10c1" + integrity sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + is-regex "^1.2.1" + sass@1.89.2: version "1.89.2" resolved "https://registry.yarnpkg.com/sass/-/sass-1.89.2.tgz#a771716aeae774e2b529f72c0ff2dfd46c9de10e" @@ -2702,12 +3222,17 @@ semver@^6.3.1: resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.1.tgz#556d2ef8689146e46dcea4bfdd095f3434dffcb4" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== -semver@^7.6.0, semver@^7.6.3: +semver@^7.6.0: + version "7.7.2" + resolved "https://registry.yarnpkg.com/semver/-/semver-7.7.2.tgz#67d99fdcd35cec21e6f8b87a7fd515a33f982b58" + integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA== + +semver@^7.6.3: version "7.6.3" resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.3.tgz#980f7b5550bc175fb4dc09403085627f9eb33143" integrity sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A== -set-function-length@^1.2.1: +set-function-length@^1.2.1, set-function-length@^1.2.2: version "1.2.2" resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449" integrity sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg== @@ -2719,7 +3244,7 @@ set-function-length@^1.2.1: gopd "^1.0.1" has-property-descriptors "^1.0.2" -set-function-name@^2.0.1: +set-function-name@^2.0.1, set-function-name@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/set-function-name/-/set-function-name-2.0.2.tgz#16a705c5a0dc2f5e638ca96d8a8cd4e1c2b90985" integrity sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ== @@ -2729,6 +3254,15 @@ set-function-name@^2.0.1: functions-have-names "^1.2.3" has-property-descriptors "^1.0.2" +set-proto@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/set-proto/-/set-proto-1.0.0.tgz#0760dbcff30b2d7e801fd6e19983e56da337565e" + integrity sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw== + dependencies: + dunder-proto "^1.0.1" + es-errors "^1.3.0" + es-object-atoms "^1.0.0" + set-value@^4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/set-value/-/set-value-4.1.0.tgz#aa433662d87081b75ad88a4743bd450f044e7d09" @@ -2749,6 +3283,35 @@ shebang-regex@^3.0.0: resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== +side-channel-list@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/side-channel-list/-/side-channel-list-1.0.0.tgz#10cb5984263115d3b7a0e336591e290a830af8ad" + integrity sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA== + dependencies: + es-errors "^1.3.0" + object-inspect "^1.13.3" + +side-channel-map@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/side-channel-map/-/side-channel-map-1.0.1.tgz#d6bb6b37902c6fef5174e5f533fab4c732a26f42" + integrity sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + get-intrinsic "^1.2.5" + object-inspect "^1.13.3" + +side-channel-weakmap@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz#11dda19d5368e40ce9ec2bdc1fb0ecbc0790ecea" + integrity sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A== + dependencies: + call-bound "^1.0.2" + es-errors "^1.3.0" + get-intrinsic "^1.2.5" + object-inspect "^1.13.3" + side-channel-map "^1.0.1" + side-channel@^1.0.4: version "1.0.6" resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.6.tgz#abd25fb7cd24baf45466406b1096b7831c9215f2" @@ -2759,10 +3322,16 @@ side-channel@^1.0.4: get-intrinsic "^1.2.4" object-inspect "^1.13.1" -slash@^3.0.0: - version "3.0.0" - resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634" - integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q== +side-channel@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.1.0.tgz#c3fcff9c4da932784873335ec9765fa94ff66bc9" + integrity sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw== + dependencies: + es-errors "^1.3.0" + object-inspect "^1.13.3" + side-channel-list "^1.0.0" + side-channel-map "^1.0.1" + side-channel-weakmap "^1.0.2" "source-map-js@>=0.6.2 <2.0.0": version "1.2.0" @@ -2774,6 +3343,27 @@ split-on-first@^3.0.0: resolved "https://registry.yarnpkg.com/split-on-first/-/split-on-first-3.0.0.tgz#f04959c9ea8101b9b0bbf35a61b9ebea784a23e7" integrity sha512-qxQJTx2ryR0Dw0ITYyekNQWpz6f8dGd7vffGNflQQ3Iqj9NJ6qiZ7ELpZsJ/QBhIVAiDfXdag3+Gp8RvWa62AA== +stop-iteration-iterator@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz#f481ff70a548f6124d0312c3aa14cbfa7aa542ad" + integrity sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ== + dependencies: + es-errors "^1.3.0" + internal-slot "^1.1.0" + +string.prototype.trim@^1.2.10: + version "1.2.10" + resolved "https://registry.yarnpkg.com/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz#40b2dd5ee94c959b4dcfb1d65ce72e90da480c81" + integrity sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.2" + define-data-property "^1.1.4" + define-properties "^1.2.1" + es-abstract "^1.23.5" + es-object-atoms "^1.0.0" + has-property-descriptors "^1.0.2" + string.prototype.trim@^1.2.9: version "1.2.9" resolved "https://registry.yarnpkg.com/string.prototype.trim/-/string.prototype.trim-1.2.9.tgz#b6fa326d72d2c78b6df02f7759c73f8f6274faa4" @@ -2793,6 +3383,16 @@ string.prototype.trimend@^1.0.8: define-properties "^1.2.1" es-object-atoms "^1.0.0" +string.prototype.trimend@^1.0.9: + version "1.0.9" + resolved "https://registry.yarnpkg.com/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz#62e2731272cd285041b36596054e9f66569b6942" + integrity sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.2" + define-properties "^1.2.1" + es-object-atoms "^1.0.0" + string.prototype.trimstart@^1.0.8: version "1.0.8" resolved "https://registry.yarnpkg.com/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz#7ee834dda8c7c17eff3118472bb35bfedaa34dde" @@ -2839,13 +3439,12 @@ supports-preserve-symlinks-flag@^1.0.0: resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== -synckit@^0.9.1: - version "0.9.1" - resolved "https://registry.yarnpkg.com/synckit/-/synckit-0.9.1.tgz#febbfbb6649979450131f64735aa3f6c14575c88" - integrity sha512-7gr8p9TQP6RAHusBOSLs46F4564ZrjV8xFmw5zCmgmhGUcw2hxsShhJ6CEiHQMgPDwAQ1fWHPM0ypc4RMAig4A== +synckit@^0.11.7: + version "0.11.11" + resolved "https://registry.yarnpkg.com/synckit/-/synckit-0.11.11.tgz#c0b619cf258a97faa209155d9cd1699b5c998cb0" + integrity sha512-MeQTA1r0litLUf0Rp/iisCaL8761lKAZHaimlbGK4j0HysC4PLfqygQj9srcs0m2RdtDYnF8UuYyKpbjHYp7Jw== dependencies: - "@pkgr/core" "^0.1.0" - tslib "^2.6.2" + "@pkgr/core" "^0.2.9" tapable@^2.2.0: version "2.2.1" @@ -2882,10 +3481,10 @@ tom-select@2.4.3: "@orchidjs/sifter" "^1.1.0" "@orchidjs/unicode-variants" "^1.1.2" -ts-api-utils@^1.3.0: - version "1.3.0" - resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-1.3.0.tgz#4b490e27129f1e8e686b45cc4ab63714dc60eea1" - integrity sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ== +ts-api-utils@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/ts-api-utils/-/ts-api-utils-2.1.0.tgz#595f7094e46eed364c13fd23e75f9513d29baf91" + integrity sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ== tsconfig-paths@^3.15.0: version "3.15.0" @@ -2902,11 +3501,6 @@ tslib@^2.0.0, tslib@^2.1.0, tslib@^2.3.1: resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae" integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q== -tslib@^2.6.2: - version "2.6.3" - resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.3.tgz#0438f810ad7a9edcde7a241c3d80db693c8cbfe0" - integrity sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ== - type-check@^0.4.0, type-check@~0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/type-check/-/type-check-0.4.0.tgz#07b8203bfa7056c0657050e3ccd2c37730bab8f1" @@ -2928,6 +3522,15 @@ typed-array-buffer@^1.0.2: es-errors "^1.3.0" is-typed-array "^1.1.13" +typed-array-buffer@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz#a72395450a4869ec033fd549371b47af3a2ee536" + integrity sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw== + dependencies: + call-bound "^1.0.3" + es-errors "^1.3.0" + is-typed-array "^1.1.14" + typed-array-byte-length@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/typed-array-byte-length/-/typed-array-byte-length-1.0.1.tgz#d92972d3cff99a3fa2e765a28fcdc0f1d89dec67" @@ -2939,6 +3542,17 @@ typed-array-byte-length@^1.0.1: has-proto "^1.0.3" is-typed-array "^1.1.13" +typed-array-byte-length@^1.0.3: + version "1.0.3" + resolved "https://registry.yarnpkg.com/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz#8407a04f7d78684f3d252aa1a143d2b77b4160ce" + integrity sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg== + dependencies: + call-bind "^1.0.8" + for-each "^0.3.3" + gopd "^1.2.0" + has-proto "^1.2.0" + is-typed-array "^1.1.14" + typed-array-byte-offset@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/typed-array-byte-offset/-/typed-array-byte-offset-1.0.2.tgz#f9ec1acb9259f395093e4567eb3c28a580d02063" @@ -2951,6 +3565,19 @@ typed-array-byte-offset@^1.0.2: has-proto "^1.0.3" is-typed-array "^1.1.13" +typed-array-byte-offset@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz#ae3698b8ec91a8ab945016108aef00d5bff12355" + integrity sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ== + dependencies: + available-typed-arrays "^1.0.7" + call-bind "^1.0.8" + for-each "^0.3.3" + gopd "^1.2.0" + has-proto "^1.2.0" + is-typed-array "^1.1.15" + reflect.getprototypeof "^1.0.9" + typed-array-length@^1.0.6: version "1.0.6" resolved "https://registry.yarnpkg.com/typed-array-length/-/typed-array-length-1.0.6.tgz#57155207c76e64a3457482dfdc1c9d1d3c4c73a3" @@ -2963,6 +3590,18 @@ typed-array-length@^1.0.6: is-typed-array "^1.1.13" possible-typed-array-names "^1.0.0" +typed-array-length@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/typed-array-length/-/typed-array-length-1.0.7.tgz#ee4deff984b64be1e118b0de8c9c877d5ce73d3d" + integrity sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg== + dependencies: + call-bind "^1.0.7" + for-each "^0.3.3" + gopd "^1.0.1" + is-typed-array "^1.1.13" + possible-typed-array-names "^1.0.0" + reflect.getprototypeof "^1.0.6" + typeface-inter@3.18.1: version "3.18.1" resolved "https://registry.yarnpkg.com/typeface-inter/-/typeface-inter-3.18.1.tgz#24cccdf29923f318589783997be20a662cd3ab9c" @@ -2993,6 +3632,16 @@ unbox-primitive@^1.0.2: has-symbols "^1.0.3" which-boxed-primitive "^1.0.2" +unbox-primitive@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/unbox-primitive/-/unbox-primitive-1.1.0.tgz#8d9d2c9edeea8460c7f35033a88867944934d1e2" + integrity sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw== + dependencies: + call-bound "^1.0.3" + has-bigints "^1.0.2" + has-symbols "^1.1.0" + which-boxed-primitive "^1.1.1" + undici-types@~6.18.2: version "6.18.2" resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.18.2.tgz#8b678cf939d4fc9ec56be3c68ed69c619dee28b0" @@ -3036,6 +3685,46 @@ which-boxed-primitive@^1.0.2: is-string "^1.0.5" is-symbol "^1.0.3" +which-boxed-primitive@^1.1.0, which-boxed-primitive@^1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz#d76ec27df7fa165f18d5808374a5fe23c29b176e" + integrity sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA== + dependencies: + is-bigint "^1.1.0" + is-boolean-object "^1.2.1" + is-number-object "^1.1.1" + is-string "^1.1.1" + is-symbol "^1.1.1" + +which-builtin-type@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/which-builtin-type/-/which-builtin-type-1.2.1.tgz#89183da1b4907ab089a6b02029cc5d8d6574270e" + integrity sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q== + dependencies: + call-bound "^1.0.2" + function.prototype.name "^1.1.6" + has-tostringtag "^1.0.2" + is-async-function "^2.0.0" + is-date-object "^1.1.0" + is-finalizationregistry "^1.1.0" + is-generator-function "^1.0.10" + is-regex "^1.2.1" + is-weakref "^1.0.2" + isarray "^2.0.5" + which-boxed-primitive "^1.1.0" + which-collection "^1.0.2" + which-typed-array "^1.1.16" + +which-collection@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/which-collection/-/which-collection-1.0.2.tgz#627ef76243920a107e7ce8e96191debe4b16c2a0" + integrity sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw== + dependencies: + is-map "^2.0.3" + is-set "^2.0.3" + is-weakmap "^2.0.2" + is-weakset "^2.0.3" + which-typed-array@^1.1.14, which-typed-array@^1.1.15: version "1.1.15" resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.15.tgz#264859e9b11a649b388bfaaf4f767df1f779b38d" @@ -3047,6 +3736,19 @@ which-typed-array@^1.1.14, which-typed-array@^1.1.15: gopd "^1.0.1" has-tostringtag "^1.0.2" +which-typed-array@^1.1.16, which-typed-array@^1.1.19: + version "1.1.19" + resolved "https://registry.yarnpkg.com/which-typed-array/-/which-typed-array-1.1.19.tgz#df03842e870b6b88e117524a4b364b6fc689f956" + integrity sha512-rEvr90Bck4WZt9HHFC4DJMsjvu7x+r6bImz0/BrbWb7A2djJ8hnZMrWnHo9F8ssv0OMErasDhftrfROTyqSDrw== + dependencies: + available-typed-arrays "^1.0.7" + call-bind "^1.0.8" + call-bound "^1.0.4" + for-each "^0.3.5" + get-proto "^1.0.1" + gopd "^1.2.0" + has-tostringtag "^1.0.2" + which@^2.0.1: version "2.0.2" resolved "https://registry.yarnpkg.com/which/-/which-2.0.2.tgz#7c6a8dd0a636a0327e10b59c9286eee93f3f51b1" diff --git a/netbox/release.yaml b/netbox/release.yaml index 3d4d74e6a..0115419cd 100644 --- a/netbox/release.yaml +++ b/netbox/release.yaml @@ -1,3 +1,3 @@ -version: "4.3.3" +version: "4.3.5" edition: "Community" -published: "2025-06-26" +published: "2025-07-29" diff --git a/netbox/templates/account/bookmarks.html b/netbox/templates/account/bookmarks.html index f7aa9bf57..2e3ff5691 100644 --- a/netbox/templates/account/bookmarks.html +++ b/netbox/templates/account/bookmarks.html @@ -24,9 +24,7 @@ {# Form buttons #}

- {% if 'bulk_delete' in actions %} - {% bulk_delete_button model query_params=request.GET %} - {% endif %} + {% bulk_delete_button model query_params=request.GET %}
{% endblock %} diff --git a/netbox/templates/account/notifications.html b/netbox/templates/account/notifications.html index 5a471ef25..46feb80b0 100644 --- a/netbox/templates/account/notifications.html +++ b/netbox/templates/account/notifications.html @@ -24,9 +24,7 @@ {# Form buttons #}
- {% if 'bulk_delete' in actions %} - {% bulk_delete_button model query_params=request.GET %} - {% endif %} + {% bulk_delete_button model query_params=request.GET %}
{% endblock %} diff --git a/netbox/templates/account/subscriptions.html b/netbox/templates/account/subscriptions.html index d97053d63..0ae1060cc 100644 --- a/netbox/templates/account/subscriptions.html +++ b/netbox/templates/account/subscriptions.html @@ -24,9 +24,7 @@ {# Form buttons #}
- {% if 'bulk_delete' in actions %} - {% bulk_delete_button model query_params=request.GET %} - {% endif %} + {% bulk_delete_button model query_params=request.GET %}
{% endblock %} diff --git a/netbox/templates/base/layout.html b/netbox/templates/base/layout.html index 7fce92c5c..c31896256 100644 --- a/netbox/templates/base/layout.html +++ b/netbox/templates/base/layout.html @@ -55,7 +55,7 @@ Blocks: {# Release info #}
{{ settings.RELEASE.name }} - {% if not settings.RELEASE.features.commercial %} + {% if not settings.RELEASE.features.commercial and not settings.ISOLATED_DEPLOYMENT %}
{% trans "Get" %} Cloud | {% trans "Get" %} Enterprise @@ -184,7 +184,7 @@ Blocks: {% endif %} {# Commercial links #} - {% if settings.RELEASE.features.commercial %} + {% if settings.RELEASE.features.commercial and not settings.ISOLATED_DEPLOYMENT %} {# LinkedIn #}
  • @@ -199,7 +199,7 @@ Blocks:
  • {# Community links #} - {% else %} + {% elif not settings.ISOLATED_DEPLOYMENT %} {# GitHub #}
  • diff --git a/netbox/templates/core/buttons/bulk_sync.html b/netbox/templates/core/buttons/bulk_sync.html index e92ad15df..e70b3a459 100644 --- a/netbox/templates/core/buttons/bulk_sync.html +++ b/netbox/templates/core/buttons/bulk_sync.html @@ -1,3 +1,3 @@ - diff --git a/netbox/templates/core/job.html b/netbox/templates/core/job.html index 49fa0231a..ae6e4d63d 100644 --- a/netbox/templates/core/job.html +++ b/netbox/templates/core/job.html @@ -1,27 +1,6 @@ -{% extends 'generic/object.html' %} -{% load buttons %} -{% load helpers %} -{% load perms %} +{% extends 'core/job/base.html' %} {% load i18n %} -{% block breadcrumbs %} - {{ block.super }} - {% if object.object %} -
  • - {% with parent_jobs_viewname=object.object|viewname:"jobs" %} - - {% endwith %} - {% else %} - - {% endif %} -{% endblock breadcrumbs %} - {% block content %}
    diff --git a/netbox/templates/core/job/base.html b/netbox/templates/core/job/base.html new file mode 100644 index 000000000..a31c53a8a --- /dev/null +++ b/netbox/templates/core/job/base.html @@ -0,0 +1,23 @@ +{% extends 'generic/object.html' %} +{% load buttons %} +{% load helpers %} +{% load perms %} +{% load i18n %} + +{% block breadcrumbs %} + {{ block.super }} + {% if object.object %} + + {% with parent_jobs_viewname=object.object|viewname:"jobs" %} + + {% endwith %} + {% else %} + + {% endif %} +{% endblock breadcrumbs %} diff --git a/netbox/templates/core/job/log.html b/netbox/templates/core/job/log.html new file mode 100644 index 000000000..b8c727299 --- /dev/null +++ b/netbox/templates/core/job/log.html @@ -0,0 +1,12 @@ +{% extends 'core/job/base.html' %} +{% load render_table from django_tables2 %} + +{% block content %} +
    +
    +
    + {% render_table table %} +
    +
    +
    +{% endblock %} diff --git a/netbox/templates/core/objectchange.html b/netbox/templates/core/objectchange.html index ae32e44db..e4c7d4900 100644 --- a/netbox/templates/core/objectchange.html +++ b/netbox/templates/core/objectchange.html @@ -64,10 +64,16 @@ {% endif %} + + {% trans "Message" %} + + {{ object.message|placeholder }} + + {% trans "Request ID" %} - {{ object.request_id }} + {{ object.request_id }} diff --git a/netbox/templates/dcim/buttons/bulk_disconnect.html b/netbox/templates/dcim/buttons/bulk_disconnect.html index 9ab53472b..0c9a5fc16 100644 --- a/netbox/templates/dcim/buttons/bulk_disconnect.html +++ b/netbox/templates/dcim/buttons/bulk_disconnect.html @@ -1,3 +1,3 @@ - diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 10352d45a..f8b8e95c2 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -305,11 +305,11 @@ {% endif %}

    - {% trans "Services" %} + {% trans "Application Services" %} {% if perms.ipam.add_service %} {% endif %} diff --git a/netbox/templates/dcim/inc/rack_elevation.html b/netbox/templates/dcim/inc/rack_elevation.html index 33037c1ff..d2fb62e0d 100644 --- a/netbox/templates/dcim/inc/rack_elevation.html +++ b/netbox/templates/dcim/inc/rack_elevation.html @@ -1,6 +1,17 @@ {% load i18n %} -
    - +
    +
    +
    +
    + {% trans "Loading..." %} +
    +
    +
    {% trans "Cancel" %} diff --git a/netbox/templates/extras/customfieldchoiceset.html b/netbox/templates/extras/customfieldchoiceset.html index 885f21713..85f642603 100644 --- a/netbox/templates/extras/customfieldchoiceset.html +++ b/netbox/templates/extras/customfieldchoiceset.html @@ -14,7 +14,7 @@ Description - {{ object.description|markdown|placeholder }} + {{ object.description|placeholder }} Base Choices diff --git a/netbox/templates/extras/imageattachment.html b/netbox/templates/extras/imageattachment.html index 1968344cc..8aefd095b 100644 --- a/netbox/templates/extras/imageattachment.html +++ b/netbox/templates/extras/imageattachment.html @@ -1,4 +1,67 @@ {% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} +{% load i18n %} -{% block tabs %} +{% block content %} +
    +
    +
    +

    {% trans "Image Attachment" %}

    + + + + + + + + + + + + + +
    {% trans "Parent Object" %}{{ object.parent|linkify }}
    {% trans "Name" %}{{ object.name|placeholder }}
    {% trans "Description" %}{{ object.description|placeholder }}
    +
    + {% plugin_left_page object %} +
    +
    +
    +

    {% trans "File" %}

    + + + + + + + + + + + + + +
    {% trans "Filename" %} + {{ object.filename }} + +
    {% trans "Dimensions" %}{{ object.image_width }} × {{ object.image_height }}
    {% trans "Size" %} + {{ object.size|filesizeformat }} +
    +
    + {% plugin_right_page object %} +
    +
    +
    +
    +
    +

    {% trans "Image" %}

    + +
    + {% plugin_full_width_page object %} +
    +
    {% endblock %} diff --git a/netbox/templates/extras/object_imageattachments.html b/netbox/templates/extras/object_imageattachments.html new file mode 100644 index 000000000..0f6947a95 --- /dev/null +++ b/netbox/templates/extras/object_imageattachments.html @@ -0,0 +1,46 @@ +{% extends base_template %} +{% load helpers %} +{% load i18n %} +{% load render_table from django_tables2 %} +{% load thumbnail %} + +{% block extra_controls %} + {% if perms.extras.add_imageattachment %} + {% with viewname=object|viewname:"image-attachments" %} + + {% trans "Attach an Image" %} + + {% endwith %} + {% endif %} +{% endblock %} + +{% block content %} + {% if image_attachments %} +
    + {% for object in image_attachments %} +
    + {% thumbnail object.image "200x200" crop="center" as tn %} + + {{ object.description|default:object.name }} + + {% endthumbnail %} +
    + {{ object }} +
    +
    + {% endfor %} +
    + {% else %} +
    + {% blocktrans with object_type=object|meta:"verbose_name" %} + No images have been attached to this {{ object_type }}. + {% endblocktrans %} +
    + {% endif %} +{% endblock %} diff --git a/netbox/templates/generic/bulk_delete.html b/netbox/templates/generic/bulk_delete.html index 4e3eecd8e..13e004aed 100644 --- a/netbox/templates/generic/bulk_delete.html +++ b/netbox/templates/generic/bulk_delete.html @@ -1,7 +1,8 @@ {% extends 'generic/_base.html' %} +{% load form_helpers %} {% load helpers %} -{% load render_table from django_tables2 %} {% load i18n %} +{% load render_table from django_tables2 %} {% comment %} Blocks: @@ -58,13 +59,26 @@ Context:
    {% csrf_token %} + + {# Form fields #} {% for field in form.hidden_fields %} {{ field }} {% endfor %} + + {# Meta fields #} +
    + {% if form.changelog_message %} + {% render_field form.changelog_message %} + {% endif %} + {% render_field form.background_job %} +
    + + {# Form buttons #}
    {% trans "Cancel" %}
    +
    diff --git a/netbox/templates/generic/bulk_edit.html b/netbox/templates/generic/bulk_edit.html index 8c4d305ec..58bf6dbc2 100644 --- a/netbox/templates/generic/bulk_edit.html +++ b/netbox/templates/generic/bulk_edit.html @@ -1,8 +1,8 @@ {% extends 'generic/_base.html' %} -{% load helpers %} {% load form_helpers %} -{% load render_table from django_tables2 %} +{% load helpers %} {% load i18n %} +{% load render_table from django_tables2 %} {% comment %} Blocks: @@ -102,6 +102,14 @@ Context: {% endif %} + {# Meta fields #} +
    + {% if form.changelog_message %} + {% render_field form.changelog_message %} + {% endif %} + {% render_field form.background_job %} +
    +
    {% trans "Cancel" %} diff --git a/netbox/templates/generic/bulk_import.html b/netbox/templates/generic/bulk_import.html index 3a652d3e9..0e83afcb3 100644 --- a/netbox/templates/generic/bulk_import.html +++ b/netbox/templates/generic/bulk_import.html @@ -1,6 +1,6 @@ {% extends 'generic/_base.html' %} -{% load helpers %} {% load form_helpers %} +{% load helpers %} {% load i18n %} {% comment %} @@ -42,24 +42,31 @@ Context: {# Data Import Form #}
    -
    -
    -
    - {% csrf_token %} - - {% render_field form.data %} - {% render_field form.format %} - {% render_field form.csv_delimiter %} -
    -
    - {% if return_url %} - {% trans "Cancel" %} - {% endif %} - -
    +
    + + {% csrf_token %} + + + {# Form fields #} + {% render_field form.data %} + {% render_field form.format %} + {% render_field form.csv_delimiter %} + + {# Meta fields #} +
    + {% render_field form.changelog_message %} + {% render_field form.background_job %} +
    + +
    +
    + {% if return_url %} + {% trans "Cancel" %} + {% endif %} +
    - -
    +
    +
    @@ -69,9 +76,17 @@ Context:
    {% csrf_token %} + + {# Form fields #} {% render_field form.upload_file %} {% render_field form.format %} {% render_field form.csv_delimiter %} + + {# Meta fields #} +
    + {% render_field form.changelog_message %} +
    +
    {% if return_url %} @@ -90,10 +105,19 @@ Context: {% csrf_token %} + + {# Form fields #} {% render_field form.data_source %} {% render_field form.data_file %} {% render_field form.format %} {% render_field form.csv_delimiter %} + + {# Meta fields #} +
    + {% render_field form.changelog_message %} + {% render_field form.background_job %} +
    +
    {% if return_url %} diff --git a/netbox/templates/generic/object_children.html b/netbox/templates/generic/object_children.html index b9eabdc9d..da3ce9814 100644 --- a/netbox/templates/generic/object_children.html +++ b/netbox/templates/generic/object_children.html @@ -35,7 +35,7 @@ Context:
    {% block bulk_controls %} - {% action_buttons actions model multi=True %} + {% action_buttons actions model multi=True return_url=request.path %} {% block bulk_extra_controls %}{% endblock %} {% endblock bulk_controls %}
    diff --git a/netbox/templates/generic/object_delete.html b/netbox/templates/generic/object_delete.html index bde8830d9..71d8d3984 100644 --- a/netbox/templates/generic/object_delete.html +++ b/netbox/templates/generic/object_delete.html @@ -20,7 +20,7 @@ Context: {% endblock %} {% block content %} -