diff --git a/.github/ISSUE_TEMPLATE/01-feature_request.yaml b/.github/ISSUE_TEMPLATE/01-feature_request.yaml index f07db712d..651c26942 100644 --- a/.github/ISSUE_TEMPLATE/01-feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/01-feature_request.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v4.1.3 + placeholder: v4.1.5 validations: required: true - type: dropdown @@ -36,9 +36,8 @@ body: options: - I volunteer to perform this work (if approved) - I'm a NetBox Labs customer - - This is a very minor change - N/A - default: 3 + default: 2 validations: required: true - type: textarea diff --git a/.github/ISSUE_TEMPLATE/02-bug_report.yaml b/.github/ISSUE_TEMPLATE/02-bug_report.yaml index 892981402..2984e3d10 100644 --- a/.github/ISSUE_TEMPLATE/02-bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/02-bug_report.yaml @@ -31,16 +31,15 @@ body: options: - I volunteer to perform this work (if approved) - I'm a NetBox Labs customer - - This is preventing me from using NetBox - N/A - default: 3 + default: 2 validations: required: true - type: input attributes: label: NetBox Version description: What version of NetBox are you currently running? - placeholder: v4.1.3 + placeholder: v4.1.5 validations: required: true - type: dropdown diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c3b4876c3..d77da90e3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: strategy: matrix: python-version: ['3.10', '3.11', '3.12'] - node-version: ['18.x'] + node-version: ['20.x'] services: redis: image: redis diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 000000000..09f935b61 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,44 @@ +repos: +- repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.6.9 + hooks: + - id: ruff + name: "Ruff linter" + args: [ netbox/ ] +- repo: local + hooks: + - id: django-check + name: "Django system check" + description: "Run Django's internal check for common problems" + entry: python netbox/manage.py check + language: system + pass_filenames: false + types: [python] + - id: django-makemigrations + name: "Django migrations check" + description: "Check for any missing Django migrations" + entry: python netbox/manage.py makemigrations --check + language: system + pass_filenames: false + types: [python] + - id: mkdocs-build + name: "Build documentation" + description: "Build the documentation with mkdocs" + files: 'docs/' + entry: mkdocs build + language: system + pass_filenames: false + - id: yarn-validate + name: "Yarn validate" + description: "Check UI ESLint, TypeScript, and Prettier compliance" + files: 'netbox/project-static/' + entry: yarn --cwd netbox/project-static validate + language: system + pass_filenames: false + - id: verify-bundles + name: "Verify static asset bundles" + description: "Ensure that any modified static assets have been compiled" + files: 'netbox/project-static/' + entry: scripts/verify-bundles.sh + language: system + pass_filenames: false diff --git a/README.md b/README.md index 0c793b8a4..e3829c2cc 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@
- NetBox logo + NetBox logo

The cornerstone of every automated network

Latest release License diff --git a/base_requirements.txt b/base_requirements.txt index 76955a6e1..2147731e2 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -42,7 +42,7 @@ django-rich # Django integration for RQ (Reqis queuing) # https://github.com/rq/django-rq/blob/master/CHANGELOG.md -django-rq +django-rq<3.0 # Abstraction models for rendering and paginating HTML tables # https://github.com/jieter/django-tables2/blob/master/CHANGELOG.md @@ -116,6 +116,10 @@ PyYAML # https://github.com/psf/requests/blob/main/HISTORY.md requests +# rq +# https://github.com/rq/rq/blob/master/CHANGES.md +rq<2.0 + # Social authentication framework # https://github.com/python-social-auth/social-core/blob/master/CHANGELOG.md social-auth-core diff --git a/contrib/generated_schema.json b/contrib/generated_schema.json index 1152d6974..56ddee50e 100644 --- a/contrib/generated_schema.json +++ b/contrib/generated_schema.json @@ -12,6 +12,9 @@ "left-to-right", "right-to-left", "side-to-rear", + "rear-to-side", + "bottom-to-top", + "top-to-bottom", "passive", "mixed" ] diff --git a/docs/administration/authentication/google.md b/docs/administration/authentication/google.md new file mode 100644 index 000000000..456f3a457 --- /dev/null +++ b/docs/administration/authentication/google.md @@ -0,0 +1,52 @@ +# Google + +This guide explains how to configure single sign-on (SSO) support for NetBox using [Google OAuth2](https://developers.google.com/identity/protocols/oauth2/web-server) as an authentication backend. + +## Google OAuth2 Configuration + +1. Log into [console.cloud.google.com](https://console.cloud.google.com/). +2. Create new project for NetBox. +3. Under "APIs and Services" click "OAuth consent screen" and enter the required information. +4. Under "Credentials," click "Create Credentials" and select "OAuth 2.0 Client ID." Select type "Web application." + - "Authorized JavaScript origins" should follow the format `http[s]://[:]` + - "Authorized redirect URIs" should follow the format `http[s]://[:]/oauth/complete/google-oauth2/` +5. Copy the "Client ID" and "Client Secret" values somewhere convenient. + +!!! note + Google requires the NetBox hostname to use a public top-level-domain (e.g. `.com`, `.net`). The use of IP addresses is not permitted (except `127.0.0.1`). + +For more information, consult [Google's documentation](https://developers.google.com/identity/protocols/oauth2/web-server#prerequisites). + +## NetBox Configuration + +### 1. Enter configuration parameters + +Enter the following configuration parameters in `configuration.py`, substituting your own values: + +```python +REMOTE_AUTH_BACKEND = 'social_core.backends.google.GoogleOAuth2' +SOCIAL_AUTH_GOOGLE_OAUTH2_KEY = '{CLIENT_ID}' +SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET = '{CLIENT_SECRET}' +``` + +### 2. Restart NetBox + +Restart the NetBox services so that the new configuration takes effect. This is typically done with the command below: + +```no-highlight +sudo systemctl restart netbox +``` + +## Testing + +Log out of NetBox if already authenticated, and click the "Log In" button at top right. You should see the normal login form as well as an option to authenticate using Google. Click that link. + +![NetBox Google login form](../../media/authentication/netbox_google_login.png) + +You should be redirected to Google's authentication portal. Enter the username/email and password of your test account to continue. You may also be prompted to grant this application access to your account. + +![NetBox Google login form](../../media/authentication/google_login_portal.png) + +If successful, you will be redirected back to the NetBox UI, and will be logged in as the Google user. You can verify this by navigating to your profile (using the button at top right). + +This user account has been replicated locally to NetBox, and can now be assigned groups and permissions. diff --git a/docs/administration/authentication/microsoft-entra-id.md b/docs/administration/authentication/microsoft-entra-id.md index 3451c656f..b44499fbe 100644 --- a/docs/administration/authentication/microsoft-entra-id.md +++ b/docs/administration/authentication/microsoft-entra-id.md @@ -16,7 +16,7 @@ Under the Azure Active Directory dashboard, navigate to **Add > App registration Enter a name for the registration (e.g. "NetBox") and ensure that the "single tenant" option is selected. -Under "Redirect URI", select "Web" for the platform and enter the path to your NetBox installation, ending with `/oauth/complete/entraid-oauth2/`. Note that this URI **must** begin with `https://` unless you are referencing localhost (for development purposes). +Under "Redirect URI", select "Web" for the platform and enter the path to your NetBox installation, ending with `/oauth/complete/azuread-oauth2/`. Note that this URI **must** begin with `https://` unless you are referencing localhost (for development purposes). ![App registration parameters](../../media/authentication/azure_ad_app_registration.png) diff --git a/docs/configuration/miscellaneous.md b/docs/configuration/miscellaneous.md index 576eb8739..b983acd80 100644 --- a/docs/configuration/miscellaneous.md +++ b/docs/configuration/miscellaneous.md @@ -106,6 +106,15 @@ By default, NetBox will prevent the creation of duplicate prefixes and IP addres --- +## EVENTS_PIPELINE + + +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. + +--- + ## FILE_UPLOAD_MAX_MEMORY_SIZE Default: `2621440` (2.5 MB) diff --git a/docs/customization/custom-scripts.md b/docs/customization/custom-scripts.md index 3fa6491d2..1051b31f6 100644 --- a/docs/customization/custom-scripts.md +++ b/docs/customization/custom-scripts.md @@ -72,6 +72,9 @@ script_order = (MyCustomScript, AnotherCustomScript) Script attributes are defined under a class named `Meta` within the script. These are optional, but encouraged. +!!! warning + These are also defined and used as properties on the base custom script class, so don't use the same names as variables or override them in your custom script. + ### `name` This is the human-friendly names of your script. If omitted, the class name will be used. diff --git a/docs/development/adding-models.md b/docs/development/adding-models.md index f3aa9cfcc..0bf020662 100644 --- a/docs/development/adding-models.md +++ b/docs/development/adding-models.md @@ -8,7 +8,7 @@ Each model should define, at a minimum: * A `Meta` class specifying a deterministic ordering (if ordered by fields other than the primary ID) * A `__str__()` method returning a user-friendly string representation of the instance -* A `get_absolute_url()` method returning an instance's direct URL (using `reverse()`) +* A `get_absolute_url()` method if necessary; a standard version of the method is defined in the `NetBoxFeatureSet` base class, but you will need to provide your own (returning an instance's direct URL using `reverse()`) if not subclassing that base class ## 2. Define field choices @@ -78,6 +78,8 @@ Create the following for each model: Create a GraphQL object type for the model in `graphql/types.py` by subclassing the appropriate class from `netbox.graphql.types`. +**Note:** GraphQL unit tests may fail citing null values on a non-nullable field if related objects are prefetched. You may need to fix this by setting the type annotation to be `= strawberry_django.field(select_related=["policy"])` or similar. + Also extend the schema class defined in `graphql/schema.py` with the individual object and object list fields per the established convention. ## 14. Add tests diff --git a/docs/development/getting-started.md b/docs/development/getting-started.md index 374ed34b0..6e425d5a3 100644 --- a/docs/development/getting-started.md +++ b/docs/development/getting-started.md @@ -62,22 +62,7 @@ $issue-$description The description should be just two or three words to imply the focus of the work being performed. For example, bug #1234 to fix a TypeError exception when creating a device might be named `1234-device-typerror`. This ensures that branches are always follow some logical ordering (e.g. when running `git branch -a`) and helps other developers quickly identify the purpose of each. -### 3. Enable Pre-Commit Hooks - -NetBox ships with a [git pre-commit hook](https://githooks.com/) script that automatically checks for style compliance and missing database migrations prior to committing changes. This helps avoid erroneous commits that result in CI test failures. You are encouraged to enable it by creating a link to `scripts/git-hooks/pre-commit`: - -```no-highlight -cd .git/hooks/ -ln -s ../../scripts/git-hooks/pre-commit -``` -For the pre-commit hooks to work, you will also need to install the [ruff](https://docs.astral.sh/ruff/) linter: - -```no-highlight -python -m pip install ruff -``` -...and set up the yarn packages as shown in the [Web UI Development Guide](web-ui.md) - -### 4. Create a Python Virtual Environment +### 3. Create a Python Virtual Environment A [virtual environment](https://docs.python.org/3/tutorial/venv.html) (or "venv" for short) is like a container for a set of Python packages. These allow you to build environments suited to specific projects without interfering with system packages or other projects. When installed per the documentation, NetBox uses a virtual environment in production. @@ -101,7 +86,7 @@ source ~/.venv/netbox/bin/activate Notice that the console prompt changes to indicate the active environment. This updates the necessary system environment variables to ensure that any Python scripts are run within the virtual environment. -### 5. Install Required Packages +### 4. Install Required Packages With the virtual environment activated, install the project's required Python packages using the `pip` module. Required packages are defined in `requirements.txt`. Each line in this file specifies the name and specific version of a required package. @@ -109,6 +94,26 @@ With the virtual environment activated, install the project's required Python pa python -m pip install -r requirements.txt ``` +### 5. Install Pre-Commit + +NetBox uses [`pre-commit`](https://pre-commit.com/) to automatically validate code when commiting new changes. This includes the following operations: + +* Run the `ruff` Python linter +* Run Django's internal system check +* Check for missing database migrations +* Validate any changes to the documentation with `mkdocs` +* Validate Typescript & Sass styling with `yarn` +* Ensure that any modified static front end assets have been recompiled + +Enable `pre-commit` with the following commands _prior_ to commiting any changes: + +```no-highlight +python -m pip install ruff pre-commit +pre-commit install +``` + +You may also need to set up the yarn packages as shown in the [Web UI Development Guide](web-ui.md). + ### 6. Configure NetBox Within the `netbox/netbox/` directory, copy `configuration_example.py` to `configuration.py` and update the following parameters: diff --git a/docs/development/style-guide.md b/docs/development/style-guide.md index 0d4caf395..9d6630de0 100644 --- a/docs/development/style-guide.md +++ b/docs/development/style-guide.md @@ -76,4 +76,4 @@ When adding a new dependency, a short description of the package and the URL of * When referring to NetBox in writing, use the proper form "NetBox," with the letters N and B capitalized. The lowercase form "netbox" should be used in code, filenames, etc. but never "Netbox" or any other deviation. -* There is an SVG form of the NetBox logo at [docs/netbox_logo.svg](../netbox_logo.svg). It is preferred to use this logo for all purposes as it scales to arbitrary sizes without loss of resolution. If a raster image is required, the SVG logo should be converted to a PNG image of the prescribed size. +* There are SVG forms of the NetBox logo for both [light mode](../netbox_logo_light.svg) and [dark mode](../netbox_logo_dark.svg) available. It is preferred to use the SVG logo for all purposes as it scales to arbitrary sizes without loss of resolution. If a raster image is required, the SVG logo should be converted to a PNG image of the desired size. diff --git a/docs/extra.css b/docs/extra.css index e953fa14c..4b8cd87fe 100644 --- a/docs/extra.css +++ b/docs/extra.css @@ -5,6 +5,10 @@ img { margin-right: auto; } +.md-content img { + background-color: rgba(255, 255, 255, 0.64); +} + /* Tables */ table { margin-bottom: 24px; diff --git a/docs/index.md b/docs/index.md index 5ef650ca6..a79ab03b4 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,4 +1,5 @@ -![NetBox](netbox_logo.svg "NetBox logo"){style="height: 100px; margin-bottom: 3em"} +![NetBox](netbox_logo_light.svg#only-light "NetBox logo"){style="height: 100px; margin-bottom: 3em; background: none;"} +![NetBox](netbox_logo_dark.svg#only-dark "NetBox logo"){style="height: 100px; margin-bottom: 3em; background: none;"} # The Premier Network Source of Truth diff --git a/docs/media/authentication/google_login_portal.png b/docs/media/authentication/google_login_portal.png new file mode 100644 index 000000000..55eefd872 Binary files /dev/null and b/docs/media/authentication/google_login_portal.png differ diff --git a/docs/media/authentication/netbox_google_login.png b/docs/media/authentication/netbox_google_login.png new file mode 100644 index 000000000..730173b5d Binary files /dev/null and b/docs/media/authentication/netbox_google_login.png differ diff --git a/docs/netbox_logo_dark.svg b/docs/netbox_logo_dark.svg new file mode 100644 index 000000000..958a1d401 --- /dev/null +++ b/docs/netbox_logo_dark.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/netbox_logo.svg b/docs/netbox_logo_light.svg similarity index 100% rename from docs/netbox_logo.svg rename to docs/netbox_logo_light.svg diff --git a/docs/plugins/development/index.md b/docs/plugins/development/index.md index f3f9a3e4f..8a83ab71b 100644 --- a/docs/plugins/development/index.md +++ b/docs/plugins/development/index.md @@ -114,6 +114,7 @@ NetBox looks for the `config` variable within a plugin's `__init__.py` to load i | `max_version` | Maximum version of NetBox with which the plugin is compatible | | `middleware` | A list of middleware classes to append after NetBox's build-in middleware | | `queues` | A list of custom background task queues to create | +| `events_pipeline` | A list of handlers to add to [`EVENTS_PIPELINE`](./miscellaneous.md#events_pipeline), identified by dotted paths | | `search_extensions` | The dotted path to the list of search index classes (default: `search.indexes`) | | `data_backends` | The dotted path to the list of data source backend classes (default: `data_backends.backends`) | | `template_extensions` | The dotted path to the list of template extension classes (default: `template_content.template_extensions`) | diff --git a/docs/release-notes/version-4.1.md b/docs/release-notes/version-4.1.md index 5b353d54f..741425ac1 100644 --- a/docs/release-notes/version-4.1.md +++ b/docs/release-notes/version-4.1.md @@ -1,11 +1,36 @@ # NetBox v4.1 -## v4.1.4 (FUTURE) +## v4.1.5 (2024-10-28) + +### Enhancements + +* [#17789](https://github.com/netbox-community/netbox/issues/17789) - Provide a single "scope" field for bulk editing VLAN group scope assignments + +### Bug Fixes + +* [#17358](https://github.com/netbox-community/netbox/issues/17358) - Fix validation of overlapping IP ranges +* [#17374](https://github.com/netbox-community/netbox/issues/17374) - Fix styling of highlighted table rows in dark mode +* [#17460](https://github.com/netbox-community/netbox/issues/17460) - Ensure bulk action buttons are consistent for device type components +* [#17635](https://github.com/netbox-community/netbox/issues/17635) - Ensure AbortTransaction is caught when running a custom script with `commit=False` +* [#17685](https://github.com/netbox-community/netbox/issues/17685) - Ensure background jobs are validated before being scheduled +* [#17710](https://github.com/netbox-community/netbox/issues/17710) - Remove cached fields on CableTermination model from GraphQL API +* [#17740](https://github.com/netbox-community/netbox/issues/17740) - Ensure support for image attachments with a `.webp` file extension +* [#17749](https://github.com/netbox-community/netbox/issues/17749) - Restore missing `devicetypes` and `children` fields for several objects in GraphQL API +* [#17754](https://github.com/netbox-community/netbox/issues/17754) - Remove paginator from version history table under plugin view +* [#17759](https://github.com/netbox-community/netbox/issues/17759) - Retain `job_timeout` value when scheduling a recurring custom script +* [#17774](https://github.com/netbox-community/netbox/issues/17774) - Fix SSO login support for Entra ID (formerly Azure AD) +* [#17802](https://github.com/netbox-community/netbox/issues/17802) - Fix background color for bulk rename buttons in list views +* [#17838](https://github.com/netbox-community/netbox/issues/17838) - Adjust `manage.py` to reference `python3` executable + +--- + +## v4.1.4 (2024-10-15) ### Enhancements * [#11671](https://github.com/netbox-community/netbox/issues/11671) - Display device's rack position in cable traces * [#15829](https://github.com/netbox-community/netbox/issues/15829) - Rename Microsoft Azure AD SSO backend to Microsoft Entra ID +* [#16009](https://github.com/netbox-community/netbox/issues/16009) - Float form & bulk operation buttons within UI * [#17079](https://github.com/netbox-community/netbox/issues/17079) - Introduce additional choices for device airflow direction * [#17216](https://github.com/netbox-community/netbox/issues/17216) - Add EVPN-VPWS L2VPN type * [#17655](https://github.com/netbox-community/netbox/issues/17655) - Limit the display of tagged VLANs within interface tables @@ -14,12 +39,16 @@ ### Bug Fixes * [#16024](https://github.com/netbox-community/netbox/issues/16024) - Fix AND/OR filtering in GraphQL API for selection fields +* [#17400](https://github.com/netbox-community/netbox/issues/17400) - Fix cable tracing across split paths * [#17562](https://github.com/netbox-community/netbox/issues/17562) - Fix GraphQL API query support for custom field choices * [#17566](https://github.com/netbox-community/netbox/issues/17566) - Fix AttributeError exception resulting from background jobs with no associated object type +* [#17614](https://github.com/netbox-community/netbox/issues/17614) - Disallow removal of a master device from its virtual chassis * [#17636](https://github.com/netbox-community/netbox/issues/17636) - Fix filtering of related objects when adding a power port, rear port, or inventory item template to a device type +* [#17644](https://github.com/netbox-community/netbox/issues/17644) - Correct sizing of logo & SSO icons on login page * [#17648](https://github.com/netbox-community/netbox/issues/17648) - Fix AttributeError exception when attempting to delete a background job under certain conditions * [#17663](https://github.com/netbox-community/netbox/issues/17663) - Fix extended lookups for choice field filters * [#17671](https://github.com/netbox-community/netbox/issues/17671) - Fix the display of rack types in global search results +* [#17713](https://github.com/netbox-community/netbox/issues/17713) - Fix UnboundLocalError exception when attempting to sync data source in parallel --- diff --git a/mkdocs.yml b/mkdocs.yml index 94a4edcb3..00e03a4ce 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -156,6 +156,7 @@ nav: - Administration: - Authentication: - Overview: 'administration/authentication/overview.md' + - Google: 'administration/authentication/google.md' - Microsoft Entra ID: 'administration/authentication/microsoft-entra-id.md' - Okta: 'administration/authentication/okta.md' - Permissions: 'administration/permissions.md' diff --git a/netbox/circuits/api/nested_serializers.py b/netbox/circuits/api/nested_serializers.py deleted file mode 100644 index 487749872..000000000 --- a/netbox/circuits/api/nested_serializers.py +++ /dev/null @@ -1,79 +0,0 @@ -import warnings - -from drf_spectacular.utils import extend_schema_serializer - -from circuits.models import * -from netbox.api.fields import RelatedObjectCountField -from netbox.api.serializers import WritableNestedSerializer -from .serializers_.nested import NestedProviderAccountSerializer - -__all__ = [ - 'NestedCircuitSerializer', - 'NestedCircuitTerminationSerializer', - 'NestedCircuitTypeSerializer', - 'NestedProviderNetworkSerializer', - 'NestedProviderSerializer', - 'NestedProviderAccountSerializer', -] - -# TODO: Remove in v4.2 -warnings.warn( - "Dedicated nested serializers will be removed in NetBox v4.2. Use Serializer(nested=True) instead.", - DeprecationWarning -) - - -# -# Provider networks -# - -class NestedProviderNetworkSerializer(WritableNestedSerializer): - - class Meta: - model = ProviderNetwork - fields = ['id', 'url', 'display_url', 'display', 'name'] - - -# -# Providers -# - -@extend_schema_serializer( - exclude_fields=('circuit_count',), -) -class NestedProviderSerializer(WritableNestedSerializer): - circuit_count = RelatedObjectCountField('circuits') - - class Meta: - model = Provider - fields = ['id', 'url', 'display_url', 'display', 'name', 'slug', 'circuit_count'] - - -# -# Circuits -# - -@extend_schema_serializer( - exclude_fields=('circuit_count',), -) -class NestedCircuitTypeSerializer(WritableNestedSerializer): - circuit_count = RelatedObjectCountField('circuits') - - class Meta: - model = CircuitType - fields = ['id', 'url', 'display_url', 'display', 'name', 'slug', 'circuit_count'] - - -class NestedCircuitSerializer(WritableNestedSerializer): - - class Meta: - model = Circuit - fields = ['id', 'url', 'display_url', 'display', 'cid'] - - -class NestedCircuitTerminationSerializer(WritableNestedSerializer): - circuit = NestedCircuitSerializer() - - class Meta: - model = CircuitTermination - fields = ['id', 'url', 'display_url', 'display', 'circuit', 'term_side', 'cable', '_occupied'] diff --git a/netbox/circuits/migrations/0046_charfield_null_choices.py b/netbox/circuits/migrations/0046_charfield_null_choices.py new file mode 100644 index 000000000..4ec21b750 --- /dev/null +++ b/netbox/circuits/migrations/0046_charfield_null_choices.py @@ -0,0 +1,43 @@ +from django.db import migrations, models + + +def set_null_values(apps, schema_editor): + """ + Replace empty strings with null values. + """ + Circuit = apps.get_model('circuits', 'Circuit') + CircuitGroupAssignment = apps.get_model('circuits', 'CircuitGroupAssignment') + CircuitTermination = apps.get_model('circuits', 'CircuitTermination') + + Circuit.objects.filter(distance_unit='').update(distance_unit=None) + CircuitGroupAssignment.objects.filter(priority='').update(priority=None) + CircuitTermination.objects.filter(cable_end='').update(cable_end=None) + + +class Migration(migrations.Migration): + + dependencies = [ + ('circuits', '0045_circuit_distance'), + ] + + operations = [ + migrations.AlterField( + model_name='circuit', + name='distance_unit', + field=models.CharField(blank=True, max_length=50, null=True), + ), + migrations.AlterField( + model_name='circuitgroupassignment', + name='priority', + field=models.CharField(blank=True, max_length=50, null=True), + ), + migrations.AlterField( + model_name='circuittermination', + name='cable_end', + field=models.CharField(blank=True, max_length=1, null=True), + ), + migrations.RunPython( + code=set_null_values, + reverse_code=migrations.RunPython.noop + ), + ] diff --git a/netbox/circuits/models/circuits.py b/netbox/circuits/models/circuits.py index 2df83e97e..5f749550c 100644 --- a/netbox/circuits/models/circuits.py +++ b/netbox/circuits/models/circuits.py @@ -187,7 +187,8 @@ class CircuitGroupAssignment(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, verbose_name=_('priority'), max_length=50, choices=CircuitPriorityChoices, - blank=True + blank=True, + null=True ) prerequisite_models = ( 'circuits.Circuit', diff --git a/netbox/core/api/nested_serializers.py b/netbox/core/api/nested_serializers.py deleted file mode 100644 index df7b41ca7..000000000 --- a/netbox/core/api/nested_serializers.py +++ /dev/null @@ -1,47 +0,0 @@ -import warnings - -from rest_framework import serializers - -from core.choices import JobStatusChoices -from core.models import * -from netbox.api.fields import ChoiceField -from netbox.api.serializers import WritableNestedSerializer -from users.api.serializers import UserSerializer - -__all__ = ( - 'NestedDataFileSerializer', - 'NestedDataSourceSerializer', - 'NestedJobSerializer', -) - -# TODO: Remove in v4.2 -warnings.warn( - "Dedicated nested serializers will be removed in NetBox v4.2. Use Serializer(nested=True) instead.", - DeprecationWarning -) - - -class NestedDataSourceSerializer(WritableNestedSerializer): - - class Meta: - model = DataSource - fields = ['id', 'url', 'display_url', 'display', 'name'] - - -class NestedDataFileSerializer(WritableNestedSerializer): - - class Meta: - model = DataFile - fields = ['id', 'url', 'display_url', 'display', 'path'] - - -class NestedJobSerializer(serializers.ModelSerializer): - status = ChoiceField(choices=JobStatusChoices) - user = UserSerializer( - nested=True, - read_only=True - ) - - class Meta: - model = Job - fields = ['url', 'display_url', 'created', 'completed', 'user', 'status'] diff --git a/netbox/core/models/jobs.py b/netbox/core/models/jobs.py index e1b5715dd..3cfea3e2a 100644 --- a/netbox/core/models/jobs.py +++ b/netbox/core/models/jobs.py @@ -130,7 +130,7 @@ class Job(models.Model): super().clean() # Validate the assigned object type - if self.object_type not in ObjectType.objects.with_feature('jobs'): + if self.object_type and self.object_type not in ObjectType.objects.with_feature('jobs'): raise ValidationError( _("Jobs cannot be assigned to this object type ({type}).").format(type=self.object_type) ) @@ -223,7 +223,7 @@ class Job(models.Model): rq_queue_name = get_queue_for_model(object_type.model if object_type else None) queue = django_rq.get_queue(rq_queue_name) status = JobStatusChoices.STATUS_SCHEDULED if schedule_at else JobStatusChoices.STATUS_PENDING - job = Job.objects.create( + job = Job( object_type=object_type, object_id=object_id, name=name, @@ -233,6 +233,8 @@ class Job(models.Model): user=user, job_id=uuid.uuid4() ) + job.full_clean() + job.save() # Run the job immediately, rather than enqueuing it as a background task. Note that this is a synchronous # (blocking) operation, and execution will pause until the job completes. diff --git a/netbox/dcim/api/nested_serializers.py b/netbox/dcim/api/nested_serializers.py deleted file mode 100644 index 4b8f0db4a..000000000 --- a/netbox/dcim/api/nested_serializers.py +++ /dev/null @@ -1,389 +0,0 @@ -import warnings - -from drf_spectacular.utils import extend_schema_serializer -from rest_framework import serializers - -from dcim import models -from netbox.api.fields import RelatedObjectCountField -from netbox.api.serializers import WritableNestedSerializer -from .serializers_.nested import ( - NestedDeviceBaySerializer, NestedDeviceSerializer, NestedInterfaceSerializer, NestedInterfaceTemplateSerializer, - NestedLocationSerializer, NestedModuleBaySerializer, NestedRegionSerializer, NestedSiteGroupSerializer, -) - -__all__ = [ - 'NestedCableSerializer', - 'NestedConsolePortSerializer', - 'NestedConsolePortTemplateSerializer', - 'NestedConsoleServerPortSerializer', - 'NestedConsoleServerPortTemplateSerializer', - 'NestedDeviceBaySerializer', - 'NestedDeviceBayTemplateSerializer', - 'NestedDeviceRoleSerializer', - 'NestedDeviceSerializer', - 'NestedDeviceTypeSerializer', - 'NestedFrontPortSerializer', - 'NestedFrontPortTemplateSerializer', - 'NestedInterfaceSerializer', - 'NestedInterfaceTemplateSerializer', - 'NestedInventoryItemSerializer', - 'NestedInventoryItemRoleSerializer', - 'NestedInventoryItemTemplateSerializer', - 'NestedManufacturerSerializer', - 'NestedModuleBaySerializer', - 'NestedModuleBayTemplateSerializer', - 'NestedModuleSerializer', - 'NestedModuleTypeSerializer', - 'NestedPlatformSerializer', - 'NestedPowerFeedSerializer', - 'NestedPowerOutletSerializer', - 'NestedPowerOutletTemplateSerializer', - 'NestedPowerPanelSerializer', - 'NestedPowerPortSerializer', - 'NestedPowerPortTemplateSerializer', - 'NestedLocationSerializer', - 'NestedRackReservationSerializer', - 'NestedRackRoleSerializer', - 'NestedRackSerializer', - 'NestedRearPortSerializer', - 'NestedRearPortTemplateSerializer', - 'NestedRegionSerializer', - 'NestedSiteSerializer', - 'NestedSiteGroupSerializer', - 'NestedVirtualChassisSerializer', - 'NestedVirtualDeviceContextSerializer', -] - -# TODO: Remove in v4.2 -warnings.warn( - "Dedicated nested serializers will be removed in NetBox v4.2. Use Serializer(nested=True) instead.", - DeprecationWarning -) - - -# -# Regions/sites -# - -class NestedSiteSerializer(WritableNestedSerializer): - - class Meta: - model = models.Site - fields = ['id', 'url', 'display_url', 'display', 'name', 'slug'] - - -# -# Racks -# - -@extend_schema_serializer( - exclude_fields=('rack_count',), -) -class NestedRackRoleSerializer(WritableNestedSerializer): - rack_count = RelatedObjectCountField('racks') - - class Meta: - model = models.RackRole - fields = ['id', 'url', 'display_url', 'display', 'name', 'slug', 'rack_count'] - - -@extend_schema_serializer( - exclude_fields=('device_count',), -) -class NestedRackSerializer(WritableNestedSerializer): - device_count = RelatedObjectCountField('devices') - - class Meta: - model = models.Rack - fields = ['id', 'url', 'display_url', 'display', 'name', 'device_count'] - - -class NestedRackReservationSerializer(WritableNestedSerializer): - user = serializers.SerializerMethodField(read_only=True) - - class Meta: - model = models.RackReservation - fields = ['id', 'url', 'display_url', 'display', 'user', 'units'] - - def get_user(self, obj): - return obj.user.username - - -# -# Device/module types -# - -@extend_schema_serializer( - exclude_fields=('devicetype_count',), -) -class NestedManufacturerSerializer(WritableNestedSerializer): - devicetype_count = RelatedObjectCountField('device_types') - - class Meta: - model = models.Manufacturer - fields = ['id', 'url', 'display_url', 'display', 'name', 'slug', 'devicetype_count'] - - -@extend_schema_serializer( - exclude_fields=('device_count',), -) -class NestedDeviceTypeSerializer(WritableNestedSerializer): - manufacturer = NestedManufacturerSerializer(read_only=True) - device_count = RelatedObjectCountField('instances') - - class Meta: - model = models.DeviceType - fields = ['id', 'url', 'display_url', 'display', 'manufacturer', 'model', 'slug', 'device_count'] - - -class NestedModuleTypeSerializer(WritableNestedSerializer): - manufacturer = NestedManufacturerSerializer(read_only=True) - - class Meta: - model = models.ModuleType - fields = ['id', 'url', 'display_url', 'display', 'manufacturer', 'model'] - - -# -# Component templates -# - -class NestedConsolePortTemplateSerializer(WritableNestedSerializer): - - class Meta: - model = models.ConsolePortTemplate - fields = ['id', 'url', 'display_url', 'display', 'name'] - - -class NestedConsoleServerPortTemplateSerializer(WritableNestedSerializer): - - class Meta: - model = models.ConsoleServerPortTemplate - fields = ['id', 'url', 'display_url', 'display', 'name'] - - -class NestedPowerPortTemplateSerializer(WritableNestedSerializer): - - class Meta: - model = models.PowerPortTemplate - fields = ['id', 'url', 'display_url', 'display', 'name'] - - -class NestedPowerOutletTemplateSerializer(WritableNestedSerializer): - - class Meta: - model = models.PowerOutletTemplate - fields = ['id', 'url', 'display_url', 'display', 'name'] - - -class NestedRearPortTemplateSerializer(WritableNestedSerializer): - - class Meta: - model = models.RearPortTemplate - fields = ['id', 'url', 'display_url', 'display', 'name'] - - -class NestedFrontPortTemplateSerializer(WritableNestedSerializer): - - class Meta: - model = models.FrontPortTemplate - fields = ['id', 'url', 'display_url', 'display', 'name'] - - -class NestedModuleBayTemplateSerializer(WritableNestedSerializer): - - class Meta: - model = models.ModuleBayTemplate - fields = ['id', 'url', 'display_url', 'display', 'name'] - - -class NestedDeviceBayTemplateSerializer(WritableNestedSerializer): - - class Meta: - model = models.DeviceBayTemplate - fields = ['id', 'url', 'display_url', 'display', 'name'] - - -class NestedInventoryItemTemplateSerializer(WritableNestedSerializer): - _depth = serializers.IntegerField(source='level', read_only=True) - - class Meta: - model = models.InventoryItemTemplate - fields = ['id', 'url', 'display_url', 'display', 'name', '_depth'] - - -# -# Devices -# - -@extend_schema_serializer( - exclude_fields=('device_count', 'virtualmachine_count'), -) -class NestedDeviceRoleSerializer(WritableNestedSerializer): - device_count = RelatedObjectCountField('devices') - virtualmachine_count = RelatedObjectCountField('virtual_machines') - - class Meta: - model = models.DeviceRole - fields = ['id', 'url', 'display_url', 'display', 'name', 'slug', 'device_count', 'virtualmachine_count'] - - -@extend_schema_serializer( - exclude_fields=('device_count', 'virtualmachine_count'), -) -class NestedPlatformSerializer(WritableNestedSerializer): - device_count = RelatedObjectCountField('devices') - virtualmachine_count = RelatedObjectCountField('virtual_machines') - - class Meta: - model = models.Platform - fields = ['id', 'url', 'display_url', 'display', 'name', 'slug', 'device_count', 'virtualmachine_count'] - - -class ModuleNestedModuleBaySerializer(WritableNestedSerializer): - - class Meta: - model = models.ModuleBay - fields = ['id', 'url', 'display_url', 'display', 'name'] - - -class NestedModuleSerializer(WritableNestedSerializer): - device = NestedDeviceSerializer(read_only=True) - module_bay = ModuleNestedModuleBaySerializer(read_only=True) - module_type = NestedModuleTypeSerializer(read_only=True) - - class Meta: - model = models.Module - fields = ['id', 'url', 'display_url', 'display', 'device', 'module_bay', 'module_type'] - - -class NestedConsoleServerPortSerializer(WritableNestedSerializer): - device = NestedDeviceSerializer(read_only=True) - _occupied = serializers.BooleanField(required=False, read_only=True) - - class Meta: - model = models.ConsoleServerPort - fields = ['id', 'url', 'display_url', 'display', 'device', 'name', 'cable', '_occupied'] - - -class NestedConsolePortSerializer(WritableNestedSerializer): - device = NestedDeviceSerializer(read_only=True) - _occupied = serializers.BooleanField(required=False, read_only=True) - - class Meta: - model = models.ConsolePort - fields = ['id', 'url', 'display_url', 'display', 'device', 'name', 'cable', '_occupied'] - - -class NestedPowerOutletSerializer(WritableNestedSerializer): - device = NestedDeviceSerializer(read_only=True) - _occupied = serializers.BooleanField(required=False, read_only=True) - - class Meta: - model = models.PowerOutlet - fields = ['id', 'url', 'display_url', 'display', 'device', 'name', 'cable', '_occupied'] - - -class NestedPowerPortSerializer(WritableNestedSerializer): - device = NestedDeviceSerializer(read_only=True) - _occupied = serializers.BooleanField(required=False, read_only=True) - - class Meta: - model = models.PowerPort - fields = ['id', 'url', 'display_url', 'display', 'device', 'name', 'cable', '_occupied'] - - -class NestedRearPortSerializer(WritableNestedSerializer): - device = NestedDeviceSerializer(read_only=True) - _occupied = serializers.BooleanField(required=False, read_only=True) - - class Meta: - model = models.RearPort - fields = ['id', 'url', 'display_url', 'display', 'device', 'name', 'cable', '_occupied'] - - -class NestedFrontPortSerializer(WritableNestedSerializer): - device = NestedDeviceSerializer(read_only=True) - _occupied = serializers.BooleanField(required=False, read_only=True) - - class Meta: - model = models.FrontPort - fields = ['id', 'url', 'display_url', 'display', 'device', 'name', 'cable', '_occupied'] - - -class NestedInventoryItemSerializer(WritableNestedSerializer): - device = NestedDeviceSerializer(read_only=True) - _depth = serializers.IntegerField(source='level', read_only=True) - - class Meta: - model = models.InventoryItem - fields = ['id', 'url', 'display_url', 'display', 'device', 'name', '_depth'] - - -@extend_schema_serializer( - exclude_fields=('inventoryitem_count',), -) -class NestedInventoryItemRoleSerializer(WritableNestedSerializer): - inventoryitem_count = RelatedObjectCountField('inventory_items') - - class Meta: - model = models.InventoryItemRole - fields = ['id', 'url', 'display_url', 'display', 'name', 'slug', 'inventoryitem_count'] - - -# -# Cables -# - -class NestedCableSerializer(WritableNestedSerializer): - - class Meta: - model = models.Cable - fields = ['id', 'url', 'display_url', 'display', 'label'] - - -# -# Virtual chassis -# - -@extend_schema_serializer( - exclude_fields=('member_count',), -) -class NestedVirtualChassisSerializer(WritableNestedSerializer): - master = NestedDeviceSerializer() - member_count = serializers.IntegerField(read_only=True) - - class Meta: - model = models.VirtualChassis - fields = ['id', 'url', 'display_url', 'display', 'name', 'master', 'member_count'] - - -# -# Power panels/feeds -# - -@extend_schema_serializer( - exclude_fields=('powerfeed_count',), -) -class NestedPowerPanelSerializer(WritableNestedSerializer): - powerfeed_count = RelatedObjectCountField('powerfeeds') - - class Meta: - model = models.PowerPanel - fields = ['id', 'url', 'display_url', 'display', 'name', 'powerfeed_count'] - - -class NestedPowerFeedSerializer(WritableNestedSerializer): - _occupied = serializers.BooleanField(required=False, read_only=True) - - class Meta: - model = models.PowerFeed - fields = ['id', 'url', 'display_url', 'display', 'name', 'cable', '_occupied'] - - -class NestedVirtualDeviceContextSerializer(WritableNestedSerializer): - device = NestedDeviceSerializer() - - class Meta: - model = models.VirtualDeviceContext - fields = ['id', 'url', 'display_url', 'display', 'name', 'identifier', 'device'] diff --git a/netbox/dcim/api/serializers_/sites.py b/netbox/dcim/api/serializers_/sites.py index dc91f5dc7..7cd89e38c 100644 --- a/netbox/dcim/api/serializers_/sites.py +++ b/netbox/dcim/api/serializers_/sites.py @@ -21,12 +21,13 @@ __all__ = ( class RegionSerializer(NestedGroupModelSerializer): parent = NestedRegionSerializer(required=False, allow_null=True, default=None) site_count = serializers.IntegerField(read_only=True, default=0) + prefix_count = RelatedObjectCountField('_prefixes') class Meta: model = Region fields = [ 'id', 'url', 'display_url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', - 'created', 'last_updated', 'site_count', '_depth', + 'created', 'last_updated', 'site_count', 'prefix_count', '_depth', ] brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'site_count', '_depth') @@ -34,12 +35,13 @@ class RegionSerializer(NestedGroupModelSerializer): class SiteGroupSerializer(NestedGroupModelSerializer): parent = NestedSiteGroupSerializer(required=False, allow_null=True, default=None) site_count = serializers.IntegerField(read_only=True, default=0) + prefix_count = RelatedObjectCountField('_prefixes') class Meta: model = SiteGroup fields = [ 'id', 'url', 'display_url', 'display', 'name', 'slug', 'parent', 'description', 'tags', 'custom_fields', - 'created', 'last_updated', 'site_count', '_depth', + 'created', 'last_updated', 'site_count', 'prefix_count', '_depth', ] brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'site_count', '_depth') @@ -61,7 +63,7 @@ class SiteSerializer(NetBoxModelSerializer): # Related object counts circuit_count = RelatedObjectCountField('circuit_terminations') device_count = RelatedObjectCountField('devices') - prefix_count = RelatedObjectCountField('prefixes') + prefix_count = RelatedObjectCountField('_prefixes') rack_count = RelatedObjectCountField('racks') vlan_count = RelatedObjectCountField('vlans') virtualmachine_count = RelatedObjectCountField('virtual_machines') @@ -84,11 +86,13 @@ class LocationSerializer(NestedGroupModelSerializer): tenant = TenantSerializer(nested=True, required=False, allow_null=True) rack_count = serializers.IntegerField(read_only=True, default=0) device_count = serializers.IntegerField(read_only=True, default=0) + prefix_count = RelatedObjectCountField('_prefixes') class Meta: model = Location fields = [ 'id', 'url', 'display_url', 'display', 'name', 'slug', 'site', 'parent', 'status', 'tenant', 'facility', - 'description', 'tags', 'custom_fields', 'created', 'last_updated', 'rack_count', 'device_count', '_depth', + 'description', 'tags', 'custom_fields', 'created', 'last_updated', 'rack_count', 'device_count', + 'prefix_count', '_depth', ] brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'rack_count', '_depth') diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index b951aead0..cd863837a 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -112,7 +112,7 @@ class ModularComponentTemplateType(ComponentTemplateType): @strawberry_django.type( models.CableTermination, - exclude=('termination_type', 'termination_id'), + exclude=('termination_type', 'termination_id', '_device', '_rack', '_location', '_site'), filters=CableTerminationFilter ) class CableTerminationType(NetBoxObjectType): @@ -243,6 +243,7 @@ class DeviceType(ConfigContextMixin, ImageAttachmentsMixin, ContactsMixin, NetBo consoleserverports: List[Annotated["ConsoleServerPortType", strawberry.lazy('dcim.graphql.types')]] poweroutlets: List[Annotated["PowerOutletType", strawberry.lazy('dcim.graphql.types')]] frontports: List[Annotated["FrontPortType", strawberry.lazy('dcim.graphql.types')]] + devicebays: List[Annotated["DeviceBayType", strawberry.lazy('dcim.graphql.types')]] modulebays: List[Annotated["ModuleBayType", strawberry.lazy('dcim.graphql.types')]] services: List[Annotated["ServiceType", strawberry.lazy('ipam.graphql.types')]] inventoryitems: List[Annotated["InventoryItemType", strawberry.lazy('dcim.graphql.types')]] diff --git a/netbox/dcim/migrations/0194_charfield_null_choices.py b/netbox/dcim/migrations/0194_charfield_null_choices.py new file mode 100644 index 000000000..8e507c050 --- /dev/null +++ b/netbox/dcim/migrations/0194_charfield_null_choices.py @@ -0,0 +1,287 @@ +from django.db import migrations, models + + +def set_null_values(apps, schema_editor): + """ + Replace empty strings with null values. + """ + Cable = apps.get_model('dcim', 'Cable') + ConsolePort = apps.get_model('dcim', 'ConsolePort') + ConsolePortTemplate = apps.get_model('dcim', 'ConsolePortTemplate') + ConsoleServerPort = apps.get_model('dcim', 'ConsoleServerPort') + ConsoleServerPortTemplate = apps.get_model('dcim', 'ConsoleServerPortTemplate') + Device = apps.get_model('dcim', 'Device') + DeviceType = apps.get_model('dcim', 'DeviceType') + FrontPort = apps.get_model('dcim', 'FrontPort') + Interface = apps.get_model('dcim', 'Interface') + InterfaceTemplate = apps.get_model('dcim', 'InterfaceTemplate') + ModuleType = apps.get_model('dcim', 'ModuleType') + PowerFeed = apps.get_model('dcim', 'PowerFeed') + PowerOutlet = apps.get_model('dcim', 'PowerOutlet') + PowerOutletTemplate = apps.get_model('dcim', 'PowerOutletTemplate') + PowerPort = apps.get_model('dcim', 'PowerPort') + PowerPortTemplate = apps.get_model('dcim', 'PowerPortTemplate') + Rack = apps.get_model('dcim', 'Rack') + RackType = apps.get_model('dcim', 'RackType') + RearPort = apps.get_model('dcim', 'RearPort') + + Cable.objects.filter(length_unit='').update(length_unit=None) + Cable.objects.filter(type='').update(type=None) + ConsolePort.objects.filter(cable_end='').update(cable_end=None) + ConsolePort.objects.filter(type='').update(type=None) + ConsolePortTemplate.objects.filter(type='').update(type=None) + ConsoleServerPort.objects.filter(cable_end='').update(cable_end=None) + ConsoleServerPort.objects.filter(type='').update(type=None) + ConsoleServerPortTemplate.objects.filter(type='').update(type=None) + Device.objects.filter(airflow='').update(airflow=None) + Device.objects.filter(face='').update(face=None) + DeviceType.objects.filter(airflow='').update(airflow=None) + DeviceType.objects.filter(subdevice_role='').update(subdevice_role=None) + DeviceType.objects.filter(weight_unit='').update(weight_unit=None) + FrontPort.objects.filter(cable_end='').update(cable_end=None) + Interface.objects.filter(cable_end='').update(cable_end=None) + Interface.objects.filter(mode='').update(mode=None) + Interface.objects.filter(poe_mode='').update(poe_mode=None) + Interface.objects.filter(poe_type='').update(poe_type=None) + Interface.objects.filter(rf_channel='').update(rf_channel=None) + Interface.objects.filter(rf_role='').update(rf_role=None) + InterfaceTemplate.objects.filter(poe_mode='').update(poe_mode=None) + InterfaceTemplate.objects.filter(poe_type='').update(poe_type=None) + InterfaceTemplate.objects.filter(rf_role='').update(rf_role=None) + ModuleType.objects.filter(airflow='').update(airflow=None) + ModuleType.objects.filter(weight_unit='').update(weight_unit=None) + PowerFeed.objects.filter(cable_end='').update(cable_end=None) + PowerOutlet.objects.filter(cable_end='').update(cable_end=None) + PowerOutlet.objects.filter(feed_leg='').update(feed_leg=None) + PowerOutlet.objects.filter(type='').update(type=None) + PowerOutletTemplate.objects.filter(feed_leg='').update(feed_leg=None) + PowerOutletTemplate.objects.filter(type='').update(type=None) + PowerPort.objects.filter(cable_end='').update(cable_end=None) + PowerPort.objects.filter(type='').update(type=None) + PowerPortTemplate.objects.filter(type='').update(type=None) + Rack.objects.filter(airflow='').update(airflow=None) + Rack.objects.filter(form_factor='').update(form_factor=None) + Rack.objects.filter(outer_unit='').update(outer_unit=None) + Rack.objects.filter(weight_unit='').update(weight_unit=None) + RackType.objects.filter(outer_unit='').update(outer_unit=None) + RackType.objects.filter(weight_unit='').update(weight_unit=None) + RearPort.objects.filter(cable_end='').update(cable_end=None) + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0193_poweroutlet_color'), + ] + + operations = [ + migrations.AlterField( + model_name='cable', + name='length_unit', + field=models.CharField(blank=True, max_length=50, null=True), + ), + migrations.AlterField( + model_name='cable', + name='type', + field=models.CharField(blank=True, max_length=50, null=True), + ), + migrations.AlterField( + model_name='consoleport', + name='cable_end', + field=models.CharField(blank=True, max_length=1, null=True), + ), + migrations.AlterField( + model_name='consoleport', + name='type', + field=models.CharField(blank=True, max_length=50, null=True), + ), + migrations.AlterField( + model_name='consoleporttemplate', + name='type', + field=models.CharField(blank=True, max_length=50, null=True), + ), + migrations.AlterField( + model_name='consoleserverport', + name='cable_end', + field=models.CharField(blank=True, max_length=1, null=True), + ), + migrations.AlterField( + model_name='consoleserverport', + name='type', + field=models.CharField(blank=True, max_length=50, null=True), + ), + migrations.AlterField( + model_name='consoleserverporttemplate', + name='type', + field=models.CharField(blank=True, max_length=50, null=True), + ), + migrations.AlterField( + model_name='device', + name='airflow', + field=models.CharField(blank=True, max_length=50, null=True), + ), + migrations.AlterField( + model_name='device', + name='face', + field=models.CharField(blank=True, max_length=50, null=True), + ), + migrations.AlterField( + model_name='devicetype', + name='airflow', + field=models.CharField(blank=True, max_length=50, null=True), + ), + migrations.AlterField( + model_name='devicetype', + name='subdevice_role', + field=models.CharField(blank=True, max_length=50, null=True), + ), + migrations.AlterField( + model_name='devicetype', + name='weight_unit', + field=models.CharField(blank=True, max_length=50, null=True), + ), + migrations.AlterField( + model_name='frontport', + name='cable_end', + field=models.CharField(blank=True, max_length=1, null=True), + ), + migrations.AlterField( + model_name='interface', + name='cable_end', + field=models.CharField(blank=True, max_length=1, null=True), + ), + migrations.AlterField( + model_name='interface', + name='mode', + field=models.CharField(blank=True, max_length=50, null=True), + ), + migrations.AlterField( + model_name='interface', + name='poe_mode', + field=models.CharField(blank=True, max_length=50, null=True), + ), + migrations.AlterField( + model_name='interface', + name='poe_type', + field=models.CharField(blank=True, max_length=50, null=True), + ), + migrations.AlterField( + model_name='interface', + name='rf_channel', + field=models.CharField(blank=True, max_length=50, null=True), + ), + migrations.AlterField( + model_name='interface', + name='rf_role', + field=models.CharField(blank=True, max_length=30, null=True), + ), + migrations.AlterField( + model_name='interfacetemplate', + name='poe_mode', + field=models.CharField(blank=True, max_length=50, null=True), + ), + migrations.AlterField( + model_name='interfacetemplate', + name='poe_type', + field=models.CharField(blank=True, max_length=50, null=True), + ), + migrations.AlterField( + model_name='interfacetemplate', + name='rf_role', + field=models.CharField(blank=True, max_length=30, null=True), + ), + migrations.AlterField( + model_name='moduletype', + name='airflow', + field=models.CharField(blank=True, max_length=50, null=True), + ), + migrations.AlterField( + model_name='moduletype', + name='weight_unit', + field=models.CharField(blank=True, max_length=50, null=True), + ), + migrations.AlterField( + model_name='powerfeed', + name='cable_end', + field=models.CharField(blank=True, max_length=1, null=True), + ), + migrations.AlterField( + model_name='poweroutlet', + name='cable_end', + field=models.CharField(blank=True, max_length=1, null=True), + ), + migrations.AlterField( + model_name='poweroutlet', + name='feed_leg', + field=models.CharField(blank=True, max_length=50, null=True), + ), + migrations.AlterField( + model_name='poweroutlet', + name='type', + field=models.CharField(blank=True, max_length=50, null=True), + ), + migrations.AlterField( + model_name='poweroutlettemplate', + name='feed_leg', + field=models.CharField(blank=True, max_length=50, null=True), + ), + migrations.AlterField( + model_name='poweroutlettemplate', + name='type', + field=models.CharField(blank=True, max_length=50, null=True), + ), + migrations.AlterField( + model_name='powerport', + name='cable_end', + field=models.CharField(blank=True, max_length=1, null=True), + ), + migrations.AlterField( + model_name='powerport', + name='type', + field=models.CharField(blank=True, max_length=50, null=True), + ), + migrations.AlterField( + model_name='powerporttemplate', + name='type', + field=models.CharField(blank=True, max_length=50, null=True), + ), + migrations.AlterField( + model_name='rack', + name='airflow', + field=models.CharField(blank=True, max_length=50, null=True), + ), + migrations.AlterField( + model_name='rack', + name='form_factor', + field=models.CharField(blank=True, max_length=50, null=True), + ), + migrations.AlterField( + model_name='rack', + name='outer_unit', + field=models.CharField(blank=True, max_length=50, null=True), + ), + migrations.AlterField( + model_name='rack', + name='weight_unit', + field=models.CharField(blank=True, max_length=50, null=True), + ), + migrations.AlterField( + model_name='racktype', + name='outer_unit', + field=models.CharField(blank=True, max_length=50, null=True), + ), + migrations.AlterField( + model_name='racktype', + name='weight_unit', + field=models.CharField(blank=True, max_length=50, null=True), + ), + migrations.AlterField( + model_name='rearport', + name='cable_end', + field=models.CharField(blank=True, max_length=1, null=True), + ), + migrations.RunPython( + code=set_null_values, + reverse_code=migrations.RunPython.noop + ), + ] diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index 7d243e2e0..9f47a63d3 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -42,7 +42,8 @@ class Cable(PrimaryModel): verbose_name=_('type'), max_length=50, choices=CableTypeChoices, - blank=True + blank=True, + null=True ) status = models.CharField( verbose_name=_('status'), @@ -78,6 +79,7 @@ class Cable(PrimaryModel): max_length=50, choices=CableLengthUnitChoices, blank=True, + null=True ) # Stores the normalized length (in meters) for database ordering _abs_length = models.DecimalField( @@ -160,7 +162,7 @@ class Cable(PrimaryModel): if self.length is not None and not self.length_unit: raise ValidationError(_("Must specify a unit when setting a cable length")) - if self._state.adding and (not self.a_terminations or not self.b_terminations): + if self._state.adding and self.pk is None and (not self.a_terminations or not self.b_terminations): raise ValidationError(_("Must define A and B terminations when creating a new cable.")) if self._terminations_modified: @@ -206,7 +208,7 @@ class Cable(PrimaryModel): # Clear length_unit if no length is defined if self.length is None: - self.length_unit = '' + self.length_unit = None super().save(*args, **kwargs) @@ -365,7 +367,7 @@ class CableTermination(ChangeLoggedModel): termination = self.termination._meta.model.objects.get(pk=self.termination_id) termination.snapshot() termination.cable = None - termination.cable_end = '' + termination.cable_end = None termination.save() super().delete(*args, **kwargs) @@ -662,6 +664,14 @@ class CablePath(models.Model): rear_port_id=remote_terminations[0].pk, rear_port_position__in=position_stack.pop() ) + # If all rear ports have a single position, we can just get the front ports + elif all([rp.positions == 1 for rp in remote_terminations]): + front_ports = FrontPort.objects.filter(rear_port_id__in=[rp.pk for rp in remote_terminations]) + + if len(front_ports) != len(remote_terminations): + # Some rear ports does not have a front port + is_split = True + break else: # No position indicated: path has split, so we stop at the RearPorts is_split = True diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index 3a71c424d..00555d49e 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -203,7 +203,8 @@ class ConsolePortTemplate(ModularComponentTemplateModel): verbose_name=_('type'), max_length=50, choices=ConsolePortTypeChoices, - blank=True + blank=True, + null=True ) component_model = ConsolePort @@ -237,7 +238,8 @@ class ConsoleServerPortTemplate(ModularComponentTemplateModel): verbose_name=_('type'), max_length=50, choices=ConsolePortTypeChoices, - blank=True + blank=True, + null=True ) component_model = ConsoleServerPort @@ -272,7 +274,8 @@ class PowerPortTemplate(ModularComponentTemplateModel): verbose_name=_('type'), max_length=50, choices=PowerPortTypeChoices, - blank=True + blank=True, + null=True ) maximum_draw = models.PositiveIntegerField( verbose_name=_('maximum draw'), @@ -334,7 +337,8 @@ class PowerOutletTemplate(ModularComponentTemplateModel): verbose_name=_('type'), max_length=50, choices=PowerOutletTypeChoices, - blank=True + blank=True, + null=True ) power_port = models.ForeignKey( to='dcim.PowerPortTemplate', @@ -348,6 +352,7 @@ class PowerOutletTemplate(ModularComponentTemplateModel): max_length=50, choices=PowerOutletFeedLegChoices, blank=True, + null=True, help_text=_('Phase (for three-phase feeds)') ) @@ -434,18 +439,21 @@ class InterfaceTemplate(ModularComponentTemplateModel): max_length=50, choices=InterfacePoEModeChoices, blank=True, + null=True, verbose_name=_('PoE mode') ) poe_type = models.CharField( max_length=50, choices=InterfacePoETypeChoices, blank=True, + null=True, verbose_name=_('PoE type') ) rf_role = models.CharField( max_length=30, choices=WirelessRoleChoices, blank=True, + null=True, verbose_name=_('wireless role') ) diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 1a86a250c..a5bc2f604 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -142,8 +142,9 @@ class CabledObjectModel(models.Model): cable_end = models.CharField( verbose_name=_('cable end'), max_length=1, + choices=CableEndChoices, blank=True, - choices=CableEndChoices + null=True ) mark_connected = models.BooleanField( verbose_name=_('mark connected'), @@ -283,6 +284,7 @@ class ConsolePort(ModularComponentModel, CabledObjectModel, PathEndpoint, Tracki max_length=50, choices=ConsolePortTypeChoices, blank=True, + null=True, help_text=_('Physical port type') ) speed = models.PositiveIntegerField( @@ -309,6 +311,7 @@ class ConsoleServerPort(ModularComponentModel, CabledObjectModel, PathEndpoint, max_length=50, choices=ConsolePortTypeChoices, blank=True, + null=True, help_text=_('Physical port type') ) speed = models.PositiveIntegerField( @@ -339,6 +342,7 @@ class PowerPort(ModularComponentModel, CabledObjectModel, PathEndpoint, Tracking max_length=50, choices=PowerPortTypeChoices, blank=True, + null=True, help_text=_('Physical port type') ) maximum_draw = models.PositiveIntegerField( @@ -454,6 +458,7 @@ class PowerOutlet(ModularComponentModel, CabledObjectModel, PathEndpoint, Tracki max_length=50, choices=PowerOutletTypeChoices, blank=True, + null=True, help_text=_('Physical port type') ) power_port = models.ForeignKey( @@ -468,6 +473,7 @@ class PowerOutlet(ModularComponentModel, CabledObjectModel, PathEndpoint, Tracki max_length=50, choices=PowerOutletFeedLegChoices, blank=True, + null=True, help_text=_('Phase (for three-phase feeds)') ) color = ColorField( @@ -522,6 +528,7 @@ class BaseInterface(models.Model): max_length=50, choices=InterfaceModeChoices, blank=True, + null=True, help_text=_('IEEE 802.1Q tagging strategy') ) parent = models.ForeignKey( @@ -624,12 +631,14 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd max_length=30, choices=WirelessRoleChoices, blank=True, + null=True, verbose_name=_('wireless role') ) rf_channel = models.CharField( max_length=50, choices=WirelessChannelChoices, blank=True, + null=True, verbose_name=_('wireless channel') ) rf_channel_frequency = models.DecimalField( @@ -658,12 +667,14 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd max_length=50, choices=InterfacePoEModeChoices, blank=True, + null=True, verbose_name=_('PoE mode') ) poe_type = models.CharField( max_length=50, choices=InterfacePoETypeChoices, blank=True, + null=True, verbose_name=_('PoE type') ) wireless_link = models.ForeignKey( diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 777a99ef6..b9ba2bb64 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -118,6 +118,7 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin): max_length=50, choices=SubdeviceRoleChoices, blank=True, + null=True, verbose_name=_('parent/child status'), help_text=_('Parent devices house child devices in device bays. Leave blank ' 'if this device type is neither a parent nor a child.') @@ -126,7 +127,8 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin): verbose_name=_('airflow'), max_length=50, choices=DeviceAirflowChoices, - blank=True + blank=True, + null=True ) front_image = models.ImageField( upload_to='devicetype-images', @@ -387,7 +389,8 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin): verbose_name=_('airflow'), max_length=50, choices=ModuleAirflowChoices, - blank=True + blank=True, + null=True ) clone_fields = ('manufacturer', 'weight', 'weight_unit', 'airflow') @@ -632,6 +635,7 @@ class Device( face = models.CharField( max_length=50, blank=True, + null=True, choices=DeviceFaceChoices, verbose_name=_('rack face') ) @@ -645,7 +649,8 @@ class Device( verbose_name=_('airflow'), max_length=50, choices=DeviceAirflowChoices, - blank=True + blank=True, + null=True ) primary_ip4 = models.OneToOneField( to='ipam.IPAddress', @@ -966,6 +971,13 @@ class Device( 'vc_position': _("A device assigned to a virtual chassis must have its position defined.") }) + if hasattr(self, 'vc_master_for') and self.vc_master_for and self.vc_master_for != self.virtual_chassis: + raise ValidationError({ + 'virtual_chassis': _('Device cannot be removed from virtual chassis {virtual_chassis} because it is currently designated as its master.').format( + virtual_chassis=self.vc_master_for + ) + }) + def _instantiate_components(self, queryset, bulk_create=True): """ Instantiate components for the device from the specified component templates. diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index ae5513fea..013dfb619 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -83,7 +83,8 @@ class RackBase(WeightMixin, PrimaryModel): verbose_name=_('outer unit'), max_length=50, choices=RackDimensionUnitChoices, - blank=True + blank=True, + null=True ) mounting_depth = models.PositiveSmallIntegerField( verbose_name=_('mounting depth'), @@ -188,7 +189,7 @@ class RackType(RackBase): # Clear unit if outer width & depth are not set if self.outer_width is None and self.outer_depth is None: - self.outer_unit = '' + self.outer_unit = None super().save(*args, **kwargs) @@ -242,6 +243,7 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, RackBase): choices=RackFormFactorChoices, max_length=50, blank=True, + null=True, verbose_name=_('form factor') ) rack_type = models.ForeignKey( @@ -317,7 +319,8 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, RackBase): verbose_name=_('airflow'), max_length=50, choices=RackAirflowChoices, - blank=True + blank=True, + null=True ) # Generic relations @@ -409,7 +412,7 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, RackBase): # Clear unit if outer width & depth are not set if self.outer_width is None and self.outer_depth is None: - self.outer_unit = '' + self.outer_unit = None super().save(*args, **kwargs) diff --git a/netbox/dcim/models/sites.py b/netbox/dcim/models/sites.py index 37f59045d..a290f4119 100644 --- a/netbox/dcim/models/sites.py +++ b/netbox/dcim/models/sites.py @@ -28,6 +28,12 @@ class Region(ContactsMixin, NestedGroupModel): states, and/or cities. Regions are recursively nested into a hierarchy: all sites belonging to a child region are also considered to be members of its parent and ancestor region(s). """ + prefixes = GenericRelation( + to='ipam.Prefix', + content_type_field='scope_type', + object_id_field='scope_id', + related_query_name='region' + ) vlan_groups = GenericRelation( to='ipam.VLANGroup', content_type_field='scope_type', @@ -78,6 +84,12 @@ class SiteGroup(ContactsMixin, NestedGroupModel): within corporate sites you might distinguish between offices and data centers. Like regions, site groups can be nested recursively to form a hierarchy. """ + prefixes = GenericRelation( + to='ipam.Prefix', + content_type_field='scope_type', + object_id_field='scope_id', + related_query_name='site_group' + ) vlan_groups = GenericRelation( to='ipam.VLANGroup', content_type_field='scope_type', @@ -214,6 +226,12 @@ class Site(ContactsMixin, ImageAttachmentsMixin, PrimaryModel): ) # Generic relations + prefixes = GenericRelation( + to='ipam.Prefix', + content_type_field='scope_type', + object_id_field='scope_id', + related_query_name='site' + ) vlan_groups = GenericRelation( to='ipam.VLANGroup', content_type_field='scope_type', @@ -273,6 +291,12 @@ class Location(ContactsMixin, ImageAttachmentsMixin, NestedGroupModel): ) # Generic relations + prefixes = GenericRelation( + to='ipam.Prefix', + content_type_field='scope_type', + object_id_field='scope_id', + related_query_name='location' + ) vlan_groups = GenericRelation( to='ipam.VLANGroup', content_type_field='scope_type', diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py index cd7b0e6d7..f7c337bdf 100644 --- a/netbox/dcim/tests/test_cablepaths.py +++ b/netbox/dcim/tests/test_cablepaths.py @@ -2060,6 +2060,49 @@ class CablePathTestCase(TestCase): # Test SVG generation CableTraceSVG(interface1).render() + def test_222_single_path_via_multiple_singleposition_rear_ports(self): + """ + [IF1] --C1-- [FP1] [RP1] --C2-- [IF2] + [FP2] [RP2] + """ + interface1 = Interface.objects.create(device=self.device, name='Interface 1') + interface2 = Interface.objects.create(device=self.device, name='Interface 2') + rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=1) + rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=1) + frontport1 = FrontPort.objects.create( + device=self.device, name='Front Port 1', rear_port=rearport1, rear_port_position=1 + ) + frontport2 = FrontPort.objects.create( + device=self.device, name='Front Port 2', rear_port=rearport2, rear_port_position=1 + ) + + cable1 = Cable( + a_terminations=[interface1], + b_terminations=[frontport1, frontport2] + ) + cable1.save() + self.assertEqual(CablePath.objects.count(), 1) + + cable2 = Cable( + a_terminations=[rearport1, rearport2], + b_terminations=[interface2] + ) + cable2.save() + self.assertEqual(CablePath.objects.count(), 2) + + self.assertPathExists( + (interface1, cable1, (frontport1, frontport2), (rearport1, rearport2), cable2, interface2), + is_complete=True + ) + self.assertPathExists( + (interface2, cable2, (rearport1, rearport2), (frontport1, frontport2), cable1, interface1), + is_complete=True + ) + + # Test SVG generation both directions + CableTraceSVG(interface1).render() + CableTraceSVG(interface2).render() + def test_301_create_path_via_existing_cable(self): """ [IF1] --C1-- [FP1] [RP1] --C2-- [RP2] [FP2] --C3-- [IF2] diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index ae738b57f..d19c51564 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -871,7 +871,6 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_outer_unit(self): - self.assertEqual(Rack.objects.filter(outer_unit__isnull=False).count(), 5) params = {'outer_unit': RackDimensionUnitChoices.UNIT_MILLIMETER} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index ca0db5588..dc3e74ae1 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -592,7 +592,7 @@ class DeviceTypeTestCase( 'part_number': '123ABC', 'u_height': 2, 'is_full_depth': True, - 'subdevice_role': '', # CharField + 'subdevice_role': None, 'comments': 'Some comments', 'tags': [t.pk for t in tags], } diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 98665a7a0..3cd423426 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -11,7 +11,7 @@ from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse from django.utils.html import escape from django.utils.safestring import mark_safe -from django.utils.translation import gettext as _ +from django.utils.translation import gettext_lazy as _ from django.views.generic import View from jinja2.exceptions import TemplateError diff --git a/netbox/extras/api/nested_serializers.py b/netbox/extras/api/nested_serializers.py deleted file mode 100644 index 235cdd6d6..000000000 --- a/netbox/extras/api/nested_serializers.py +++ /dev/null @@ -1,135 +0,0 @@ -import warnings - -from rest_framework import serializers - -from extras import models -from netbox.api.serializers import NestedTagSerializer, WritableNestedSerializer - -__all__ = [ - 'NestedBookmarkSerializer', - 'NestedConfigContextSerializer', - 'NestedConfigTemplateSerializer', - 'NestedCustomFieldChoiceSetSerializer', - 'NestedCustomFieldSerializer', - 'NestedCustomLinkSerializer', - 'NestedEventRuleSerializer', - 'NestedExportTemplateSerializer', - 'NestedImageAttachmentSerializer', - 'NestedJournalEntrySerializer', - 'NestedSavedFilterSerializer', - 'NestedScriptSerializer', - 'NestedTagSerializer', # Defined in netbox.api.serializers - 'NestedWebhookSerializer', -] - -# TODO: Remove in v4.2 -warnings.warn( - "Dedicated nested serializers will be removed in NetBox v4.2. Use Serializer(nested=True) instead.", - DeprecationWarning -) - - -class NestedEventRuleSerializer(WritableNestedSerializer): - - class Meta: - model = models.EventRule - fields = ['id', 'url', 'display_url', 'display', 'name'] - - -class NestedWebhookSerializer(WritableNestedSerializer): - - class Meta: - model = models.Webhook - fields = ['id', 'url', 'display_url', 'display', 'name'] - - -class NestedCustomFieldSerializer(WritableNestedSerializer): - - class Meta: - model = models.CustomField - fields = ['id', 'url', 'display_url', 'display', 'name'] - - -class NestedCustomFieldChoiceSetSerializer(WritableNestedSerializer): - - class Meta: - model = models.CustomFieldChoiceSet - fields = ['id', 'url', 'display_url', 'display', 'name', 'choices_count'] - - -class NestedCustomLinkSerializer(WritableNestedSerializer): - - class Meta: - model = models.CustomLink - fields = ['id', 'url', 'display_url', 'display', 'name'] - - -class NestedConfigContextSerializer(WritableNestedSerializer): - - class Meta: - model = models.ConfigContext - fields = ['id', 'url', 'display_url', 'display', 'name'] - - -class NestedConfigTemplateSerializer(WritableNestedSerializer): - - class Meta: - model = models.ConfigTemplate - fields = ['id', 'url', 'display_url', 'display', 'name'] - - -class NestedExportTemplateSerializer(WritableNestedSerializer): - - class Meta: - model = models.ExportTemplate - fields = ['id', 'url', 'display_url', 'display', 'name'] - - -class NestedSavedFilterSerializer(WritableNestedSerializer): - - class Meta: - model = models.SavedFilter - fields = ['id', 'url', 'display_url', 'display', 'name', 'slug'] - - -class NestedBookmarkSerializer(WritableNestedSerializer): - - class Meta: - model = models.Bookmark - fields = ['id', 'url', 'display', 'object_id', 'object_type'] - - -class NestedImageAttachmentSerializer(WritableNestedSerializer): - - class Meta: - model = models.ImageAttachment - fields = ['id', 'url', 'display', 'name', 'image'] - - -class NestedJournalEntrySerializer(WritableNestedSerializer): - - class Meta: - model = models.JournalEntry - fields = ['id', 'url', 'display_url', 'display', 'created'] - - -class NestedScriptSerializer(WritableNestedSerializer): - url = serializers.HyperlinkedIdentityField( - view_name='extras-api:script-detail', - lookup_field='full_name', - lookup_url_kwarg='pk' - ) - display_url = serializers.HyperlinkedIdentityField( - view_name='extras:script', - lookup_field='full_name', - lookup_url_kwarg='pk' - ) - name = serializers.CharField(read_only=True) - display = serializers.SerializerMethodField(read_only=True) - - class Meta: - model = models.Script - fields = ['id', 'url', 'display_url', 'display', 'name'] - - def get_display(self, obj): - return f'{obj.name} ({obj.module})' diff --git a/netbox/extras/jobs.py b/netbox/extras/jobs.py index 64a7d6a69..a913fe456 100644 --- a/netbox/extras/jobs.py +++ b/netbox/extras/jobs.py @@ -49,7 +49,6 @@ class ScriptJob(JobRunner): script.log_info(message=_("Database changes have been reverted automatically.")) if script.failed: logger.warning("Script failed") - raise except Exception as e: if type(e) is AbortScript: diff --git a/netbox/extras/migrations/0122_charfield_null_choices.py b/netbox/extras/migrations/0122_charfield_null_choices.py new file mode 100644 index 000000000..9a1c7ff3f --- /dev/null +++ b/netbox/extras/migrations/0122_charfield_null_choices.py @@ -0,0 +1,29 @@ +from django.db import migrations, models + + +def set_null_values(apps, schema_editor): + """ + Replace empty strings with null values. + """ + CustomFieldChoiceSet = apps.get_model('extras', 'CustomFieldChoiceSet') + + CustomFieldChoiceSet.objects.filter(base_choices='').update(base_choices=None) + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0121_customfield_related_object_filter'), + ] + + operations = [ + migrations.AlterField( + model_name='customfieldchoiceset', + name='base_choices', + field=models.CharField(blank=True, max_length=50, null=True), + ), + migrations.RunPython( + code=set_null_values, + reverse_code=migrations.RunPython.noop + ), + ] diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 8b7fc0cb6..e1ceaf7a6 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -760,6 +760,7 @@ class CustomFieldChoiceSet(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel max_length=50, choices=CustomFieldChoiceSetBaseChoices, blank=True, + null=True, help_text=_('Base set of predefined choices (optional)') ) extra_choices = ArrayField( diff --git a/netbox/extras/utils.py b/netbox/extras/utils.py index 28d2e13f0..efe7ada5b 100644 --- a/netbox/extras/utils.py +++ b/netbox/extras/utils.py @@ -33,7 +33,7 @@ def image_upload(instance, filename): # Rename the file to the provided name, if any. Attempt to preserve the file extension. extension = filename.rsplit('.')[-1].lower() - if instance.name and extension in ['bmp', 'gif', 'jpeg', 'jpg', 'png']: + if instance.name and extension in ['bmp', 'gif', 'jpeg', 'jpg', 'png', 'webp']: filename = '.'.join([instance.name, extension]) elif instance.name: filename = instance.name diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 321842260..0d98b1324 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -1180,7 +1180,8 @@ class ScriptView(BaseScriptView): data=form.cleaned_data, request=copy_safe_request(request), job_timeout=script.python_class.job_timeout, - commit=form.cleaned_data.pop('_commit') + commit=form.cleaned_data.pop('_commit'), + name=script.name ) return redirect('extras:script_result', job_pk=job.pk) diff --git a/netbox/ipam/api/nested_serializers.py b/netbox/ipam/api/nested_serializers.py deleted file mode 100644 index 8b10f29df..000000000 --- a/netbox/ipam/api/nested_serializers.py +++ /dev/null @@ -1,204 +0,0 @@ -import warnings - -from drf_spectacular.utils import extend_schema_serializer -from rest_framework import serializers - -from ipam import models -from netbox.api.fields import RelatedObjectCountField -from netbox.api.serializers import WritableNestedSerializer -from .field_serializers import IPAddressField -from .serializers_.nested import NestedIPAddressSerializer - -__all__ = [ - 'NestedAggregateSerializer', - 'NestedASNSerializer', - 'NestedASNRangeSerializer', - 'NestedFHRPGroupSerializer', - 'NestedFHRPGroupAssignmentSerializer', - 'NestedIPAddressSerializer', - 'NestedIPRangeSerializer', - 'NestedPrefixSerializer', - 'NestedRIRSerializer', - 'NestedRoleSerializer', - 'NestedRouteTargetSerializer', - 'NestedServiceSerializer', - 'NestedServiceTemplateSerializer', - 'NestedVLANGroupSerializer', - 'NestedVLANSerializer', - 'NestedVRFSerializer', -] - -# TODO: Remove in v4.2 -warnings.warn( - "Dedicated nested serializers will be removed in NetBox v4.2. Use Serializer(nested=True) instead.", - DeprecationWarning -) - - -# -# ASN ranges -# - -class NestedASNRangeSerializer(WritableNestedSerializer): - - class Meta: - model = models.ASNRange - fields = ['id', 'url', 'display_url', 'display', 'name'] - - -# -# ASNs -# - -class NestedASNSerializer(WritableNestedSerializer): - - class Meta: - model = models.ASN - fields = ['id', 'url', 'display_url', 'display', 'asn'] - - -# -# VRFs -# - -@extend_schema_serializer( - exclude_fields=('prefix_count',), -) -class NestedVRFSerializer(WritableNestedSerializer): - prefix_count = RelatedObjectCountField('prefixes') - - class Meta: - model = models.VRF - fields = ['id', 'url', 'display_url', 'display', 'name', 'rd', 'prefix_count'] - - -# -# Route targets -# - -class NestedRouteTargetSerializer(WritableNestedSerializer): - - class Meta: - model = models.RouteTarget - fields = ['id', 'url', 'display_url', 'display', 'name'] - - -# -# RIRs/aggregates -# - -@extend_schema_serializer( - exclude_fields=('aggregate_count',), -) -class NestedRIRSerializer(WritableNestedSerializer): - aggregate_count = RelatedObjectCountField('aggregates') - - class Meta: - model = models.RIR - fields = ['id', 'url', 'display_url', 'display', 'name', 'slug', 'aggregate_count'] - - -class NestedAggregateSerializer(WritableNestedSerializer): - family = serializers.IntegerField(read_only=True) - - class Meta: - model = models.Aggregate - fields = ['id', 'url', 'display_url', 'display', 'family', 'prefix'] - - -# -# FHRP groups -# - -class NestedFHRPGroupSerializer(WritableNestedSerializer): - - class Meta: - model = models.FHRPGroup - fields = ['id', 'url', 'display_url', 'display', 'protocol', 'group_id'] - - -class NestedFHRPGroupAssignmentSerializer(WritableNestedSerializer): - group = NestedFHRPGroupSerializer() - - class Meta: - model = models.FHRPGroupAssignment - fields = ['id', 'url', 'display_url', 'display', 'group', 'interface_type', 'interface_id', 'priority'] - - -# -# VLANs -# - -@extend_schema_serializer( - exclude_fields=('prefix_count', 'vlan_count'), -) -class NestedRoleSerializer(WritableNestedSerializer): - prefix_count = RelatedObjectCountField('prefixes') - vlan_count = RelatedObjectCountField('vlans') - - class Meta: - model = models.Role - fields = ['id', 'url', 'display_url', 'display', 'name', 'slug', 'prefix_count', 'vlan_count'] - - -@extend_schema_serializer( - exclude_fields=('vlan_count',), -) -class NestedVLANGroupSerializer(WritableNestedSerializer): - vlan_count = RelatedObjectCountField('vlans') - - class Meta: - model = models.VLANGroup - fields = ['id', 'url', 'display_url', 'display', 'name', 'slug', 'vlan_count'] - - -class NestedVLANSerializer(WritableNestedSerializer): - - class Meta: - model = models.VLAN - fields = ['id', 'url', 'display_url', 'display', 'vid', 'name'] - - -# -# Prefixes -# - -class NestedPrefixSerializer(WritableNestedSerializer): - family = serializers.IntegerField(read_only=True) - _depth = serializers.IntegerField(read_only=True) - - class Meta: - model = models.Prefix - fields = ['id', 'url', 'display_url', 'display', 'family', 'prefix', '_depth'] - - -# -# IP ranges -# - -class NestedIPRangeSerializer(WritableNestedSerializer): - family = serializers.IntegerField(read_only=True) - start_address = IPAddressField() - end_address = IPAddressField() - - class Meta: - model = models.IPRange - fields = ['id', 'url', 'display_url', 'display', 'family', 'start_address', 'end_address'] - - -# -# Services -# - -class NestedServiceTemplateSerializer(WritableNestedSerializer): - - class Meta: - model = models.ServiceTemplate - fields = ['id', 'url', 'display_url', 'display', 'name', 'protocol', 'ports'] - - -class NestedServiceSerializer(WritableNestedSerializer): - - class Meta: - model = models.Service - fields = ['id', 'url', 'display_url', 'display', 'name', 'protocol', 'ports'] diff --git a/netbox/ipam/api/serializers_/ip.py b/netbox/ipam/api/serializers_/ip.py index 535ffcec1..0c3c141af 100644 --- a/netbox/ipam/api/serializers_/ip.py +++ b/netbox/ipam/api/serializers_/ip.py @@ -2,9 +2,8 @@ from django.contrib.contenttypes.models import ContentType from drf_spectacular.utils import extend_schema_field from rest_framework import serializers -from dcim.api.serializers_.sites import SiteSerializer from ipam.choices import * -from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS +from ipam.constants import IPADDRESS_ASSIGNMENT_MODELS, PREFIX_SCOPE_TYPES from ipam.models import Aggregate, IPAddress, IPRange, Prefix from netbox.api.fields import ChoiceField, ContentTypeField from netbox.api.serializers import NetBoxModelSerializer @@ -45,8 +44,17 @@ class AggregateSerializer(NetBoxModelSerializer): class PrefixSerializer(NetBoxModelSerializer): family = ChoiceField(choices=IPAddressFamilyChoices, read_only=True) - site = SiteSerializer(nested=True, required=False, allow_null=True) vrf = VRFSerializer(nested=True, required=False, allow_null=True) + scope_type = ContentTypeField( + queryset=ContentType.objects.filter( + model__in=PREFIX_SCOPE_TYPES + ), + allow_null=True, + required=False, + default=None + ) + scope_id = serializers.IntegerField(allow_null=True, required=False, default=None) + scope = serializers.SerializerMethodField(read_only=True) tenant = TenantSerializer(nested=True, required=False, allow_null=True) vlan = VLANSerializer(nested=True, required=False, allow_null=True) status = ChoiceField(choices=PrefixStatusChoices, required=False) @@ -58,12 +66,20 @@ class PrefixSerializer(NetBoxModelSerializer): class Meta: model = Prefix fields = [ - 'id', 'url', 'display_url', 'display', 'family', 'prefix', 'site', 'vrf', 'tenant', 'vlan', 'status', - 'role', 'is_pool', 'mark_utilized', 'description', 'comments', 'tags', 'custom_fields', - 'created', 'last_updated', 'children', '_depth', + 'id', 'url', 'display_url', 'display', 'family', 'prefix', 'vrf', 'scope_type', 'scope_id', 'scope', + 'tenant', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', 'description', 'comments', 'tags', + 'custom_fields', 'created', 'last_updated', 'children', '_depth', ] brief_fields = ('id', 'url', 'display', 'family', 'prefix', 'description', '_depth') + @extend_schema_field(serializers.JSONField(allow_null=True)) + def get_scope(self, obj): + if obj.scope_id is None: + return None + serializer = get_serializer_for_model(obj.scope) + context = {'request': self.context['request']} + return serializer(obj.scope, nested=True, context=context).data + class PrefixLengthSerializer(serializers.Serializer): diff --git a/netbox/ipam/apps.py b/netbox/ipam/apps.py index c118d5464..e0463dfce 100644 --- a/netbox/ipam/apps.py +++ b/netbox/ipam/apps.py @@ -1,5 +1,7 @@ from django.apps import AppConfig +from netbox import denormalized + class IPAMConfig(AppConfig): name = "ipam" @@ -8,6 +10,16 @@ class IPAMConfig(AppConfig): def ready(self): from netbox.models.features import register_models from . import signals, search # noqa: F401 + from .models import Prefix # Register models register_models(*self.get_models()) + + # Register denormalized fields + denormalized.register(Prefix, '_site', { + '_region': 'region', + '_sitegroup': 'group', + }) + denormalized.register(Prefix, '_location', { + '_site': 'site', + }) diff --git a/netbox/ipam/constants.py b/netbox/ipam/constants.py index 6dffd3287..c07b8441f 100644 --- a/netbox/ipam/constants.py +++ b/netbox/ipam/constants.py @@ -23,6 +23,11 @@ VRF_RD_MAX_LENGTH = 21 PREFIX_LENGTH_MIN = 1 PREFIX_LENGTH_MAX = 127 # IPv6 +# models values for ContentTypes which may be Prefix scope types +PREFIX_SCOPE_TYPES = ( + 'region', 'sitegroup', 'site', 'location', +) + # # IPAddresses diff --git a/netbox/ipam/fields.py b/netbox/ipam/fields.py index 20341005d..a82976326 100644 --- a/netbox/ipam/fields.py +++ b/netbox/ipam/fields.py @@ -105,6 +105,8 @@ IPAddressField.register_lookup(lookups.NetIn) IPAddressField.register_lookup(lookups.NetHostContained) IPAddressField.register_lookup(lookups.NetFamily) IPAddressField.register_lookup(lookups.NetMaskLength) +IPAddressField.register_lookup(lookups.Host) +IPAddressField.register_lookup(lookups.Inet) class ASNField(models.BigIntegerField): diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 894219c64..6fba68e8b 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -9,7 +9,7 @@ from drf_spectacular.utils import extend_schema_field from netaddr.core import AddrFormatError from circuits.models import Provider -from dcim.models import Device, Interface, Region, Site, SiteGroup +from dcim.models import Device, Interface, Location, Region, Site, SiteGroup from netbox.filtersets import ChangeLoggedModelFilterSet, OrganizationalModelFilterSet, NetBoxModelFilterSet from tenancy.filtersets import TenancyFilterSet from utilities.filters import ( @@ -332,42 +332,57 @@ class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet): to_field_name='rd', label=_('VRF (RD)'), ) + scope_type = ContentTypeFilter() region_id = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='site__region', + field_name='_region', lookup_expr='in', label=_('Region (ID)'), ) region = TreeNodeMultipleChoiceFilter( queryset=Region.objects.all(), - field_name='site__region', + field_name='_region', lookup_expr='in', to_field_name='slug', label=_('Region (slug)'), ) site_group_id = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), - field_name='site__group', + field_name='_sitegroup', lookup_expr='in', label=_('Site group (ID)'), ) site_group = TreeNodeMultipleChoiceFilter( queryset=SiteGroup.objects.all(), - field_name='site__group', + field_name='_sitegroup', lookup_expr='in', to_field_name='slug', label=_('Site group (slug)'), ) site_id = django_filters.ModelMultipleChoiceFilter( queryset=Site.objects.all(), + field_name='_site', label=_('Site (ID)'), ) site = django_filters.ModelMultipleChoiceFilter( - field_name='site__slug', + field_name='_site__slug', queryset=Site.objects.all(), to_field_name='slug', label=_('Site (slug)'), ) + location_id = TreeNodeMultipleChoiceFilter( + queryset=Location.objects.all(), + field_name='_location', + lookup_expr='in', + label=_('Location (ID)'), + ) + location = TreeNodeMultipleChoiceFilter( + queryset=Location.objects.all(), + field_name='_location', + lookup_expr='in', + to_field_name='slug', + label=_('Location (slug)'), + ) vlan_id = django_filters.ModelMultipleChoiceFilter( queryset=VLAN.objects.all(), label=_('VLAN (ID)'), @@ -393,7 +408,7 @@ class PrefixFilterSet(NetBoxModelFilterSet, TenancyFilterSet): class Meta: model = Prefix - fields = ('id', 'is_pool', 'mark_utilized', 'description') + fields = ('id', 'scope_id', 'is_pool', 'mark_utilized', 'description') def search(self, queryset, name, value): if not value.strip(): diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py index f4a7eabb7..223fad790 100644 --- a/netbox/ipam/forms/bulk_edit.py +++ b/netbox/ipam/forms/bulk_edit.py @@ -1,22 +1,23 @@ from django import forms from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ObjectDoesNotExist from django.utils.translation import gettext_lazy as _ -from dcim.models import Location, Rack, Region, Site, SiteGroup +from dcim.models import Region, Site, SiteGroup from ipam.choices import * from ipam.constants import * from ipam.models import * from ipam.models import ASN from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant -from utilities.forms import add_blank_choice +from utilities.forms import add_blank_choice, get_field_value from utilities.forms.fields import ( CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, NumericArrayField, NumericRangeArrayField, ) from utilities.forms.rendering import FieldSet -from utilities.forms.widgets import BulkEditNullBooleanSelect -from virtualization.models import Cluster, ClusterGroup +from utilities.forms.widgets import BulkEditNullBooleanSelect, HTMXSelect +from utilities.templatetags.builtins.filters import bettertitle __all__ = ( 'AggregateBulkEditForm', @@ -203,24 +204,18 @@ class RoleBulkEditForm(NetBoxModelBulkEditForm): class PrefixBulkEditForm(NetBoxModelBulkEditForm): - region = DynamicModelChoiceField( - label=_('Region'), - queryset=Region.objects.all(), - required=False - ) - site_group = DynamicModelChoiceField( - label=_('Site group'), - queryset=SiteGroup.objects.all(), - required=False - ) - site = DynamicModelChoiceField( - label=_('Site'), - queryset=Site.objects.all(), + scope_type = ContentTypeChoiceField( + queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES), + widget=HTMXSelect(method='post', attrs={'hx-select': '#form_fields'}), required=False, - query_params={ - 'region_id': '$region', - 'group_id': '$site_group', - } + label=_('Scope type') + ) + scope = DynamicModelChoiceField( + label=_('Scope'), + queryset=Site.objects.none(), # Initial queryset + required=False, + disabled=True, + selector=True ) vlan_group = DynamicModelChoiceField( queryset=VLANGroup.objects.all(), @@ -281,14 +276,28 @@ class PrefixBulkEditForm(NetBoxModelBulkEditForm): model = Prefix fieldsets = ( FieldSet('tenant', 'status', 'role', 'description'), - FieldSet('region', 'site_group', 'site', name=_('Site')), FieldSet('vrf', 'prefix_length', 'is_pool', 'mark_utilized', name=_('Addressing')), + FieldSet('scope_type', 'scope', name=_('Scope')), FieldSet('vlan_group', 'vlan', name=_('VLAN Assignment')), ) nullable_fields = ( - 'site', 'vlan', 'vrf', 'tenant', 'role', 'description', 'comments', + 'vlan', 'vrf', 'tenant', 'role', 'scope', 'description', 'comments', ) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + if scope_type_id := get_field_value(self, 'scope_type'): + try: + scope_type = ContentType.objects.get(pk=scope_type_id) + model = scope_type.model_class() + self.fields['scope'].queryset = model.objects.all() + self.fields['scope'].widget.attrs['selector'] = model._meta.label_lower + self.fields['scope'].disabled = False + self.fields['scope'].label = _(bettertitle(model._meta.verbose_name)) + except ObjectDoesNotExist: + pass + class IPRangeBulkEditForm(NetBoxModelBulkEditForm): vrf = DynamicModelChoiceField( @@ -429,62 +438,17 @@ class VLANGroupBulkEditForm(NetBoxModelBulkEditForm): required=False ) scope_type = ContentTypeChoiceField( - label=_('Scope type'), queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES), - required=False - ) - scope_id = forms.IntegerField( + widget=HTMXSelect(method='post', attrs={'hx-select': '#form_fields'}), required=False, - widget=forms.HiddenInput() + label=_('Scope type') ) - region = DynamicModelChoiceField( - label=_('Region'), - queryset=Region.objects.all(), - required=False - ) - sitegroup = DynamicModelChoiceField( - queryset=SiteGroup.objects.all(), + scope = DynamicModelChoiceField( + label=_('Scope'), + queryset=Site.objects.none(), # Initial queryset required=False, - label=_('Site group') - ) - site = DynamicModelChoiceField( - label=_('Site'), - queryset=Site.objects.all(), - required=False, - query_params={ - 'region_id': '$region', - 'group_id': '$sitegroup', - } - ) - location = DynamicModelChoiceField( - label=_('Location'), - queryset=Location.objects.all(), - required=False, - query_params={ - 'site_id': '$site', - } - ) - rack = DynamicModelChoiceField( - label=_('Rack'), - queryset=Rack.objects.all(), - required=False, - query_params={ - 'site_id': '$site', - 'location_id': '$location', - } - ) - clustergroup = DynamicModelChoiceField( - queryset=ClusterGroup.objects.all(), - required=False, - label=_('Cluster group') - ) - cluster = DynamicModelChoiceField( - label=_('Cluster'), - queryset=Cluster.objects.all(), - required=False, - query_params={ - 'group_id': '$clustergroup', - } + disabled=True, + selector=True ) vid_ranges = NumericRangeArrayField( label=_('VLAN ID ranges'), @@ -494,24 +458,23 @@ class VLANGroupBulkEditForm(NetBoxModelBulkEditForm): model = VLANGroup fieldsets = ( FieldSet('site', 'vid_ranges', 'description'), - FieldSet( - 'scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster', name=_('Scope') - ), + FieldSet('scope_type', 'scope', name=_('Scope')), ) - nullable_fields = ('description',) + nullable_fields = ('description', 'scope') - def clean(self): - super().clean() + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) - # Assign scope based on scope_type - if self.cleaned_data.get('scope_type'): - scope_field = self.cleaned_data['scope_type'].model - if scope_obj := self.cleaned_data.get(scope_field): - self.cleaned_data['scope_id'] = scope_obj.pk - self.changed_data.append('scope_id') - else: - self.cleaned_data.pop('scope_type') - self.changed_data.remove('scope_type') + if scope_type_id := get_field_value(self, 'scope_type'): + try: + scope_type = ContentType.objects.get(pk=scope_type_id) + model = scope_type.model_class() + self.fields['scope'].queryset = model.objects.all() + self.fields['scope'].widget.attrs['selector'] = model._meta.label_lower + self.fields['scope'].disabled = False + self.fields['scope'].label = _(bettertitle(model._meta.verbose_name)) + except ObjectDoesNotExist: + pass class VLANBulkEditForm(NetBoxModelBulkEditForm): diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py index dea250c79..a6ef1a9fb 100644 --- a/netbox/ipam/forms/bulk_import.py +++ b/netbox/ipam/forms/bulk_import.py @@ -167,12 +167,10 @@ class PrefixImportForm(NetBoxModelImportForm): to_field_name='name', help_text=_('Assigned tenant') ) - site = CSVModelChoiceField( - label=_('Site'), - queryset=Site.objects.all(), + scope_type = CSVContentTypeField( + queryset=ContentType.objects.filter(model__in=PREFIX_SCOPE_TYPES), required=False, - to_field_name='name', - help_text=_('Assigned site') + label=_('Scope type (app & model)') ) vlan_group = CSVModelChoiceField( label=_('VLAN group'), @@ -204,9 +202,12 @@ class PrefixImportForm(NetBoxModelImportForm): class Meta: model = Prefix fields = ( - 'prefix', 'vrf', 'tenant', 'site', 'vlan_group', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', - 'description', 'comments', 'tags', + 'prefix', 'vrf', 'tenant', 'vlan_group', 'vlan', 'status', 'role', 'scope_type', 'scope_id', 'is_pool', + 'mark_utilized', 'description', 'comments', 'tags', ) + labels = { + 'scope_id': 'Scope ID', + } def __init__(self, data=None, *args, **kwargs): super().__init__(data, *args, **kwargs) diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py index a32694321..57c0f479c 100644 --- a/netbox/ipam/forms/filtersets.py +++ b/netbox/ipam/forms/filtersets.py @@ -170,7 +170,7 @@ class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): ), FieldSet('vlan_id', name=_('VLAN Assignment')), FieldSet('vrf_id', 'present_in_vrf_id', name=_('VRF')), - FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')), + FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Scope')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), ) mask_length__lte = forms.IntegerField( @@ -224,12 +224,13 @@ class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): site_id = DynamicModelMultipleChoiceField( queryset=Site.objects.all(), required=False, - null_option='None', - query_params={ - 'region_id': '$region_id' - }, label=_('Site') ) + location_id = DynamicModelMultipleChoiceField( + queryset=Location.objects.all(), + required=False, + label=_('Location') + ) role_id = DynamicModelMultipleChoiceField( queryset=Role.objects.all(), required=False, diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py index 156e7c435..e9e90db57 100644 --- a/netbox/ipam/forms/model_forms.py +++ b/netbox/ipam/forms/model_forms.py @@ -201,12 +201,18 @@ class PrefixForm(TenancyForm, NetBoxModelForm): required=False, label=_('VRF') ) - site = DynamicModelChoiceField( - label=_('Site'), - queryset=Site.objects.all(), + scope_type = ContentTypeChoiceField( + queryset=ContentType.objects.filter(model__in=PREFIX_SCOPE_TYPES), + widget=HTMXSelect(), required=False, - selector=True, - null_option='None' + label=_('Scope type') + ) + scope = DynamicModelChoiceField( + label=_('Scope'), + queryset=Site.objects.none(), # Initial queryset + required=False, + disabled=True, + selector=True ) vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), @@ -228,17 +234,48 @@ class PrefixForm(TenancyForm, NetBoxModelForm): FieldSet( 'prefix', 'status', 'vrf', 'role', 'is_pool', 'mark_utilized', 'description', 'tags', name=_('Prefix') ), - FieldSet('site', 'vlan', name=_('Site/VLAN Assignment')), + FieldSet('scope_type', 'scope', name=_('Scope')), + FieldSet('vlan', name=_('VLAN Assignment')), FieldSet('tenant_group', 'tenant', name=_('Tenancy')), ) class Meta: model = Prefix fields = [ - 'prefix', 'vrf', 'site', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', 'tenant_group', 'tenant', - 'description', 'comments', 'tags', + 'prefix', 'vrf', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', 'scope_type', 'tenant_group', + 'tenant', 'description', 'comments', 'tags', ] + def __init__(self, *args, **kwargs): + instance = kwargs.get('instance') + initial = kwargs.get('initial', {}) + + if instance is not None and instance.scope: + initial['scope'] = instance.scope + kwargs['initial'] = initial + + super().__init__(*args, **kwargs) + + if scope_type_id := get_field_value(self, 'scope_type'): + try: + scope_type = ContentType.objects.get(pk=scope_type_id) + model = scope_type.model_class() + self.fields['scope'].queryset = model.objects.all() + self.fields['scope'].widget.attrs['selector'] = model._meta.label_lower + self.fields['scope'].disabled = False + self.fields['scope'].label = _(bettertitle(model._meta.verbose_name)) + except ObjectDoesNotExist: + pass + + if self.instance and scope_type_id != self.instance.scope_type_id: + self.initial['scope'] = None + + def clean(self): + super().clean() + + # Assign the selected scope (if any) + self.instance.scope = self.cleaned_data.get('scope') + class IPRangeForm(TenancyForm, NetBoxModelForm): vrf = DynamicModelChoiceField( diff --git a/netbox/ipam/graphql/types.py b/netbox/ipam/graphql/types.py index 46d45816e..9fe1fe466 100644 --- a/netbox/ipam/graphql/types.py +++ b/netbox/ipam/graphql/types.py @@ -152,17 +152,25 @@ class IPRangeType(NetBoxObjectType): @strawberry_django.type( models.Prefix, - fields='__all__', + exclude=('scope_type', 'scope_id', '_location', '_region', '_site', '_sitegroup'), filters=PrefixFilter ) class PrefixType(NetBoxObjectType, BaseIPAddressFamilyType): prefix: str - site: Annotated["SiteType", strawberry.lazy('dcim.graphql.types')] | None vrf: Annotated["VRFType", strawberry.lazy('ipam.graphql.types')] | None tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None vlan: Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None role: Annotated["RoleType", strawberry.lazy('ipam.graphql.types')] | None + @strawberry_django.field + def scope(self) -> Annotated[Union[ + Annotated["LocationType", strawberry.lazy('dcim.graphql.types')], + Annotated["RegionType", strawberry.lazy('dcim.graphql.types')], + Annotated["SiteGroupType", strawberry.lazy('dcim.graphql.types')], + Annotated["SiteType", strawberry.lazy('dcim.graphql.types')], + ], strawberry.union("PrefixScopeType")] | None: + return self.scope + @strawberry_django.type( models.RIR, diff --git a/netbox/ipam/migrations/0071_prefix_scope.py b/netbox/ipam/migrations/0071_prefix_scope.py new file mode 100644 index 000000000..d016bdb93 --- /dev/null +++ b/netbox/ipam/migrations/0071_prefix_scope.py @@ -0,0 +1,51 @@ +import django.db.models.deletion +from django.db import migrations, models + + +def copy_site_assignments(apps, schema_editor): + """ + Copy site ForeignKey values to the scope GFK. + """ + ContentType = apps.get_model('contenttypes', 'ContentType') + Prefix = apps.get_model('ipam', 'Prefix') + Site = apps.get_model('dcim', 'Site') + + Prefix.objects.filter(site__isnull=False).update( + scope_type=ContentType.objects.get_for_model(Site), + scope_id=models.F('site_id') + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('ipam', '0070_vlangroup_vlan_id_ranges'), + ] + + operations = [ + # Add the `scope` GenericForeignKey + migrations.AddField( + model_name='prefix', + name='scope_id', + field=models.PositiveBigIntegerField(blank=True, null=True), + ), + migrations.AddField( + model_name='prefix', + name='scope_type', + field=models.ForeignKey( + blank=True, + limit_choices_to=models.Q(('model__in', ('region', 'sitegroup', 'site', 'location'))), + null=True, + on_delete=django.db.models.deletion.PROTECT, + related_name='+', + to='contenttypes.contenttype' + ), + ), + + # Copy over existing site assignments + migrations.RunPython( + code=copy_site_assignments, + reverse_code=migrations.RunPython.noop + ), + ] diff --git a/netbox/ipam/migrations/0072_prefix_cached_relations.py b/netbox/ipam/migrations/0072_prefix_cached_relations.py new file mode 100644 index 000000000..2b457ebda --- /dev/null +++ b/netbox/ipam/migrations/0072_prefix_cached_relations.py @@ -0,0 +1,61 @@ +import django.db.models.deletion +from django.db import migrations, models + + +def populate_denormalized_fields(apps, schema_editor): + """ + Copy site ForeignKey values to the scope GFK. + """ + Prefix = apps.get_model('ipam', 'Prefix') + + prefixes = Prefix.objects.filter(site__isnull=False).prefetch_related('site') + for prefix in prefixes: + prefix._region_id = prefix.site.region_id + prefix._sitegroup_id = prefix.site.group_id + prefix._site_id = prefix.site_id + # Note: Location cannot be set prior to migration + + Prefix.objects.bulk_update(prefixes, ['_region', '_sitegroup', '_site']) + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0193_poweroutlet_color'), + ('ipam', '0071_prefix_scope'), + ] + + operations = [ + migrations.AddField( + model_name='prefix', + name='_location', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='_prefixes', to='dcim.location'), + ), + migrations.AddField( + model_name='prefix', + name='_region', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='_prefixes', to='dcim.region'), + ), + migrations.AddField( + model_name='prefix', + name='_site', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='_prefixes', to='dcim.site'), + ), + migrations.AddField( + model_name='prefix', + name='_sitegroup', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='_prefixes', to='dcim.sitegroup'), + ), + + # Populate denormalized FK values + migrations.RunPython( + code=populate_denormalized_fields, + reverse_code=migrations.RunPython.noop + ), + + # Delete the site ForeignKey + migrations.RemoveField( + model_name='prefix', + name='site', + ), + ] diff --git a/netbox/ipam/migrations/0073_charfield_null_choices.py b/netbox/ipam/migrations/0073_charfield_null_choices.py new file mode 100644 index 000000000..9293728f5 --- /dev/null +++ b/netbox/ipam/migrations/0073_charfield_null_choices.py @@ -0,0 +1,36 @@ +from django.db import migrations, models + + +def set_null_values(apps, schema_editor): + """ + Replace empty strings with null values. + """ + FHRPGroup = apps.get_model('ipam', 'FHRPGroup') + IPAddress = apps.get_model('ipam', 'IPAddress') + + FHRPGroup.objects.filter(auth_type='').update(auth_type=None) + IPAddress.objects.filter(role='').update(role=None) + + +class Migration(migrations.Migration): + + dependencies = [ + ('ipam', '0072_prefix_cached_relations'), + ] + + operations = [ + migrations.AlterField( + model_name='fhrpgroup', + name='auth_type', + field=models.CharField(blank=True, max_length=50, null=True), + ), + migrations.AlterField( + model_name='ipaddress', + name='role', + field=models.CharField(blank=True, max_length=50, null=True), + ), + migrations.RunPython( + code=set_null_values, + reverse_code=migrations.RunPython.noop + ), + ] diff --git a/netbox/ipam/models/fhrp.py b/netbox/ipam/models/fhrp.py index 28bb37ef3..f5982853e 100644 --- a/netbox/ipam/models/fhrp.py +++ b/netbox/ipam/models/fhrp.py @@ -34,6 +34,7 @@ class FHRPGroup(PrimaryModel): max_length=50, choices=FHRPGroupAuthTypeChoices, blank=True, + null=True, verbose_name=_('authentication type') ) auth_key = models.CharField( diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index a540b5810..b17e26169 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -1,4 +1,5 @@ import netaddr +from django.apps import apps from django.contrib.contenttypes.fields import GenericForeignKey from django.core.exceptions import ValidationError from django.db import models @@ -199,21 +200,30 @@ class Role(OrganizationalModel): class Prefix(ContactsMixin, GetAvailablePrefixesMixin, PrimaryModel): """ - A Prefix represents an IPv4 or IPv6 network, including mask length. Prefixes can optionally be assigned to Sites and - VRFs. A Prefix must be assigned a status and may optionally be assigned a used-define Role. A Prefix can also be - assigned to a VLAN where appropriate. + A Prefix represents an IPv4 or IPv6 network, including mask length. Prefixes can optionally be scoped to certain + areas and/or assigned to VRFs. A Prefix must be assigned a status and may optionally be assigned a used-define Role. + A Prefix can also be assigned to a VLAN where appropriate. """ prefix = IPNetworkField( verbose_name=_('prefix'), help_text=_('IPv4 or IPv6 network with mask') ) - site = models.ForeignKey( - to='dcim.Site', + scope_type = models.ForeignKey( + to='contenttypes.ContentType', on_delete=models.PROTECT, - related_name='prefixes', + limit_choices_to=Q(model__in=PREFIX_SCOPE_TYPES), + related_name='+', blank=True, null=True ) + scope_id = models.PositiveBigIntegerField( + blank=True, + null=True + ) + scope = GenericForeignKey( + ct_field='scope_type', + fk_field='scope_id' + ) vrf = models.ForeignKey( to='ipam.VRF', on_delete=models.PROTECT, @@ -262,6 +272,36 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, PrimaryModel): help_text=_("Treat as fully utilized") ) + # Cached associations to enable efficient filtering + _location = models.ForeignKey( + to='dcim.Location', + on_delete=models.CASCADE, + related_name='_prefixes', + blank=True, + null=True + ) + _site = models.ForeignKey( + to='dcim.Site', + on_delete=models.CASCADE, + related_name='_prefixes', + blank=True, + null=True + ) + _region = models.ForeignKey( + to='dcim.Region', + on_delete=models.CASCADE, + related_name='_prefixes', + blank=True, + null=True + ) + _sitegroup = models.ForeignKey( + to='dcim.SiteGroup', + on_delete=models.CASCADE, + related_name='_prefixes', + blank=True, + null=True + ) + # Cached depth & child counts _depth = models.PositiveSmallIntegerField( default=0, @@ -275,7 +315,7 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, PrimaryModel): objects = PrefixQuerySet.as_manager() clone_fields = ( - 'site', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', 'description', + 'scope_type', 'scope_id', 'vrf', 'tenant', 'vlan', 'status', 'role', 'is_pool', 'mark_utilized', 'description', ) class Meta: @@ -323,8 +363,30 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, PrimaryModel): # Clear host bits from prefix self.prefix = self.prefix.cidr + # Cache objects associated with the terminating object (for filtering) + self.cache_related_objects() + super().save(*args, **kwargs) + def cache_related_objects(self): + self._region = self._sitegroup = self._site = self._location = None + if self.scope_type: + scope_type = self.scope_type.model_class() + if scope_type == apps.get_model('dcim', 'region'): + self._region = self.scope + elif scope_type == apps.get_model('dcim', 'sitegroup'): + self._sitegroup = self.scope + elif scope_type == apps.get_model('dcim', 'site'): + self._region = self.scope.region + self._sitegroup = self.scope.group + self._site = self.scope + elif scope_type == apps.get_model('dcim', 'location'): + self._region = self.scope.site.region + self._sitegroup = self.scope.site.group + self._site = self.scope.site + self._location = self.scope + cache_related_objects.alters_data = True + @property def family(self): return self.prefix.version if self.prefix else None @@ -564,15 +626,15 @@ class IPRange(ContactsMixin, PrimaryModel): }) # Check for overlapping ranges - overlapping_range = IPRange.objects.exclude(pk=self.pk).filter(vrf=self.vrf).filter( - Q(start_address__gte=self.start_address, start_address__lte=self.end_address) | # Starts inside - Q(end_address__gte=self.start_address, end_address__lte=self.end_address) | # Ends inside - Q(start_address__lte=self.start_address, end_address__gte=self.end_address) # Starts & ends outside - ).first() - if overlapping_range: + overlapping_ranges = IPRange.objects.exclude(pk=self.pk).filter(vrf=self.vrf).filter( + Q(start_address__host__inet__gte=self.start_address.ip, start_address__host__inet__lte=self.end_address.ip) | # Starts inside + Q(end_address__host__inet__gte=self.start_address.ip, end_address__host__inet__lte=self.end_address.ip) | # Ends inside + Q(start_address__host__inet__lte=self.start_address.ip, end_address__host__inet__gte=self.end_address.ip) # Starts & ends outside + ) + if overlapping_ranges.exists(): raise ValidationError( _("Defined addresses overlap with range {overlapping_range} in VRF {vrf}").format( - overlapping_range=overlapping_range, + overlapping_range=overlapping_ranges.first(), vrf=self.vrf )) @@ -722,6 +784,7 @@ class IPAddress(ContactsMixin, PrimaryModel): max_length=50, choices=IPAddressRoleChoices, blank=True, + null=True, help_text=_('The functional role of this IP') ) assigned_object_type = models.ForeignKey( diff --git a/netbox/ipam/tables/ip.py b/netbox/ipam/tables/ip.py index 8ec7a5967..399641422 100644 --- a/netbox/ipam/tables/ip.py +++ b/netbox/ipam/tables/ip.py @@ -241,8 +241,11 @@ class PrefixTable(TenancyColumnsMixin, NetBoxTable): template_code=VRF_LINK, verbose_name=_('VRF') ) - site = tables.Column( - verbose_name=_('Site'), + scope_type = columns.ContentTypeColumn( + verbose_name=_('Scope Type'), + ) + scope = tables.Column( + verbose_name=_('Scope'), linkify=True ) vlan_group = tables.Column( @@ -285,11 +288,12 @@ class PrefixTable(TenancyColumnsMixin, NetBoxTable): model = Prefix fields = ( 'pk', 'id', 'prefix', 'prefix_flat', 'status', 'children', 'vrf', 'utilization', 'tenant', 'tenant_group', - 'site', 'vlan_group', 'vlan', 'role', 'is_pool', 'mark_utilized', 'description', 'comments', 'tags', - 'created', 'last_updated', + 'scope', 'scope_type', 'vlan_group', 'vlan', 'role', 'is_pool', 'mark_utilized', 'description', 'comments', + 'tags', 'created', 'last_updated', ) default_columns = ( - 'pk', 'prefix', 'status', 'children', 'vrf', 'utilization', 'tenant', 'site', 'vlan', 'role', 'description', + 'pk', 'prefix', 'status', 'children', 'vrf', 'utilization', 'tenant', 'scope', 'vlan', 'role', + 'description', ) row_attrs = { 'class': lambda record: 'success' if not record.pk else '', diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index 4e38b1450..7bc372fbf 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/netbox/ipam/tests/test_filtersets.py @@ -656,14 +656,14 @@ class PrefixTestCase(TestCase, ChangeLoggedFilterSetTests): Tenant.objects.bulk_create(tenants) prefixes = ( - Prefix(prefix='10.0.0.0/24', tenant=None, site=None, vrf=None, vlan=None, role=None, is_pool=True, mark_utilized=True, description='foobar1'), - Prefix(prefix='10.0.1.0/24', tenant=tenants[0], site=sites[0], vrf=vrfs[0], vlan=vlans[0], role=roles[0], description='foobar2'), - Prefix(prefix='10.0.2.0/24', tenant=tenants[1], site=sites[1], vrf=vrfs[1], vlan=vlans[1], role=roles[1], status=PrefixStatusChoices.STATUS_DEPRECATED), - Prefix(prefix='10.0.3.0/24', tenant=tenants[2], site=sites[2], vrf=vrfs[2], vlan=vlans[2], role=roles[2], status=PrefixStatusChoices.STATUS_RESERVED), - Prefix(prefix='2001:db8::/64', tenant=None, site=None, vrf=None, vlan=None, role=None, is_pool=True, mark_utilized=True), - Prefix(prefix='2001:db8:0:1::/64', tenant=tenants[0], site=sites[0], vrf=vrfs[0], vlan=vlans[0], role=roles[0]), - Prefix(prefix='2001:db8:0:2::/64', tenant=tenants[1], site=sites[1], vrf=vrfs[1], vlan=vlans[1], role=roles[1], status=PrefixStatusChoices.STATUS_DEPRECATED), - Prefix(prefix='2001:db8:0:3::/64', tenant=tenants[2], site=sites[2], vrf=vrfs[2], vlan=vlans[2], role=roles[2], status=PrefixStatusChoices.STATUS_RESERVED), + Prefix(prefix='10.0.0.0/24', tenant=None, scope=None, vrf=None, vlan=None, role=None, is_pool=True, mark_utilized=True, description='foobar1'), + Prefix(prefix='10.0.1.0/24', tenant=tenants[0], scope=sites[0], vrf=vrfs[0], vlan=vlans[0], role=roles[0], description='foobar2'), + Prefix(prefix='10.0.2.0/24', tenant=tenants[1], scope=sites[1], vrf=vrfs[1], vlan=vlans[1], role=roles[1], status=PrefixStatusChoices.STATUS_DEPRECATED), + Prefix(prefix='10.0.3.0/24', tenant=tenants[2], scope=sites[2], vrf=vrfs[2], vlan=vlans[2], role=roles[2], status=PrefixStatusChoices.STATUS_RESERVED), + Prefix(prefix='2001:db8::/64', tenant=None, scope=None, vrf=None, vlan=None, role=None, is_pool=True, mark_utilized=True), + Prefix(prefix='2001:db8:0:1::/64', tenant=tenants[0], scope=sites[0], vrf=vrfs[0], vlan=vlans[0], role=roles[0]), + Prefix(prefix='2001:db8:0:2::/64', tenant=tenants[1], scope=sites[1], vrf=vrfs[1], vlan=vlans[1], role=roles[1], status=PrefixStatusChoices.STATUS_DEPRECATED), + Prefix(prefix='2001:db8:0:3::/64', tenant=tenants[2], scope=sites[2], vrf=vrfs[2], vlan=vlans[2], role=roles[2], status=PrefixStatusChoices.STATUS_RESERVED), Prefix(prefix='10.0.0.0/16'), Prefix(prefix='2001:db8::/32'), ) diff --git a/netbox/ipam/tests/test_models.py b/netbox/ipam/tests/test_models.py index 8a5d918a9..d14fa0657 100644 --- a/netbox/ipam/tests/test_models.py +++ b/netbox/ipam/tests/test_models.py @@ -36,6 +36,35 @@ class TestAggregate(TestCase): self.assertEqual(aggregate.get_utilization(), 100) +class TestIPRange(TestCase): + + def test_overlapping_range(self): + iprange_192_168 = IPRange.objects.create(start_address=IPNetwork('192.168.0.1/22'), end_address=IPNetwork('192.168.0.49/22')) + iprange_192_168.clean() + iprange_3_1_99 = IPRange.objects.create(start_address=IPNetwork('1.2.3.1/24'), end_address=IPNetwork('1.2.3.99/24')) + iprange_3_1_99.clean() + iprange_3_100_199 = IPRange.objects.create(start_address=IPNetwork('1.2.3.100/24'), end_address=IPNetwork('1.2.3.199/24')) + iprange_3_100_199.clean() + iprange_3_200_255 = IPRange.objects.create(start_address=IPNetwork('1.2.3.200/24'), end_address=IPNetwork('1.2.3.255/24')) + iprange_3_200_255.clean() + iprange_4_1_99 = IPRange.objects.create(start_address=IPNetwork('1.2.4.1/24'), end_address=IPNetwork('1.2.4.99/24')) + iprange_4_1_99.clean() + iprange_4_200 = IPRange.objects.create(start_address=IPNetwork('1.2.4.200/24'), end_address=IPNetwork('1.2.4.255/24')) + iprange_4_200.clean() + # Overlapping range entirely within existing + with self.assertRaises(ValidationError): + iprange_3_123_124 = IPRange.objects.create(start_address=IPNetwork('1.2.3.123/26'), end_address=IPNetwork('1.2.3.124/26')) + iprange_3_123_124.clean() + # Overlapping range starting within existing + with self.assertRaises(ValidationError): + iprange_4_98_101 = IPRange.objects.create(start_address=IPNetwork('1.2.4.98/24'), end_address=IPNetwork('1.2.4.101/24')) + iprange_4_98_101.clean() + # Overlapping range ending within existing + with self.assertRaises(ValidationError): + iprange_4_198_201 = IPRange.objects.create(start_address=IPNetwork('1.2.4.198/24'), end_address=IPNetwork('1.2.4.201/24')) + iprange_4_198_201.clean() + + class TestPrefix(TestCase): def test_get_duplicates(self): diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index 95b311878..27d88767b 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -1,5 +1,6 @@ import datetime +from django.contrib.contenttypes.models import ContentType from django.test import override_settings from django.urls import reverse from netaddr import IPNetwork @@ -409,9 +410,9 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase): Role.objects.bulk_create(roles) prefixes = ( - Prefix(prefix=IPNetwork('10.1.0.0/16'), vrf=vrfs[0], site=sites[0], role=roles[0]), - Prefix(prefix=IPNetwork('10.2.0.0/16'), vrf=vrfs[0], site=sites[0], role=roles[0]), - Prefix(prefix=IPNetwork('10.3.0.0/16'), vrf=vrfs[0], site=sites[0], role=roles[0]), + Prefix(prefix=IPNetwork('10.1.0.0/16'), vrf=vrfs[0], scope=sites[0], role=roles[0]), + Prefix(prefix=IPNetwork('10.2.0.0/16'), vrf=vrfs[0], scope=sites[0], role=roles[0]), + Prefix(prefix=IPNetwork('10.3.0.0/16'), vrf=vrfs[0], scope=sites[0], role=roles[0]), ) Prefix.objects.bulk_create(prefixes) @@ -419,7 +420,8 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase): cls.form_data = { 'prefix': IPNetwork('192.0.2.0/24'), - 'site': sites[1].pk, + 'scope_type': ContentType.objects.get_for_model(Site).pk, + 'scope': sites[1].pk, 'vrf': vrfs[1].pk, 'tenant': None, 'vlan': None, @@ -430,11 +432,12 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'tags': [t.pk for t in tags], } + site = sites[0].pk cls.csv_data = ( - "vrf,prefix,status", - "VRF 1,10.4.0.0/16,active", - "VRF 1,10.5.0.0/16,active", - "VRF 1,10.6.0.0/16,active", + "vrf,prefix,status,scope_type,scope_id", + f"VRF 1,10.4.0.0/16,active,dcim.site,{site}", + f"VRF 1,10.5.0.0/16,active,dcim.site,{site}", + f"VRF 1,10.6.0.0/16,active,dcim.site,{site}", ) cls.csv_update_data = ( @@ -445,7 +448,6 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase): ) cls.bulk_edit_data = { - 'site': sites[1].pk, 'vrf': vrfs[1].pk, 'tenant': None, 'status': PrefixStatusChoices.STATUS_RESERVED, @@ -501,11 +503,13 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase): """ Custom import test for YAML-based imports (versus CSV) """ - IMPORT_DATA = """ + site = Site.objects.get(name='Site 1') + IMPORT_DATA = f""" prefix: 10.1.1.0/24 status: active vlan: 101 -site: Site 1 +scope_type: dcim.site +scope_id: {site.pk} """ # Note, a site is not tied to the VLAN to verify the fix for #12622 VLAN.objects.create(vid=101, name='VLAN101') @@ -523,19 +527,21 @@ site: Site 1 prefix = Prefix.objects.get(prefix='10.1.1.0/24') self.assertEqual(prefix.status, PrefixStatusChoices.STATUS_ACTIVE) self.assertEqual(prefix.vlan.vid, 101) - self.assertEqual(prefix.site.name, "Site 1") + self.assertEqual(prefix.scope, site) @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_prefix_import_with_vlan_group(self): """ This test covers a unique import edge case where VLAN group is specified during the import. """ - IMPORT_DATA = """ + site = Site.objects.get(name='Site 1') + IMPORT_DATA = f""" prefix: 10.1.2.0/24 status: active -vlan: 102 -site: Site 1 +scope_type: dcim.site +scope_id: {site.pk} vlan_group: Group 1 +vlan: 102 """ vlan_group = VLANGroup.objects.create(name='Group 1', slug='group-1', scope=Site.objects.get(name="Site 1")) VLAN.objects.create(vid=102, name='VLAN102', group=vlan_group) @@ -553,7 +559,7 @@ vlan_group: Group 1 prefix = Prefix.objects.get(prefix='10.1.2.0/24') self.assertEqual(prefix.status, PrefixStatusChoices.STATUS_ACTIVE) self.assertEqual(prefix.vlan.vid, 102) - self.assertEqual(prefix.site.name, "Site 1") + self.assertEqual(prefix.scope, site) class IPRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase): diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 67d56f15e..b712ef3b6 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -3,7 +3,7 @@ from django.db.models import Prefetch from django.db.models.expressions import RawSQL from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse -from django.utils.translation import gettext as _ +from django.utils.translation import gettext_lazy as _ from circuits.models import Provider from dcim.filtersets import InterfaceFilterSet @@ -352,7 +352,7 @@ class AggregatePrefixesView(generic.ObjectChildrenView): def get_children(self, request, parent): return Prefix.objects.restrict(request.user, 'view').filter( prefix__net_contained_or_equal=str(parent.prefix) - ).prefetch_related('site', 'role', 'tenant', 'tenant__group', 'vlan') + ).prefetch_related('scope', 'role', 'tenant', 'tenant__group', 'vlan') def prep_table_data(self, request, queryset, parent): # Determine whether to show assigned prefixes, available prefixes, or both @@ -492,7 +492,7 @@ class PrefixView(generic.ObjectView): ).filter( prefix__net_contains=str(instance.prefix) ).prefetch_related( - 'site', 'role', 'tenant', 'vlan', + 'scope', 'role', 'tenant', 'vlan', ) parent_prefix_table = tables.PrefixTable( list(parent_prefixes), @@ -506,7 +506,7 @@ class PrefixView(generic.ObjectView): ).exclude( pk=instance.pk ).prefetch_related( - 'site', 'role', 'tenant', 'vlan', + 'scope', 'role', 'tenant', 'vlan', ) duplicate_prefix_table = tables.PrefixTable( list(duplicate_prefixes), @@ -538,7 +538,7 @@ class PrefixPrefixesView(generic.ObjectChildrenView): def get_children(self, request, parent): return parent.get_child_prefixes().restrict(request.user, 'view').prefetch_related( - 'site', 'vrf', 'vlan', 'role', 'tenant', 'tenant__group' + 'scope', 'vrf', 'vlan', 'role', 'tenant', 'tenant__group' ) def prep_table_data(self, request, queryset, parent): diff --git a/netbox/manage.py b/netbox/manage.py index 2ce3867f3..737592ae8 100755 --- a/netbox/manage.py +++ b/netbox/manage.py @@ -1,4 +1,4 @@ -#!/usr/bin/env python +#!/usr/bin/env python3 import os import sys diff --git a/netbox/netbox/authentication/__init__.py b/netbox/netbox/authentication/__init__.py index f80454f99..7c2df4200 100644 --- a/netbox/netbox/authentication/__init__.py +++ b/netbox/netbox/authentication/__init__.py @@ -20,10 +20,10 @@ AUTH_BACKEND_ATTRS = { 'amazon': ('Amazon AWS', 'aws'), 'apple': ('Apple', 'apple'), 'auth0': ('Auth0', None), - 'entraid-oauth2': ('Microsoft Entra ID', 'microsoft'), - 'entraid-b2c-oauth2': ('Microsoft Entra ID', 'microsoft'), - 'entraid-tenant-oauth2': ('Microsoft Entra ID', 'microsoft'), - 'entraid-v2-tenant-oauth2': ('Microsoft Entra ID', 'microsoft'), + 'azuread-oauth2': ('Microsoft Entra ID', 'microsoft'), + 'azuread-b2c-oauth2': ('Microsoft Entra ID', 'microsoft'), + 'azuread-tenant-oauth2': ('Microsoft Entra ID', 'microsoft'), + 'azuread-v2-tenant-oauth2': ('Microsoft Entra ID', 'microsoft'), 'bitbucket': ('BitBucket', 'bitbucket'), 'bitbucket-oauth2': ('BitBucket', 'bitbucket'), 'digitalocean': ('DigitalOcean', 'digital-ocean'), diff --git a/netbox/netbox/graphql/views.py b/netbox/netbox/graphql/views.py index b347d71b4..85a01f025 100644 --- a/netbox/netbox/graphql/views.py +++ b/netbox/netbox/graphql/views.py @@ -1,10 +1,6 @@ -import json - from django.conf import settings from django.contrib.auth.views import redirect_to_login from django.http import HttpResponseNotFound, HttpResponseForbidden -from django.http import HttpResponse -from django.template import loader from django.urls import reverse from django.views.decorators.csrf import csrf_exempt from rest_framework.exceptions import AuthenticationFailed @@ -46,9 +42,3 @@ class NetBoxGraphQLView(GraphQLView): return HttpResponseForbidden("No credentials provided.") return super().dispatch(request, *args, **kwargs) - - def render_graphql_ide(self, request): - template = loader.get_template("graphiql.html") - context = {"SUBSCRIPTION_ENABLED": json.dumps(self.subscriptions_enabled)} - - return HttpResponse(template.render(context, request)) diff --git a/netbox/netbox/jobs.py b/netbox/netbox/jobs.py index b357c7144..a2a0c35ac 100644 --- a/netbox/netbox/jobs.py +++ b/netbox/netbox/jobs.py @@ -68,6 +68,8 @@ class JobRunner(ABC): finally: if job.interval: new_scheduled_time = (job.scheduled or job.started) + timedelta(minutes=job.interval) + if job.object and getattr(job.object, "python_class", None): + kwargs["job_timeout"] = job.object.python_class.job_timeout cls.enqueue( instance=job.object, user=job.user, diff --git a/netbox/netbox/models/mixins.py b/netbox/netbox/models/mixins.py index 804e0b71a..dc706c7c2 100644 --- a/netbox/netbox/models/mixins.py +++ b/netbox/netbox/models/mixins.py @@ -23,6 +23,7 @@ class WeightMixin(models.Model): max_length=50, choices=WeightUnitChoices, blank=True, + null=True, ) # Stores the normalized weight (in grams) for database ordering _abs_weight = models.PositiveBigIntegerField( @@ -64,6 +65,7 @@ class DistanceMixin(models.Model): max_length=50, choices=DistanceUnitChoices, blank=True, + null=True, ) # Stores the normalized distance (in meters) for database ordering _abs_distance = models.DecimalField( @@ -85,7 +87,7 @@ class DistanceMixin(models.Model): # Clear distance_unit if no distance is defined if self.distance is None: - self.distance_unit = '' + self.distance_unit = None super().save(*args, **kwargs) diff --git a/netbox/netbox/plugins/__init__.py b/netbox/netbox/plugins/__init__.py index 76bb9d26b..42e2e26bd 100644 --- a/netbox/netbox/plugins/__init__.py +++ b/netbox/netbox/plugins/__init__.py @@ -80,6 +80,7 @@ class PluginConfig(AppConfig): menu_items = None template_extensions = None user_preferences = None + events_pipeline = [] def _load_resource(self, name): # Import from the configured path, if defined. diff --git a/netbox/netbox/plugins/registration.py b/netbox/netbox/plugins/registration.py index c84572794..515405f1b 100644 --- a/netbox/netbox/plugins/registration.py +++ b/netbox/netbox/plugins/registration.py @@ -1,4 +1,5 @@ import inspect +import warnings from django.utils.translation import gettext_lazy as _ from netbox.registry import registry @@ -37,7 +38,12 @@ def register_template_extensions(class_list): # Registration for multiple models models = template_extension.models elif template_extension.model: - # Registration for a single model + # Registration for a single model (deprecated) + warnings.warn( + "PluginTemplateExtension.model is deprecated and will be removed in a future release. Use " + "'models' instead.", + DeprecationWarning + ) models = [template_extension.model] else: # Global registration (no specific models) diff --git a/netbox/netbox/plugins/templates.py b/netbox/netbox/plugins/templates.py index e1f4b7a47..4ea90b4db 100644 --- a/netbox/netbox/plugins/templates.py +++ b/netbox/netbox/plugins/templates.py @@ -21,7 +21,7 @@ class PluginTemplateExtension: * config - Plugin-specific configuration parameters """ models = None - model = None + model = None # Deprecated; use `models` instead def __init__(self, context): self.context = context diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 2b057b9ab..0682e713d 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -110,9 +110,9 @@ DEFAULT_PERMISSIONS = getattr(configuration, 'DEFAULT_PERMISSIONS', { DEVELOPER = getattr(configuration, 'DEVELOPER', False) DOCS_ROOT = getattr(configuration, 'DOCS_ROOT', os.path.join(os.path.dirname(BASE_DIR), 'docs')) EMAIL = getattr(configuration, 'EMAIL', {}) -EVENTS_PIPELINE = getattr(configuration, 'EVENTS_PIPELINE', ( +EVENTS_PIPELINE = getattr(configuration, 'EVENTS_PIPELINE', [ 'extras.events.process_event_queue', -)) +]) 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) @@ -787,6 +787,10 @@ STRAWBERRY_DJANGO = { PLUGIN_CATALOG_URL = 'https://api.netbox.oss.netboxlabs.com/v1/plugins' +EVENTS_PIPELINE = list(EVENTS_PIPELINE) +if 'extras.events.process_event_queue' not in EVENTS_PIPELINE: + EVENTS_PIPELINE.insert(0, 'extras.events.process_event_queue') + # Register any configured plugins for plugin_name in PLUGINS: try: @@ -857,6 +861,13 @@ for plugin_name in PLUGINS: f"{plugin_name}.{queue}": RQ_PARAMS for queue in plugin_config.queues }) + events_pipeline = plugin_config.events_pipeline + if events_pipeline: + if type(events_pipeline) in (list, tuple): + EVENTS_PIPELINE.extend(events_pipeline) + else: + raise ImproperlyConfigured(f"events_pipline in plugin: {plugin_name} must be a list or tuple") + # UNSUPPORTED FUNCTIONALITY: Import any local overrides. try: from .local_settings import * diff --git a/netbox/netbox/tests/dummy_plugin/__init__.py b/netbox/netbox/tests/dummy_plugin/__init__.py index 3ade8f9df..6ab62d638 100644 --- a/netbox/netbox/tests/dummy_plugin/__init__.py +++ b/netbox/netbox/tests/dummy_plugin/__init__.py @@ -17,6 +17,9 @@ class DummyPluginConfig(PluginConfig): 'testing-medium', 'testing-high' ] + events_pipeline = [ + 'netbox.tests.dummy_plugin.events.process_events_queue' + ] config = DummyPluginConfig diff --git a/netbox/netbox/tests/dummy_plugin/events.py b/netbox/netbox/tests/dummy_plugin/events.py new file mode 100644 index 000000000..934594643 --- /dev/null +++ b/netbox/netbox/tests/dummy_plugin/events.py @@ -0,0 +1,2 @@ +def process_events_queue(events): + pass diff --git a/netbox/netbox/tests/test_authentication.py b/netbox/netbox/tests/test_authentication.py index ae6d3f4c2..9eb21661d 100644 --- a/netbox/netbox/tests/test_authentication.py +++ b/netbox/netbox/tests/test_authentication.py @@ -4,12 +4,10 @@ from django.conf import settings from django.test import Client from django.test.utils import override_settings from django.urls import reverse -from netaddr import IPNetwork from rest_framework.test import APIClient from core.models import ObjectType -from dcim.models import Site -from ipam.models import Prefix +from dcim.models import Rack, Site from users.models import Group, ObjectPermission, Token, User from utilities.testing import TestCase from utilities.testing.api import APITestCase @@ -410,18 +408,18 @@ class ObjectPermissionAPIViewTestCase(TestCase): ) Site.objects.bulk_create(cls.sites) - cls.prefixes = ( - Prefix(prefix=IPNetwork('10.0.0.0/24'), site=cls.sites[0]), - Prefix(prefix=IPNetwork('10.0.1.0/24'), site=cls.sites[0]), - Prefix(prefix=IPNetwork('10.0.2.0/24'), site=cls.sites[0]), - Prefix(prefix=IPNetwork('10.0.3.0/24'), site=cls.sites[1]), - Prefix(prefix=IPNetwork('10.0.4.0/24'), site=cls.sites[1]), - Prefix(prefix=IPNetwork('10.0.5.0/24'), site=cls.sites[1]), - Prefix(prefix=IPNetwork('10.0.6.0/24'), site=cls.sites[2]), - Prefix(prefix=IPNetwork('10.0.7.0/24'), site=cls.sites[2]), - Prefix(prefix=IPNetwork('10.0.8.0/24'), site=cls.sites[2]), + cls.racks = ( + Rack(name='Rack 1', site=cls.sites[0]), + Rack(name='Rack 2', site=cls.sites[0]), + Rack(name='Rack 3', site=cls.sites[0]), + Rack(name='Rack 4', site=cls.sites[1]), + Rack(name='Rack 5', site=cls.sites[1]), + Rack(name='Rack 6', site=cls.sites[1]), + Rack(name='Rack 7', site=cls.sites[2]), + Rack(name='Rack 8', site=cls.sites[2]), + Rack(name='Rack 9', site=cls.sites[2]), ) - Prefix.objects.bulk_create(cls.prefixes) + Rack.objects.bulk_create(cls.racks) def setUp(self): """ @@ -435,8 +433,7 @@ class ObjectPermissionAPIViewTestCase(TestCase): def test_get_object(self): # Attempt to retrieve object without permission - url = reverse('ipam-api:prefix-detail', - kwargs={'pk': self.prefixes[0].pk}) + url = reverse('dcim-api:rack-detail', kwargs={'pk': self.racks[0].pk}) response = self.client.get(url, **self.header) self.assertEqual(response.status_code, 403) @@ -448,23 +445,21 @@ class ObjectPermissionAPIViewTestCase(TestCase): ) obj_perm.save() obj_perm.users.add(self.user) - obj_perm.object_types.add(ObjectType.objects.get_for_model(Prefix)) + obj_perm.object_types.add(ObjectType.objects.get_for_model(Rack)) # Retrieve permitted object - url = reverse('ipam-api:prefix-detail', - kwargs={'pk': self.prefixes[0].pk}) + url = reverse('dcim-api:rack-detail', kwargs={'pk': self.racks[0].pk}) response = self.client.get(url, **self.header) self.assertEqual(response.status_code, 200) # Attempt to retrieve non-permitted object - url = reverse('ipam-api:prefix-detail', - kwargs={'pk': self.prefixes[3].pk}) + url = reverse('dcim-api:rack-detail', kwargs={'pk': self.racks[3].pk}) response = self.client.get(url, **self.header) self.assertEqual(response.status_code, 404) @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_list_objects(self): - url = reverse('ipam-api:prefix-list') + url = reverse('dcim-api:rack-list') # Attempt to list objects without permission response = self.client.get(url, **self.header) @@ -478,7 +473,7 @@ class ObjectPermissionAPIViewTestCase(TestCase): ) obj_perm.save() obj_perm.users.add(self.user) - obj_perm.object_types.add(ObjectType.objects.get_for_model(Prefix)) + obj_perm.object_types.add(ObjectType.objects.get_for_model(Rack)) # Retrieve all objects. Only permitted objects should be returned. response = self.client.get(url, **self.header) @@ -487,12 +482,12 @@ class ObjectPermissionAPIViewTestCase(TestCase): @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_create_object(self): - url = reverse('ipam-api:prefix-list') + url = reverse('dcim-api:rack-list') data = { - 'prefix': '10.0.9.0/24', + 'name': 'Rack 10', 'site': self.sites[1].pk, } - initial_count = Prefix.objects.count() + initial_count = Rack.objects.count() # Attempt to create an object without permission response = self.client.post(url, data, format='json', **self.header) @@ -506,26 +501,25 @@ class ObjectPermissionAPIViewTestCase(TestCase): ) obj_perm.save() obj_perm.users.add(self.user) - obj_perm.object_types.add(ObjectType.objects.get_for_model(Prefix)) + obj_perm.object_types.add(ObjectType.objects.get_for_model(Rack)) # Attempt to create a non-permitted object response = self.client.post(url, data, format='json', **self.header) self.assertEqual(response.status_code, 403) - self.assertEqual(Prefix.objects.count(), initial_count) + self.assertEqual(Rack.objects.count(), initial_count) # Create a permitted object data['site'] = self.sites[0].pk response = self.client.post(url, data, format='json', **self.header) self.assertEqual(response.status_code, 201) - self.assertEqual(Prefix.objects.count(), initial_count + 1) + self.assertEqual(Rack.objects.count(), initial_count + 1) @override_settings(EXEMPT_VIEW_PERMISSIONS=[]) def test_edit_object(self): # Attempt to edit an object without permission data = {'site': self.sites[0].pk} - url = reverse('ipam-api:prefix-detail', - kwargs={'pk': self.prefixes[0].pk}) + url = reverse('dcim-api:rack-detail', kwargs={'pk': self.racks[0].pk}) response = self.client.patch(url, data, format='json', **self.header) self.assertEqual(response.status_code, 403) @@ -537,26 +531,23 @@ class ObjectPermissionAPIViewTestCase(TestCase): ) obj_perm.save() obj_perm.users.add(self.user) - obj_perm.object_types.add(ObjectType.objects.get_for_model(Prefix)) + obj_perm.object_types.add(ObjectType.objects.get_for_model(Rack)) # Attempt to edit a non-permitted object data = {'site': self.sites[0].pk} - url = reverse('ipam-api:prefix-detail', - kwargs={'pk': self.prefixes[3].pk}) + url = reverse('dcim-api:rack-detail', kwargs={'pk': self.racks[3].pk}) response = self.client.patch(url, data, format='json', **self.header) self.assertEqual(response.status_code, 404) # Edit a permitted object data['status'] = 'reserved' - url = reverse('ipam-api:prefix-detail', - kwargs={'pk': self.prefixes[0].pk}) + url = reverse('dcim-api:rack-detail', kwargs={'pk': self.racks[0].pk}) response = self.client.patch(url, data, format='json', **self.header) self.assertEqual(response.status_code, 200) # Attempt to modify a permitted object to a non-permitted object data['site'] = self.sites[1].pk - url = reverse('ipam-api:prefix-detail', - kwargs={'pk': self.prefixes[0].pk}) + url = reverse('dcim-api:rack-detail', kwargs={'pk': self.racks[0].pk}) response = self.client.patch(url, data, format='json', **self.header) self.assertEqual(response.status_code, 403) @@ -564,8 +555,7 @@ class ObjectPermissionAPIViewTestCase(TestCase): def test_delete_object(self): # Attempt to delete an object without permission - url = reverse('ipam-api:prefix-detail', - kwargs={'pk': self.prefixes[0].pk}) + url = reverse('dcim-api:rack-detail', kwargs={'pk': self.racks[0].pk}) response = self.client.delete(url, format='json', **self.header) self.assertEqual(response.status_code, 403) @@ -577,16 +567,14 @@ class ObjectPermissionAPIViewTestCase(TestCase): ) obj_perm.save() obj_perm.users.add(self.user) - obj_perm.object_types.add(ObjectType.objects.get_for_model(Prefix)) + obj_perm.object_types.add(ObjectType.objects.get_for_model(Rack)) # Attempt to delete a non-permitted object - url = reverse('ipam-api:prefix-detail', - kwargs={'pk': self.prefixes[3].pk}) + url = reverse('dcim-api:rack-detail', kwargs={'pk': self.racks[3].pk}) response = self.client.delete(url, format='json', **self.header) self.assertEqual(response.status_code, 404) # Delete a permitted object - url = reverse('ipam-api:prefix-detail', - kwargs={'pk': self.prefixes[0].pk}) + url = reverse('dcim-api:rack-detail', kwargs={'pk': self.racks[0].pk}) response = self.client.delete(url, format='json', **self.header) self.assertEqual(response.status_code, 204) diff --git a/netbox/netbox/tests/test_jobs.py b/netbox/netbox/tests/test_jobs.py index 0e230d467..fef5110fb 100644 --- a/netbox/netbox/tests/test_jobs.py +++ b/netbox/netbox/tests/test_jobs.py @@ -5,7 +5,7 @@ from django.utils import timezone from django_rq import get_queue from ..jobs import * -from core.models import Job +from core.models import DataSource, Job from core.choices import JobStatusChoices @@ -68,7 +68,7 @@ class EnqueueTest(JobRunnerTestCase): """ def test_enqueue(self): - instance = Job() + instance = DataSource() for i in range(1, 3): job = TestJobRunner.enqueue(instance, schedule_at=self.get_schedule_at()) @@ -76,13 +76,13 @@ class EnqueueTest(JobRunnerTestCase): self.assertEqual(TestJobRunner.get_jobs(instance).count(), i) def test_enqueue_once(self): - job = TestJobRunner.enqueue_once(instance=Job(), schedule_at=self.get_schedule_at()) + job = TestJobRunner.enqueue_once(instance=DataSource(), schedule_at=self.get_schedule_at()) self.assertIsInstance(job, Job) self.assertEqual(job.name, TestJobRunner.__name__) def test_enqueue_once_twice_same(self): - instance = Job() + instance = DataSource() schedule_at = self.get_schedule_at() job1 = TestJobRunner.enqueue_once(instance, schedule_at=schedule_at) job2 = TestJobRunner.enqueue_once(instance, schedule_at=schedule_at) @@ -100,7 +100,7 @@ class EnqueueTest(JobRunnerTestCase): self.assertEqual(TestJobRunner.get_jobs(instance).count(), 1) def test_enqueue_once_twice_different_schedule_at(self): - instance = Job() + instance = DataSource() job1 = TestJobRunner.enqueue_once(instance, schedule_at=self.get_schedule_at()) job2 = TestJobRunner.enqueue_once(instance, schedule_at=self.get_schedule_at(2)) @@ -109,7 +109,7 @@ class EnqueueTest(JobRunnerTestCase): self.assertEqual(TestJobRunner.get_jobs(instance).count(), 1) def test_enqueue_once_twice_different_interval(self): - instance = Job() + instance = DataSource() schedule_at = self.get_schedule_at() job1 = TestJobRunner.enqueue_once(instance, schedule_at=schedule_at) job2 = TestJobRunner.enqueue_once(instance, schedule_at=schedule_at, interval=60) @@ -121,7 +121,7 @@ class EnqueueTest(JobRunnerTestCase): self.assertEqual(TestJobRunner.get_jobs(instance).count(), 1) def test_enqueue_once_with_enqueue(self): - instance = Job() + instance = DataSource() job1 = TestJobRunner.enqueue_once(instance, schedule_at=self.get_schedule_at(2)) job2 = TestJobRunner.enqueue(instance, schedule_at=self.get_schedule_at()) @@ -129,7 +129,7 @@ class EnqueueTest(JobRunnerTestCase): self.assertEqual(TestJobRunner.get_jobs(instance).count(), 2) def test_enqueue_once_after_enqueue(self): - instance = Job() + instance = DataSource() job1 = TestJobRunner.enqueue(instance, schedule_at=self.get_schedule_at()) job2 = TestJobRunner.enqueue_once(instance, schedule_at=self.get_schedule_at(2)) diff --git a/netbox/netbox/tests/test_plugins.py b/netbox/netbox/tests/test_plugins.py index 0fe8549a8..753e5c08a 100644 --- a/netbox/netbox/tests/test_plugins.py +++ b/netbox/netbox/tests/test_plugins.py @@ -211,3 +211,10 @@ class PluginTest(TestCase): self.assertEqual(get_plugin_config(plugin, 'foo'), 123) self.assertEqual(get_plugin_config(plugin, 'bar'), None) self.assertEqual(get_plugin_config(plugin, 'bar', default=456), 456) + + + def test_events_pipeline(self): + """ + Check that events pipeline is registered. + """ + self.assertIn('netbox.tests.dummy_plugin.events.process_events_queue', settings.EVENTS_PIPELINE) diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index d7d28b95f..d8115726c 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -3,7 +3,7 @@ import re from copy import deepcopy from django.contrib import messages -from django.contrib.contenttypes.fields import GenericRel +from django.contrib.contenttypes.fields import GenericForeignKey, GenericRel from django.contrib.contenttypes.models import ContentType from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist, ValidationError from django.db import transaction, IntegrityError @@ -576,7 +576,10 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView): for name, model_field in model_fields.items(): # Handle nullification if name in form.nullable_fields and name in nullified_fields: - setattr(obj, name, None if model_field.null else '') + if type(model_field) is GenericForeignKey: + setattr(obj, name, None) + else: + setattr(obj, name, None if model_field.null else '') # Normal fields elif name in form.changed_data: setattr(obj, name, form.cleaned_data[name]) @@ -688,7 +691,7 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView): logger.debug("Form validation failed") else: - form = self.form(initial=initial_data) + form = self.form(request.POST, initial=initial_data) restrict_form_fields(form, request.user) # Retrieve objects being edited diff --git a/netbox/netbox/views/generic/feature_views.py b/netbox/netbox/views/generic/feature_views.py index 451c9c01d..49862e83f 100644 --- a/netbox/netbox/views/generic/feature_views.py +++ b/netbox/netbox/views/generic/feature_views.py @@ -4,7 +4,7 @@ from django.contrib import messages from django.db import transaction from django.db.models import Q from django.shortcuts import get_object_or_404, redirect, render -from django.utils.translation import gettext as _ +from django.utils.translation import gettext_lazy as _ from django.views.generic import View from core.models import Job, ObjectChange diff --git a/netbox/project-static/dist/netbox.css b/netbox/project-static/dist/netbox.css index 93361c63a..cd66ffa0f 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 afdbea0f8..969d5c73a 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 212be3659..0f4ac63d3 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 8180ddb90..b4e3a04eb 100644 --- a/netbox/project-static/package.json +++ b/netbox/project-static/package.json @@ -29,8 +29,8 @@ "flatpickr": "4.6.13", "gridstack": "10.3.1", "htmx.org": "1.9.12", - "query-string": "9.1.0", - "sass": "1.79.3", + "query-string": "9.1.1", + "sass": "1.80.4", "tom-select": "2.3.1", "typeface-inter": "3.18.1", "typeface-roboto-mono": "1.1.13" diff --git a/netbox/project-static/src/buttons/floatBulk.ts b/netbox/project-static/src/buttons/floatBulk.ts new file mode 100644 index 000000000..0735690cd --- /dev/null +++ b/netbox/project-static/src/buttons/floatBulk.ts @@ -0,0 +1,42 @@ +import { getElements } from '../util'; + +/** + * Conditionally add and remove a class that will float the button group + * based on whether or not items in the list are checked + */ +function toggleFloat(): void { + const checkedCheckboxes = document.querySelector( + 'input[type="checkbox"][name="pk"]:checked', + ); + const buttonGroup = document.querySelector( + 'div.form.form-horizontal div.btn-list', + ); + if (!buttonGroup) { + return; + } + const isFloating = buttonGroup.classList.contains('btn-float-group-left'); + if (checkedCheckboxes !== null && !isFloating) { + buttonGroup.classList.add('btn-float-group-left'); + } else if (checkedCheckboxes === null && isFloating) { + buttonGroup.classList.remove('btn-float-group-left'); + } +} + +/** + * Initialize floating bulk buttons. + */ +export function initFloatBulk(): void { + for (const element of getElements('input[type="checkbox"][name="pk"]')) { + element.addEventListener('change', () => { + toggleFloat(); + }); + } + // Handle the select-all checkbox + for (const element of getElements( + 'table tr th > input[type="checkbox"].toggle', + )) { + element.addEventListener('change', () => { + toggleFloat(); + }); + } +} diff --git a/netbox/project-static/src/buttons/index.ts b/netbox/project-static/src/buttons/index.ts index 6c1c0db0b..cb520b818 100644 --- a/netbox/project-static/src/buttons/index.ts +++ b/netbox/project-static/src/buttons/index.ts @@ -3,6 +3,7 @@ import { initDepthToggle } from './depthToggle'; import { initMoveButtons } from './moveOptions'; import { initReslug } from './reslug'; import { initSelectAll } from './selectAll'; +import { initFloatBulk } from './floatBulk'; import { initSelectMultiple } from './selectMultiple'; import { initMarkdownPreviews } from './markdownPreview'; import { initSecretToggle } from './secretToggle'; @@ -14,6 +15,7 @@ export function initButtons(): void { initReslug, initSelectAll, initSelectMultiple, + initFloatBulk, initMoveButtons, initMarkdownPreviews, initSecretToggle, diff --git a/netbox/project-static/styles/custom/_markdown.scss b/netbox/project-static/styles/custom/_markdown.scss index 32ef7a09c..75ada3bc6 100644 --- a/netbox/project-static/styles/custom/_markdown.scss +++ b/netbox/project-static/styles/custom/_markdown.scss @@ -28,16 +28,19 @@ } -// Remove the bottom margin of

elements inside a table cell -td > .rendered-markdown { - max-height: 200px; - overflow-y: scroll; - +// Remove the bottom margin of the last

elements in markdown +.rendered-markdown { p:last-of-type { margin-bottom: 0; } } +// fix layout of rendered markdown inside a table cell +td > .rendered-markdown { + max-height: 200px; + overflow-y: scroll; +} + // Markdown preview .markdown-widget { .preview { diff --git a/netbox/project-static/styles/custom/_misc.scss b/netbox/project-static/styles/custom/_misc.scss index 08125e8d3..4ef2f1337 100644 --- a/netbox/project-static/styles/custom/_misc.scss +++ b/netbox/project-static/styles/custom/_misc.scss @@ -33,3 +33,33 @@ span.color-label { .netbox-edition { letter-spacing: .15rem; } + +// A floating div for form buttons +.btn-float-group { + position: sticky; + bottom: 10px; + z-index: 2; +} + +.btn-float-group-left { + @extend .btn-float-group; + float: left; +} + +.btn-float-group-right { + @extend .btn-float-group; + float: right; +} + +// Override a transparent background +.btn-float { + --tblr-btn-bg: var(--#{$prefix}bg-surface-tertiary) !important; +} + +.logo { + height: 80px; +} + +.sso-icon { + height: 24px; +} diff --git a/netbox/project-static/styles/overrides/_tabler.scss b/netbox/project-static/styles/overrides/_tabler.scss index 252da8f4a..6f7c7cc8c 100644 --- a/netbox/project-static/styles/overrides/_tabler.scss +++ b/netbox/project-static/styles/overrides/_tabler.scss @@ -131,6 +131,11 @@ body[data-bs-theme=dark] { .toast { color: var(--#{$prefix}body-color); } + .table-primary { + --tblr-table-bg: rgba(var(--tblr-secondary-rgb), 0.48); + --tblr-table-hover-bg: inherit; + --tblr-table-hover-color: inherit; + } } // Do not apply padding to elements inside a

diff --git a/netbox/project-static/yarn.lock b/netbox/project-static/yarn.lock
index e1a30b229..9c57c5485 100644
--- a/netbox/project-static/yarn.lock
+++ b/netbox/project-static/yarn.lock
@@ -365,6 +365,89 @@
   resolved "https://registry.yarnpkg.com/@orchidjs/unicode-variants/-/unicode-variants-1.0.4.tgz#6d2f812e3b19545bba2d81caffff1204de9a6a58"
   integrity sha512-NvVBRnZNE+dugiXERFsET1JlKZfM5lJDEpSMilKW4bToYJ7pxf0Zne78xyXB2ny2c2aHfJ6WLnz1AaTNHAmQeQ==
 
+"@parcel/watcher-android-arm64@2.4.1":
+  version "2.4.1"
+  resolved "https://registry.yarnpkg.com/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.4.1.tgz#c2c19a3c442313ff007d2d7a9c2c1dd3e1c9ca84"
+  integrity sha512-LOi/WTbbh3aTn2RYddrO8pnapixAziFl6SMxHM69r3tvdSm94JtCenaKgk1GRg5FJ5wpMCpHeW+7yqPlvZv7kg==
+
+"@parcel/watcher-darwin-arm64@2.4.1":
+  version "2.4.1"
+  resolved "https://registry.yarnpkg.com/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.4.1.tgz#c817c7a3b4f3a79c1535bfe54a1c2818d9ffdc34"
+  integrity sha512-ln41eihm5YXIY043vBrrHfn94SIBlqOWmoROhsMVTSXGh0QahKGy77tfEywQ7v3NywyxBBkGIfrWRHm0hsKtzA==
+
+"@parcel/watcher-darwin-x64@2.4.1":
+  version "2.4.1"
+  resolved "https://registry.yarnpkg.com/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.4.1.tgz#1a3f69d9323eae4f1c61a5f480a59c478d2cb020"
+  integrity sha512-yrw81BRLjjtHyDu7J61oPuSoeYWR3lDElcPGJyOvIXmor6DEo7/G2u1o7I38cwlcoBHQFULqF6nesIX3tsEXMg==
+
+"@parcel/watcher-freebsd-x64@2.4.1":
+  version "2.4.1"
+  resolved "https://registry.yarnpkg.com/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.4.1.tgz#0d67fef1609f90ba6a8a662bc76a55fc93706fc8"
+  integrity sha512-TJa3Pex/gX3CWIx/Co8k+ykNdDCLx+TuZj3f3h7eOjgpdKM+Mnix37RYsYU4LHhiYJz3DK5nFCCra81p6g050w==
+
+"@parcel/watcher-linux-arm-glibc@2.4.1":
+  version "2.4.1"
+  resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.4.1.tgz#ce5b340da5829b8e546bd00f752ae5292e1c702d"
+  integrity sha512-4rVYDlsMEYfa537BRXxJ5UF4ddNwnr2/1O4MHM5PjI9cvV2qymvhwZSFgXqbS8YoTk5i/JR0L0JDs69BUn45YA==
+
+"@parcel/watcher-linux-arm64-glibc@2.4.1":
+  version "2.4.1"
+  resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.4.1.tgz#6d7c00dde6d40608f9554e73998db11b2b1ff7c7"
+  integrity sha512-BJ7mH985OADVLpbrzCLgrJ3TOpiZggE9FMblfO65PlOCdG++xJpKUJ0Aol74ZUIYfb8WsRlUdgrZxKkz3zXWYA==
+
+"@parcel/watcher-linux-arm64-musl@2.4.1":
+  version "2.4.1"
+  resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.4.1.tgz#bd39bc71015f08a4a31a47cd89c236b9d6a7f635"
+  integrity sha512-p4Xb7JGq3MLgAfYhslU2SjoV9G0kI0Xry0kuxeG/41UfpjHGOhv7UoUDAz/jb1u2elbhazy4rRBL8PegPJFBhA==
+
+"@parcel/watcher-linux-x64-glibc@2.4.1":
+  version "2.4.1"
+  resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.4.1.tgz#0ce29966b082fb6cdd3de44f2f74057eef2c9e39"
+  integrity sha512-s9O3fByZ/2pyYDPoLM6zt92yu6P4E39a03zvO0qCHOTjxmt3GHRMLuRZEWhWLASTMSrrnVNWdVI/+pUElJBBBg==
+
+"@parcel/watcher-linux-x64-musl@2.4.1":
+  version "2.4.1"
+  resolved "https://registry.yarnpkg.com/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.4.1.tgz#d2ebbf60e407170bb647cd6e447f4f2bab19ad16"
+  integrity sha512-L2nZTYR1myLNST0O632g0Dx9LyMNHrn6TOt76sYxWLdff3cB22/GZX2UPtJnaqQPdCRoszoY5rcOj4oMTtp5fQ==
+
+"@parcel/watcher-win32-arm64@2.4.1":
+  version "2.4.1"
+  resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.4.1.tgz#eb4deef37e80f0b5e2f215dd6d7a6d40a85f8adc"
+  integrity sha512-Uq2BPp5GWhrq/lcuItCHoqxjULU1QYEcyjSO5jqqOK8RNFDBQnenMMx4gAl3v8GiWa59E9+uDM7yZ6LxwUIfRg==
+
+"@parcel/watcher-win32-ia32@2.4.1":
+  version "2.4.1"
+  resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.4.1.tgz#94fbd4b497be39fd5c8c71ba05436927842c9df7"
+  integrity sha512-maNRit5QQV2kgHFSYwftmPBxiuK5u4DXjbXx7q6eKjq5dsLXZ4FJiVvlcw35QXzk0KrUecJmuVFbj4uV9oYrcw==
+
+"@parcel/watcher-win32-x64@2.4.1":
+  version "2.4.1"
+  resolved "https://registry.yarnpkg.com/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.4.1.tgz#4bf920912f67cae5f2d264f58df81abfea68dadf"
+  integrity sha512-+DvS92F9ezicfswqrvIRM2njcYJbd5mb9CUgtrHCHmvn7pPPa+nMDRu1o1bYYz/l5IB2NVGNJWiH7h1E58IF2A==
+
+"@parcel/watcher@^2.4.1":
+  version "2.4.1"
+  resolved "https://registry.yarnpkg.com/@parcel/watcher/-/watcher-2.4.1.tgz#a50275151a1bb110879c6123589dba90c19f1bf8"
+  integrity sha512-HNjmfLQEVRZmHRET336f20H/8kOozUGwk7yajvsonjNxbj2wBTK1WsQuHkD5yYh9RxFGL2EyDHryOihOwUoKDA==
+  dependencies:
+    detect-libc "^1.0.3"
+    is-glob "^4.0.3"
+    micromatch "^4.0.5"
+    node-addon-api "^7.0.0"
+  optionalDependencies:
+    "@parcel/watcher-android-arm64" "2.4.1"
+    "@parcel/watcher-darwin-arm64" "2.4.1"
+    "@parcel/watcher-darwin-x64" "2.4.1"
+    "@parcel/watcher-freebsd-x64" "2.4.1"
+    "@parcel/watcher-linux-arm-glibc" "2.4.1"
+    "@parcel/watcher-linux-arm64-glibc" "2.4.1"
+    "@parcel/watcher-linux-arm64-musl" "2.4.1"
+    "@parcel/watcher-linux-x64-glibc" "2.4.1"
+    "@parcel/watcher-linux-x64-musl" "2.4.1"
+    "@parcel/watcher-win32-arm64" "2.4.1"
+    "@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"
@@ -1196,6 +1279,11 @@ delegate@^3.1.2:
   resolved "https://registry.yarnpkg.com/delegate/-/delegate-3.2.0.tgz#b66b71c3158522e8ab5744f720d8ca0c2af59166"
   integrity sha512-IofjkYBZaZivn0V8nnsMJGBr4jVLxHDheKSW88PyxS5QC4Vo9ZbZVvhzlSxY87fVq3STR6r+4cGepyHkcWOQSw==
 
+detect-libc@^1.0.3:
+  version "1.0.3"
+  resolved "https://registry.yarnpkg.com/detect-libc/-/detect-libc-1.0.3.tgz#fa137c4bd698edf55cd5cd02ac559f91a4c4ba9b"
+  integrity sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==
+
 detect-node-es@^1.1.0:
   version "1.1.0"
   resolved "https://registry.yarnpkg.com/detect-node-es/-/detect-node-es-1.1.0.tgz#163acdf643330caa0b4cd7c21e7ee7755d6fa493"
@@ -2205,7 +2293,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.4, micromatch@^4.0.5:
   version "4.0.8"
   resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.8.tgz#d66fa18f3a47076789320b9b1af32bd86d9fa202"
   integrity sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==
@@ -2247,6 +2335,11 @@ natural-compare@^1.4.0:
   resolved "https://registry.yarnpkg.com/natural-compare/-/natural-compare-1.4.0.tgz#4abebfeed7541f2c27acfb29bdbbd15c8d5ba4f7"
   integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==
 
+node-addon-api@^7.0.0:
+  version "7.1.1"
+  resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-7.1.1.tgz#1aba6693b0f255258a049d621329329322aad558"
+  integrity sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==
+
 normalize-path@^3.0.0, normalize-path@~3.0.0:
   version "3.0.0"
   resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65"
@@ -2417,10 +2510,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.1.0:
-  version "9.1.0"
-  resolved "https://registry.yarnpkg.com/query-string/-/query-string-9.1.0.tgz#5f12a4653a4ba56021e113b5cf58e56581823e7a"
-  integrity sha512-t6dqMECpCkqfyv2FfwVS1xcB6lgXW/0XZSaKdsCNGYkqMO76AFiJEg4vINzoDKcZa6MS7JX+OHIjwh06K5vczw==
+query-string@9.1.1:
+  version "9.1.1"
+  resolved "https://registry.yarnpkg.com/query-string/-/query-string-9.1.1.tgz#dbfebb4196aeb2919915f2b2b81b91b965cf03a0"
+  integrity sha512-MWkCOVIcJP9QSKU52Ngow6bsAWAPlPK2MludXvcrS2bGZSl+T1qX9MZvRIkqUIkGLJquMJHWfsT6eRqUpp4aWg==
   dependencies:
     decode-uri-component "^0.4.1"
     filter-obj "^5.1.0"
@@ -2563,11 +2656,12 @@ safe-regex-test@^1.0.3:
     es-errors "^1.3.0"
     is-regex "^1.1.4"
 
-sass@1.79.3:
-  version "1.79.3"
-  resolved "https://registry.yarnpkg.com/sass/-/sass-1.79.3.tgz#7811b000eb68195fe51dea89177e73e7ef7f546f"
-  integrity sha512-m7dZxh0W9EZ3cw50Me5GOuYm/tVAJAn91SUnohLRo9cXBixGUOdvmryN+dXpwR831bhoY3Zv7rEFt85PUwTmzA==
+sass@1.80.4:
+  version "1.80.4"
+  resolved "https://registry.yarnpkg.com/sass/-/sass-1.80.4.tgz#bc0418fd796cad2f1a1309d8b4d7fe44b7027de0"
+  integrity sha512-rhMQ2tSF5CsuuspvC94nPM9rToiAFw2h3JTrLlgmNw1MH79v8Cr3DH6KF6o6r+8oofY3iYVPUf66KzC8yuVN1w==
   dependencies:
+    "@parcel/watcher" "^2.4.1"
     chokidar "^4.0.0"
     immutable "^4.0.0"
     source-map-js ">=0.6.2 <2.0.0"
diff --git a/netbox/release.yaml b/netbox/release.yaml
index 3963fd542..63854ac33 100644
--- a/netbox/release.yaml
+++ b/netbox/release.yaml
@@ -1,3 +1,3 @@
-version: "4.1.3"
+version: "4.1.5"
 edition: "Community"
-published: "2024-10-02"
+published: "2024-10-28"
diff --git a/netbox/templates/core/plugin.html b/netbox/templates/core/plugin.html
index 34eaf81ee..b833db037 100644
--- a/netbox/templates/core/plugin.html
+++ b/netbox/templates/core/plugin.html
@@ -2,6 +2,7 @@
 {% load helpers %}
 {% load form_helpers %}
 {% load i18n %}
+{% load render_table from django_tables2 %}
 
 {% block title %}{{ plugin.title_long }}{% endblock %}
 
@@ -93,8 +94,8 @@
       

{% trans "Version History" %}

-
- {% include 'htmx/table.html' %} +
+ {% render_table table 'inc/table.html' %}
diff --git a/netbox/templates/dcim/component_list.html b/netbox/templates/dcim/component_list.html index 6351643e8..6f91aff3e 100644 --- a/netbox/templates/dcim/component_list.html +++ b/netbox/templates/dcim/component_list.html @@ -10,7 +10,7 @@ {% endif %} {% if 'bulk_rename' in actions %} {% with bulk_rename_view=model|validated_viewname:"bulk_rename" %} - {% endwith %} diff --git a/netbox/templates/dcim/device_list.html b/netbox/templates/dcim/device_list.html index 41ef8fc73..493b652f5 100644 --- a/netbox/templates/dcim/device_list.html +++ b/netbox/templates/dcim/device_list.html @@ -78,7 +78,7 @@ {% if 'bulk_edit' in actions %}
{% bulk_edit_button model query_params=request.GET %} -
diff --git a/netbox/templates/dcim/devicetype/component_templates.html b/netbox/templates/dcim/devicetype/component_templates.html index df9789c00..291c7c988 100644 --- a/netbox/templates/dcim/devicetype/component_templates.html +++ b/netbox/templates/dcim/devicetype/component_templates.html @@ -18,21 +18,8 @@ {% endif %} {% endwith %} {% endblock bulk_edit_controls %} - -{% block bulk_extra_controls %} - {{ block.super }} - {% if request.user|can_add:child_model %} - - {% endif %} -{% endblock bulk_extra_controls %} - diff --git a/netbox/templates/dcim/inc/devicetype_breadcrumbs.html b/netbox/templates/dcim/inc/devicetype_breadcrumbs.html new file mode 100644 index 000000000..02f326ddc --- /dev/null +++ b/netbox/templates/dcim/inc/devicetype_breadcrumbs.html @@ -0,0 +1,2 @@ + + diff --git a/netbox/templates/dcim/inc/moduletype_buttons.html b/netbox/templates/dcim/inc/moduletype_buttons.html new file mode 100644 index 000000000..9ec34f412 --- /dev/null +++ b/netbox/templates/dcim/inc/moduletype_buttons.html @@ -0,0 +1,38 @@ +{% load buttons %} +{% load helpers %} +{% load i18n %} + + +{% if perms.dcim.change_devicetype %} + +{% endif %} diff --git a/netbox/templates/dcim/moduletype.html b/netbox/templates/dcim/moduletype.html index 110db8e20..f57c501cf 100644 --- a/netbox/templates/dcim/moduletype.html +++ b/netbox/templates/dcim/moduletype.html @@ -1,9 +1,20 @@ -{% extends 'dcim/moduletype/base.html' %} +{% extends 'generic/object.html' %} {% load buttons %} {% load helpers %} {% load plugins %} {% load i18n %} +{% block title %}{{ object.manufacturer }} {{ object.model }}{% endblock %} + +{% block breadcrumbs %} + {{ block.super }} + {% include 'dcim/inc/devicetype_breadcrumbs.html' %} +{% endblock %} + +{% block extra_controls %} + {% include 'dcim/inc/moduletype_buttons.html' %} +{% endblock %} + {% block content %}
diff --git a/netbox/templates/dcim/moduletype/base.html b/netbox/templates/dcim/moduletype/base.html deleted file mode 100644 index b7d9026d5..000000000 --- a/netbox/templates/dcim/moduletype/base.html +++ /dev/null @@ -1,48 +0,0 @@ -{% extends 'generic/object.html' %} -{% load buttons %} -{% load helpers %} -{% load plugins %} -{% load i18n %} - -{% block title %}{{ object.manufacturer }} {{ object.model }}{% endblock %} - -{% block breadcrumbs %} - {{ block.super }} - -{% endblock %} - -{% block extra_controls %} - {% if perms.dcim.change_devicetype %} - - {% endif %} -{% endblock %} diff --git a/netbox/templates/dcim/moduletype/component_templates.html b/netbox/templates/dcim/moduletype/component_templates.html index 9c4d59cba..8f4c24478 100644 --- a/netbox/templates/dcim/moduletype/component_templates.html +++ b/netbox/templates/dcim/moduletype/component_templates.html @@ -1,44 +1,37 @@ -{% extends 'dcim/moduletype/base.html' %} +{% extends 'generic/object_children.html' %} {% load render_table from django_tables2 %} {% load helpers %} {% load i18n %} -{% block content %} - {% if perms.dcim.change_moduletype %} -
- {% csrf_token %} -
-
- {% include 'htmx/table.html' %} -
- -
-
- {% else %} -
-

{{ title }}

-
- {% include 'htmx/table.html' %} -
-
- {% endif %} -{% endblock content %} +{% block title %}{{ object.manufacturer }} {{ object.model }}{% endblock %} + +{% block breadcrumbs %} + {{ block.super }} + {% include 'dcim/inc/devicetype_breadcrumbs.html' %} +{% endblock %} + +{% block extra_controls %} + {% include 'dcim/inc/moduletype_buttons.html' %} +{% endblock %} + +{% block bulk_edit_controls %} + {% with bulk_edit_view=child_model|validated_viewname:"bulk_edit" %} + {% if 'bulk_edit' in actions and bulk_edit_view %} + + {% endif %} + {% endwith %} + {% with bulk_rename_view=child_model|validated_viewname:"bulk_rename" %} + {% if 'bulk_rename' in actions and bulk_rename_view %} + + {% endif %} + {% endwith %} +{% endblock bulk_edit_controls %} + diff --git a/netbox/templates/extras/script_list.html b/netbox/templates/extras/script_list.html index 5b7361a12..cbffbf8de 100644 --- a/netbox/templates/extras/script_list.html +++ b/netbox/templates/extras/script_list.html @@ -37,101 +37,104 @@ {% endif %}
- {% if module.scripts %} - - - - - - - - - - - - {% for script in module.scripts.all %} - {% with last_job=script.get_latest_jobs|first %} - - +
{% trans "Name" %}{% trans "Description" %}{% trans "Last Run" %}{% trans "Status" %}
- {% if script.is_executable %} - {{ script.python_class.name }} + {% with scripts=module.scripts.all %} + {% if scripts %} + + + + + + + + + + + + {% for script in scripts %} + {% with last_job=script.get_latest_jobs|first %} + + + + {% if last_job %} + + {% else %} - {{ script.python_class.name }} - - - + + {% endif %} - - + + {% if last_job %} - - - {% else %} - - + {% for test_name, data in last_job.data.tests.items %} + + + + + {% endfor %} + {% elif not last_job.data.log %} + {# legacy #} + {% for method, stats in last_job.data.items %} + + + + + {% endfor %} {% endif %} - - - {% if last_job %} - {% for test_name, data in last_job.data.tests.items %} - - - - - {% endfor %} - {% elif not last_job.data.log %} - {# legacy #} - {% for method, stats in last_job.data.items %} - - - - - {% endfor %} - {% endif %} - {% endwith %} - {% endfor %} - -
{% trans "Name" %}{% trans "Description" %}{% trans "Last Run" %}{% trans "Status" %}
+ {% if script.is_executable %} + {{ script.python_class.name }} + {% else %} + {{ script.python_class.name }} + + + + {% endif %} + {{ script.python_class.Meta.description|markdown|placeholder }} + {{ last_job.created|isodatetime }} + + {% badge last_job.get_status_display last_job.get_status_color %} + {% trans "Never" %}{{ ''|placeholder }}{{ script.python_class.Meta.description|markdown|placeholder }} + {% if request.user|can_run:script and script.is_executable %} +
+
+ {% csrf_token %} + +
+
+ {% endif %} +
- {{ last_job.created|isodatetime }} - - {% badge last_job.get_status_display last_job.get_status_color %} - {% trans "Never" %}{{ ''|placeholder }}
+ {{ test_name }} + + {{ data.success }} + {{ data.info }} + {{ data.warning }} + {{ data.failure }} +
+ {{ method }} + + {{ stats.success }} + {{ stats.info }} + {{ stats.warning }} + {{ stats.failure }} +
- {% if request.user|can_run:script and script.is_executable %} -
-
- {% csrf_token %} - -
-
- {% endif %} -
- {{ test_name }} - - {{ data.success }} - {{ data.info }} - {{ data.warning }} - {{ data.failure }} -
- {{ method }} - - {{ stats.success }} - {{ stats.info }} - {{ stats.warning }} - {{ stats.failure }} -
- {% else %} -
-
+ {% else %} +
+
-
- {% endif %} + {% endif %} + {% endwith %}
{% empty %}