diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index b4240171b..be2aacff5 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.5-beta2 + placeholder: v3.5.1 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index cedee1f44..fcb3516b4 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -14,7 +14,7 @@ body: attributes: label: NetBox version description: What version of NetBox are you currently running? - placeholder: v3.5-beta2 + placeholder: v3.5.1 validations: required: true - type: dropdown diff --git a/base_requirements.txt b/base_requirements.txt index c1e2fed7f..1e9a45048 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -1,6 +1,6 @@ # HTML sanitizer # https://github.com/mozilla/bleach/blob/main/CHANGES -bleach<6.0 +bleach # Python client for Amazon AWS API # https://github.com/boto/boto3/blob/develop/CHANGELOG.rst @@ -137,8 +137,7 @@ social-auth-core # Django app for social-auth-core # https://github.com/python-social-auth/social-app-django/blob/master/CHANGELOG.md -# See https://github.com/python-social-auth/social-app-django/issues/429 -social-auth-app-django==5.0.0 +social-auth-app-django # SVG image rendering (used for rack elevations) # hhttps://github.com/mozman/svgwrite/blob/master/NEWS.rst diff --git a/contrib/netbox-housekeeping.service b/contrib/netbox-housekeeping.service new file mode 100644 index 000000000..4b0361fcb --- /dev/null +++ b/contrib/netbox-housekeeping.service @@ -0,0 +1,17 @@ +[Unit] +Description=NetBox Housekeeping Service +Documentation=https://docs.netbox.dev/ +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple + +User=netbox +Group=netbox +WorkingDirectory=/opt/netbox + +ExecStart=/opt/netbox/venv/bin/python /opt/netbox/netbox/manage.py housekeeping + +[Install] +WantedBy=multi-user.target diff --git a/contrib/netbox-housekeeping.timer b/contrib/netbox-housekeeping.timer new file mode 100644 index 000000000..16facb05c --- /dev/null +++ b/contrib/netbox-housekeeping.timer @@ -0,0 +1,13 @@ +[Unit] +Description=NetBox Housekeeping Timer +Documentation=https://docs.netbox.dev/ +After=network-online.target +Wants=network-online.target + +[Timer] +OnCalendar=daily +AccuracySec=1h +Persistent=true + +[Install] +WantedBy=multi-user.target diff --git a/docs/administration/housekeeping.md b/docs/administration/housekeeping.md index 212b8308d..674ceb312 100644 --- a/docs/administration/housekeeping.md +++ b/docs/administration/housekeeping.md @@ -7,7 +7,13 @@ NetBox includes a `housekeeping` management command that should be run nightly. * Deleting job result records older than the configured [retention time](../configuration/miscellaneous.md#job_retention) * Check for new NetBox releases (if [`RELEASE_CHECK_URL`](../configuration/miscellaneous.md#release_check_url) is set) -This command can be invoked directly, or by using the shell script provided at `/opt/netbox/contrib/netbox-housekeeping.sh`. This script can be linked from your cron scheduler's daily jobs directory (e.g. `/etc/cron.daily`) or referenced directly within the cron configuration file. +This command can be invoked directly, or by using the shell script provided at `/opt/netbox/contrib/netbox-housekeeping.sh`. + +## Scheduling + +### Using Cron + +This script can be linked from your cron scheduler's daily jobs directory (e.g. `/etc/cron.daily`) or referenced directly within the cron configuration file. ```shell sudo ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping @@ -16,4 +22,28 @@ sudo ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-hou !!! note On Debian-based systems, be sure to omit the `.sh` file extension when linking to the script from within a cron directory. Otherwise, the task may not run. -The `housekeeping` command can also be run manually at any time: Running the command outside scheduled execution times will not interfere with its operation. +### Using Systemd + +First, create symbolic links for the systemd service and timer files. Link the existing service and timer files from the `/opt/netbox/contrib/` directory to the `/etc/systemd/system/` directory: + +```bash +sudo ln -s /opt/netbox/contrib/netbox-housekeeping.service /etc/systemd/system/netbox-housekeeping.service +sudo ln -s /opt/netbox/contrib/netbox-housekeeping.timer /etc/systemd/system/netbox-housekeeping.timer +``` + +Then, reload the systemd configuration and enable the timer to start automatically at boot: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now netbox-housekeeping.timer +``` + +Check the status of your timer by running: + +```bash +sudo systemctl list-timers --all +``` + +This command will show a list of all timers, including your `netbox-housekeeping.timer`. Make sure the timer is active and properly scheduled. + +That's it! Your NetBox housekeeping service is now configured to run daily using systemd. diff --git a/docs/configuration/miscellaneous.md b/docs/configuration/miscellaneous.md index 14f0b9151..c3fbb40aa 100644 --- a/docs/configuration/miscellaneous.md +++ b/docs/configuration/miscellaneous.md @@ -129,7 +129,7 @@ Setting this to True will display a "maintenance mode" banner at the top of ever Default: `https://maps.google.com/?q=` (Google Maps) -This specifies the URL to use when presenting a map of a physical location by street address or GPS coordinates. The URL must accept either a free-form street address or a comma-separated pair of numeric coordinates appended to it. +This specifies the URL to use when presenting a map of a physical location by street address or GPS coordinates. The URL must accept either a free-form street address or a comma-separated pair of numeric coordinates appended to it. Set this to `None` to disable the "map it" button within the UI. --- diff --git a/docs/configuration/required-parameters.md b/docs/configuration/required-parameters.md index a71a1b410..1eba265bf 100644 --- a/docs/configuration/required-parameters.md +++ b/docs/configuration/required-parameters.md @@ -33,11 +33,13 @@ NetBox requires access to a PostgreSQL 11 or later database service to store dat * `HOST` - Name or IP address of the database server (use `localhost` if running locally) * `PORT` - TCP port of the PostgreSQL service; leave blank for default port (TCP/5432) * `CONN_MAX_AGE` - Lifetime of a [persistent database connection](https://docs.djangoproject.com/en/stable/ref/databases/#persistent-connections), in seconds (300 is the default) +* `ENGINE` - The database backend to use; must be a PostgreSQL-compatible backend (e.g. `django.db.backends.postgresql`) Example: ```python DATABASE = { + 'ENGINE': 'django.db.backends.postgresql', 'NAME': 'netbox', # Database name 'USER': 'netbox', # PostgreSQL username 'PASSWORD': 'J5brHrAXFLQSif0K', # PostgreSQL password @@ -50,6 +52,9 @@ DATABASE = { !!! note NetBox supports all PostgreSQL database options supported by the underlying Django framework. For a complete list of available parameters, please see [the Django documentation](https://docs.djangoproject.com/en/stable/ref/settings/#databases). +!!! warning + Make sure to use a PostgreSQL-compatible backend for the ENGINE setting. If you don't specify an ENGINE, the default will be django.db.backends.postgresql. + --- ## REDIS @@ -144,8 +149,6 @@ REDIS = { ## SECRET_KEY -This is a secret, random string used to assist in the creation new cryptographic hashes for passwords and HTTP cookies. The key defined here should not be shared outside of the configuration file. `SECRET_KEY` can be changed at any time, however be aware that doing so will invalidate all existing sessions. +This is a secret, pseudorandom string used to assist in the creation new cryptographic hashes for passwords and HTTP cookies. The key defined here should not be shared outside the configuration file. `SECRET_KEY` can be changed at any time without impacting stored data, however be aware that doing so will invalidate all existing user sessions. NetBox deployments comprising multiple nodes must have the same secret key configured on all nodes. -Please note that this key is **not** used directly for hashing user passwords or for the encrypted storage of secret data in NetBox. - -`SECRET_KEY` should be at least 50 characters in length and contain a random mix of letters, digits, and symbols. The script located at `$INSTALL_ROOT/netbox/generate_secret_key.py` may be used to generate a suitable key. +`SECRET_KEY` **must** be at least 50 characters in length, and should contain a mix of letters, digits, and symbols. The script located at `$INSTALL_ROOT/netbox/generate_secret_key.py` may be used to generate a suitable key. Please note that this key is **not** used directly for hashing user passwords or for the encrypted storage of secret data in NetBox. diff --git a/docs/customization/custom-links.md b/docs/customization/custom-links.md index 5d1cd4556..baae1db4f 100644 --- a/docs/customization/custom-links.md +++ b/docs/customization/custom-links.md @@ -2,12 +2,12 @@ Custom links allow users to display arbitrary hyperlinks to external content within NetBox object views. These are helpful for cross-referencing related records in systems outside NetBox. For example, you might create a custom link on the device view which links to the current device in a Network Monitoring System (NMS). -Custom links are created by navigating to Customization > Custom Links. Each link is associated with a particular NetBox object type (site, device, prefix, etc.) and will be displayed on relevant views. Each link has display text and a URL, and data from the NetBox item being viewed can be included in the link using [Jinja2 template code](https://jinja2docs.readthedocs.io/en/stable/) through the variable `obj`, and custom fields through `obj.cf`. +Custom links are created by navigating to Customization > Custom Links. Each link is associated with a particular NetBox object type (site, device, prefix, etc.) and will be displayed on relevant views. Each link has display text and a URL, and data from the NetBox item being viewed can be included in the link using [Jinja2 template code](https://jinja2docs.readthedocs.io/en/stable/) through the variable `object`, and custom fields through `object.cf`. For example, you might define a link like this: * Text: `View NMS` -* URL: `https://nms.example.com/nodes/?name={{ obj.name }}` +* URL: `https://nms.example.com/nodes/?name={{ object.name }}` When viewing a device named Router4, this link would render as: @@ -43,7 +43,7 @@ Only links which render with non-empty text are included on the page. You can em For example, if you only want to display a link for active devices, you could set the link text to ```jinja2 -{% if obj.status == 'active' %}View NMS{% endif %} +{% if object.status == 'active' %}View NMS{% endif %} ``` The link will not appear when viewing a device with any status other than "active." @@ -51,7 +51,7 @@ The link will not appear when viewing a device with any status other than "activ As another example, if you wanted to show only devices belonging to a certain manufacturer, you could do something like this: ```jinja2 -{% if obj.device_type.manufacturer.name == 'Cisco' %}View NMS{% endif %} +{% if object.device_type.manufacturer.name == 'Cisco' %}View NMS{% endif %} ``` The link will only appear when viewing a device with a manufacturer name of "Cisco." diff --git a/docs/development/models.md b/docs/development/models.md index 6db61531b..d4838570a 100644 --- a/docs/development/models.md +++ b/docs/development/models.md @@ -32,7 +32,7 @@ These are considered the "core" application models which are used to model netwo * [circuits.Circuit](../models/circuits/circuit.md) * [circuits.Provider](../models/circuits/provider.md) -* [circuits.ProviderAccount](../models/circuits/provideracount.md) +* [circuits.ProviderAccount](../models/circuits/provideraccount.md) * [circuits.ProviderNetwork](../models/circuits/providernetwork.md) * [core.DataSource](../models/core/datasource.md) * [dcim.Cable](../models/dcim/cable.md) diff --git a/docs/models/dcim/platform.md b/docs/models/dcim/platform.md index 875f4a88a..dc332da74 100644 --- a/docs/models/dcim/platform.md +++ b/docs/models/dcim/platform.md @@ -4,8 +4,6 @@ A platform defines the type of software running on a [device](./device.md) or [v Platforms may optionally be limited by [manufacturer](./manufacturer.md): If a platform is assigned to a particular manufacturer, it can only be assigned to devices with a type belonging to that manufacturer. -The platform model is also used to indicate which [NAPALM driver](../../integrations/napalm.md) (if any) and any associated arguments NetBox should use when connecting to a remote device. The name of the driver along with optional parameters are stored with the platform. - The assignment of platforms to devices is an optional feature, and may be disregarded if not desired. ## Fields diff --git a/docs/release-notes/index.md b/docs/release-notes/index.md index 6262ef92c..4d812938f 100644 --- a/docs/release-notes/index.md +++ b/docs/release-notes/index.md @@ -10,6 +10,16 @@ Minor releases are published in April, August, and December of each calendar yea This page contains a history of all major and minor releases since NetBox v2.0. For more detail on a specific patch release, please see the release notes page for that specific minor release. +#### [Version 3.5](./version-3.5.md) (April 2023) + +* Customizable Dashboard ([#9416](https://github.com/netbox-community/netbox/issues/9416)) +* Remote Data Sources ([#11558](https://github.com/netbox-community/netbox/issues/11558)) +* Configuration Template Rendering ([#11559](https://github.com/netbox-community/netbox/issues/11559)) +* NAPALM Integration Plugin ([#10520](https://github.com/netbox-community/netbox/issues/10520)) +* ASN Ranges ([#8550](https://github.com/netbox-community/netbox/issues/8550)) +* Provider Accounts ([#9047](https://github.com/netbox-community/netbox/issues/9047)) +* Job-Triggered Webhooks ([#8958](https://github.com/netbox-community/netbox/issues/8958)) + #### [Version 3.4](./version-3.4.md) (December 2022) * New Global Search ([#10560](https://github.com/netbox-community/netbox/issues/10560)) diff --git a/docs/release-notes/version-3.4.md b/docs/release-notes/version-3.4.md index bcb3a9ad2..22c33bb01 100644 --- a/docs/release-notes/version-3.4.md +++ b/docs/release-notes/version-3.4.md @@ -1,5 +1,15 @@ # NetBox v3.4 +## v3.4.10 (2023-04-27) + +### Bug Fixes + +* [#11607](https://github.com/netbox-community/netbox/issues/11607) - Fix custom object field assignments made via REST API for for cables +* [#12252](https://github.com/netbox-community/netbox/issues/12252) - Fix ordering of search results when sorting by object name +* [#12355](https://github.com/netbox-community/netbox/issues/12355) - Fix escaping of certain characters in URL when rendering custom links + +--- + ## v3.4.9 (2023-04-26) ### Enhancements diff --git a/docs/release-notes/version-3.5.md b/docs/release-notes/version-3.5.md index 122ecb18b..8bdfb1578 100644 --- a/docs/release-notes/version-3.5.md +++ b/docs/release-notes/version-3.5.md @@ -1,10 +1,62 @@ # NetBox v3.5 -## v3.5-beta2 (2023-04-18) +## v3.5.2 (FUTURE) + +### Enhancements + +* [#12223](https://github.com/netbox-community/netbox/issues/12223) - Add columns for parent device bay and position to devices list +* [#12498](https://github.com/netbox-community/netbox/issues/12498) - Hide map button if `MAPS_URL` is empty + +--- + +## v3.5.1 (2023-05-05) + +### Enhancements + +* [#10759](https://github.com/netbox-community/netbox/issues/10759) - Support Markdown rendering for custom field descriptions +* [#11190](https://github.com/netbox-community/netbox/issues/11190) - Including systemd service & timer configurations for housekeeping tasks +* [#11422](https://github.com/netbox-community/netbox/issues/11422) - Match on power panel name when searching for power feeds +* [#11504](https://github.com/netbox-community/netbox/issues/11504) - Add filter to select individual racks under rack elevations view +* [#11652](https://github.com/netbox-community/netbox/issues/11652) - Add a module status column to module bay tables +* [#11791](https://github.com/netbox-community/netbox/issues/11791) - Enable configuration of custom database backend via `ENGINE` parameter +* [#11801](https://github.com/netbox-community/netbox/issues/11801) - Include device description within rack elevation tooltip +* [#11932](https://github.com/netbox-community/netbox/issues/11932) - Introduce a list view for image attachments, orderable by date and other attributes +* [#12122](https://github.com/netbox-community/netbox/issues/12122) - Enable bulk import oj journal entries +* [#12245](https://github.com/netbox-community/netbox/issues/12245) - Enable the assignment of wireless LANs to interfaces under bulk edit + +### Bug Fixes + +* [#10757](https://github.com/netbox-community/netbox/issues/10757) - Simplify IP address interface and NAT IP assignment form fields to avoid confusion +* [#11715](https://github.com/netbox-community/netbox/issues/11715) - Prefix within a VRF should list global prefixes as parents only if they are containers +* [#12363](https://github.com/netbox-community/netbox/issues/12363) - Fix whitespace for paragraph elements in Markdown-rendered table columns +* [#12367](https://github.com/netbox-community/netbox/issues/12367) - Fix `RelatedObjectDoesNotExist` exception under certain conditions (regression from #11550) +* [#12380](https://github.com/netbox-community/netbox/issues/12380) - Allow selecting object change as model under object list widget configuration +* [#12384](https://github.com/netbox-community/netbox/issues/12384) - Add a three-second timeout for RSS reader widget +* [#12395](https://github.com/netbox-community/netbox/issues/12395) - Fix "create & add another" action for objects with custom fields +* [#12396](https://github.com/netbox-community/netbox/issues/12396) - Provider account should not be a required field in REST API serializer +* [#12400](https://github.com/netbox-community/netbox/issues/12400) - Validate default values for object and multi-object custom fields +* [#12401](https://github.com/netbox-community/netbox/issues/12401) - Support the creation of front ports without a pre-populated device ID +* [#12405](https://github.com/netbox-community/netbox/issues/12405) - Fix filtering for VLAN groups displayed under site view +* [#12410](https://github.com/netbox-community/netbox/issues/12410) - Fix base path for OpenAPI schema (fixes Swagger UI requests) +* [#12416](https://github.com/netbox-community/netbox/issues/12416) - Fix `FileNotFoundError` exception when a managed script file is missing from disk +* [#12412](https://github.com/netbox-community/netbox/issues/12412) - Device/VM interface MAC addresses can be nullified via REST API +* [#12415](https://github.com/netbox-community/netbox/issues/12415) - Fix `ImportError` exception when running RQ worker +* [#12433](https://github.com/netbox-community/netbox/issues/12433) - Correct the application of URL query parameters for object list dashboard widgets +* [#12436](https://github.com/netbox-community/netbox/issues/12436) - Remove extraneous "add" button from contact assignments list +* [#12463](https://github.com/netbox-community/netbox/issues/12463) - Fix the association of completed jobs with reports & scripts in the REST API +* [#12464](https://github.com/netbox-community/netbox/issues/12464) - Apply credentials for git data source only when connecting via HTTP/S +* [#12476](https://github.com/netbox-community/netbox/issues/12476) - Fix `TypeError` exception when running the `runscript` management command +* [#12483](https://github.com/netbox-community/netbox/issues/12483) - Fix git remote data syncing when with HTTP proxies defined +* [#12496](https://github.com/netbox-community/netbox/issues/12496) - Remove obsolete account field from provider UI view + +--- + +## v3.5.0 (2023-04-27) ### Breaking Changes * The `account` field has been removed from the provider model. This information is now tracked using the new provider account model. Multiple accounts can be assigned per provider. +* A minimum length of 50 characters is now enforced for the `SECRET_KEY` configuration parameter. * The JobResult model has been moved from the `extras` app to `core` and renamed to Job. Accordingly, its REST API endpoint has been moved from `/api/extras/job-results/` to `/api/core/jobs/`. * The `obj_type` field on the Job model (previously JobResult) has been renamed to `object_type` for consistency with other models. * The `JOBRESULT_RETENTION` configuration parameter has been renamed to `JOB_RETENTION`. @@ -28,7 +80,7 @@ NetBox now has the ability to synchronize arbitrary data from external sources t This release introduces the ability to render device configurations from Jinja2 templates natively within NetBox, via both the UI and REST API. The new [ConfigTemplate](../models/extras/configtemplate.md) model stores template code (which may be defined locally or sourced from remote data files). The rendering engine passes data gleaned from both config contexts and request parameters to generate complete configurations suitable for direct application to network devices. -#### NAPALM Plugin ([#10520](https://github.com/netbox-community/netbox/issues/10520)) +#### NAPALM Integration Plugin ([#10520](https://github.com/netbox-community/netbox/issues/10520)) The NAPALM integration feature found in previous NetBox releases has been moved from the core application to a [dedicated plugin](https://github.com/netbox-community/netbox-napalm). This allows greater control over the feature's configuration and will unlock additional potential as a separate project. @@ -72,6 +124,7 @@ Two new webhook trigger events have been introduced: `job_start` and `job_end`. * [#12068](https://github.com/netbox-community/netbox/issues/12068) - Enable generic foreign key relationships from jobs to NetBox objects * [#12085](https://github.com/netbox-community/netbox/issues/12085) - Add a file source view for reports * [#12218](https://github.com/netbox-community/netbox/issues/12218) - Provide more relevant API endpoint descriptions in schema +* [#12343](https://github.com/netbox-community/netbox/issues/12343) - Enforce a minimum length for `SECRET_KEY` configuration parameter ### Bug Fixes (From Beta2) diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index 5635b6730..f4abda645 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -106,7 +106,7 @@ class CircuitCircuitTerminationSerializer(WritableNestedSerializer): class CircuitSerializer(NetBoxModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuit-detail') provider = NestedProviderSerializer() - provider_account = NestedProviderAccountSerializer() + provider_account = NestedProviderAccountSerializer(required=False, allow_null=True) status = ChoiceField(choices=CircuitStatusChoices, required=False) type = NestedCircuitTypeSerializer() tenant = NestedTenantSerializer(required=False, allow_null=True) diff --git a/netbox/circuits/forms/bulk_import.py b/netbox/circuits/forms/bulk_import.py index d55831008..3941ef574 100644 --- a/netbox/circuits/forms/bulk_import.py +++ b/netbox/circuits/forms/bulk_import.py @@ -74,7 +74,8 @@ class CircuitImportForm(NetBoxModelImportForm): provider_account = CSVModelChoiceField( queryset=ProviderAccount.objects.all(), to_field_name='name', - help_text=_('Assigned provider account') + help_text=_('Assigned provider account'), + required=False ) type = CSVModelChoiceField( queryset=CircuitType.objects.all(), diff --git a/netbox/core/api/schema.py b/netbox/core/api/schema.py index d06d1d3bf..9550df3ea 100644 --- a/netbox/core/api/schema.py +++ b/netbox/core/api/schema.py @@ -1,23 +1,12 @@ import re import typing -from drf_spectacular.extensions import ( - OpenApiSerializerFieldExtension, - OpenApiViewExtension, -) +from drf_spectacular.extensions import OpenApiSerializerFieldExtension from drf_spectacular.openapi import AutoSchema from drf_spectacular.plumbing import ( - ComponentRegistry, - ResolvedComponent, - build_basic_type, - build_choice_field, - build_media_type_object, - build_object_type, - get_doc, - is_serializer, + build_basic_type, build_choice_field, build_media_type_object, build_object_type, get_doc, ) from drf_spectacular.types import OpenApiTypes -from drf_spectacular.utils import extend_schema from rest_framework.relations import ManyRelatedField from netbox.api.fields import ChoiceField, SerializedPKRelatedField diff --git a/netbox/core/data_backends.py b/netbox/core/data_backends.py index d8424c223..6cc534774 100644 --- a/netbox/core/data_backends.py +++ b/netbox/core/data_backends.py @@ -12,7 +12,7 @@ from django import forms from django.conf import settings from django.utils.translation import gettext as _ from dulwich import porcelain -from dulwich.config import StackedConfig +from dulwich.config import ConfigDict from netbox.registry import registry from .choices import DataSourceTypeChoices @@ -31,6 +31,7 @@ def register_backend(name): """ Decorator for registering a DataBackend class. """ + def _wrapper(cls): registry['data_backends'][name] = cls return cls @@ -56,7 +57,6 @@ class DataBackend: @register_backend(DataSourceTypeChoices.LOCAL) class LocalBackend(DataBackend): - @contextmanager def fetch(self): logger.debug(f"Data source type is local; skipping fetch") @@ -71,12 +71,14 @@ class GitBackend(DataBackend): 'username': forms.CharField( required=False, label=_('Username'), - widget=forms.TextInput(attrs={'class': 'form-control'}) + widget=forms.TextInput(attrs={'class': 'form-control'}), + help_text=_("Only used for cloning with HTTP / HTTPS"), ), 'password': forms.CharField( required=False, label=_('Password'), - widget=forms.TextInput(attrs={'class': 'form-control'}) + widget=forms.TextInput(attrs={'class': 'form-control'}), + help_text=_("Only used for cloning with HTTP / HTTPS"), ), 'branch': forms.CharField( required=False, @@ -89,10 +91,22 @@ class GitBackend(DataBackend): def fetch(self): local_path = tempfile.TemporaryDirectory() - username = self.params.get('username') - password = self.params.get('password') - branch = self.params.get('branch') - config = StackedConfig.default() + config = ConfigDict() + clone_args = { + "branch": self.params.get('branch'), + "config": config, + "depth": 1, + "errstream": porcelain.NoneStream(), + "quiet": True, + } + + if self.url_scheme in ('http', 'https'): + clone_args.update( + { + "username": self.params.get('username'), + "password": self.params.get('password'), + } + ) if settings.HTTP_PROXIES and self.url_scheme in ('http', 'https'): if proxy := settings.HTTP_PROXIES.get(self.url_scheme): @@ -100,10 +114,7 @@ class GitBackend(DataBackend): logger.debug(f"Cloning git repo: {self.url}") try: - porcelain.clone( - self.url, local_path.name, depth=1, branch=branch, username=username, password=password, - config=config, quiet=True, errstream=porcelain.NoneStream() - ) + porcelain.clone(self.url, local_path.name, **clone_args) except BaseException as e: raise SyncError(f"Fetching remote data failed ({type(e).__name__}): {e}") diff --git a/netbox/dcim/api/nested_serializers.py b/netbox/dcim/api/nested_serializers.py index 3770b2adc..c8440612d 100644 --- a/netbox/dcim/api/nested_serializers.py +++ b/netbox/dcim/api/nested_serializers.py @@ -456,7 +456,7 @@ class NestedInventoryItemRoleSerializer(WritableNestedSerializer): # Cables # -class NestedCableSerializer(BaseModelSerializer): +class NestedCableSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:cable-detail') class Meta: diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 8d620c408..3f6d55da7 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -904,7 +904,11 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect ) count_ipaddresses = serializers.IntegerField(read_only=True) count_fhrp_groups = serializers.IntegerField(read_only=True) - mac_address = serializers.CharField(required=False, default=None) + mac_address = serializers.CharField( + required=False, + default=None, + allow_null=True + ) wwn = serializers.CharField(required=False, default=None) class Meta: diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 207fb6d00..fccaa72f0 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -1900,6 +1900,7 @@ class PowerFeedFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet, PathEndpoi return queryset qs_filter = ( Q(name__icontains=value) | + Q(power_panel__name__icontains=value) | Q(comments__icontains=value) ) return queryset.filter(qs_filter) diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index d5abce647..6ed483c79 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -13,6 +13,7 @@ from tenancy.models import Tenant from utilities.forms import BulkEditForm, add_blank_choice, form_from_model from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField from utilities.forms.widgets import BulkEditNullBooleanSelect, NumberWithOptions +from wireless.models import WirelessLAN, WirelessLANGroup __all__ = ( 'CableBulkEditForm', @@ -1139,7 +1140,7 @@ class InterfaceBulkEditForm( form_from_model(Interface, [ 'label', 'type', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', - 'tx_power', + 'tx_power', 'wireless_lans' ]), ComponentBulkEditForm ): @@ -1229,6 +1230,19 @@ class InterfaceBulkEditForm( required=False, label=_('VRF') ) + wireless_lan_group = DynamicModelChoiceField( + queryset=WirelessLANGroup.objects.all(), + required=False, + label=_('Wireless LAN group') + ) + wireless_lans = DynamicModelMultipleChoiceField( + queryset=WirelessLAN.objects.all(), + required=False, + label=_('Wireless LANs'), + query_params={ + 'group_id': '$wireless_lan_group', + } + ) model = Interface fieldsets = ( @@ -1238,12 +1252,14 @@ class InterfaceBulkEditForm( ('PoE', ('poe_mode', 'poe_type')), ('Related Interfaces', ('parent', 'bridge', 'lag')), ('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')), - ('Wireless', ('rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width')), + ('Wireless', ( + 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'wireless_lan_group', 'wireless_lans', + )), ) nullable_fields = ( 'module', 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'vdcs', 'mtu', 'description', 'poe_mode', 'poe_type', 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', - 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'vrf', + 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'vrf', 'wireless_lans' ) def __init__(self, *args, **kwargs): diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index a00c7fe26..d31bba030 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -298,6 +298,15 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte class RackElevationFilterForm(RackFilterForm): + fieldsets = ( + (None, ('q', 'filter_id', 'tag')), + ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'id')), + ('Function', ('status', 'role_id')), + ('Hardware', ('type', 'width', 'serial', 'asset_tag')), + ('Tenant', ('tenant_group_id', 'tenant_id')), + ('Contacts', ('contact', 'contact_role', 'contact_group')), + ('Weight', ('weight', 'max_weight', 'weight_unit')), + ) id = DynamicModelMultipleChoiceField( queryset=Rack.objects.all(), label=_('Rack'), diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index d756036f4..219216045 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -1214,7 +1214,7 @@ class PopulateDeviceBayForm(BootstrapMixin, forms.Form): installed_device = forms.ModelChoiceField( queryset=Device.objects.all(), label=_('Child Device'), - help_text=_("Child devices must first be created and assigned to the site/rack of the parent device.") + help_text=_("Child devices must first be created and assigned to the site and rack of the parent device.") ) def __init__(self, device_bay, *args, **kwargs): diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index 3507faf3b..236077421 100644 --- a/netbox/dcim/forms/object_create.py +++ b/netbox/dcim/forms/object_create.py @@ -4,6 +4,7 @@ from django.utils.translation import gettext as _ from dcim.models import * from netbox.forms import NetBoxModelForm from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField +from utilities.forms.widgets import APISelect from . import model_forms __all__ = ( @@ -225,6 +226,18 @@ class InterfaceCreateForm(ComponentCreateForm, model_forms.InterfaceForm): class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm): + device = DynamicModelChoiceField( + queryset=Device.objects.all(), + selector=True, + widget=APISelect( + # TODO: Clean up the application of HTMXSelect attributes + attrs={ + 'hx-get': '.', + 'hx-include': f'#form_fields', + 'hx-target': f'#form_fields', + } + ) + ) rear_port = forms.MultipleChoiceField( choices=[], label=_('Rear ports'), @@ -244,9 +257,10 @@ class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - device = Device.objects.get( - pk=self.initial.get('device') or self.data.get('device') - ) + if device_id := self.data.get('device') or self.initial.get('device'): + device = Device.objects.get(pk=device_id) + else: + return # Determine which rear port positions are occupied. These will be excluded from the list of available # mappings. diff --git a/netbox/dcim/svg/racks.py b/netbox/dcim/svg/racks.py index 6c57e6023..62878cef9 100644 --- a/netbox/dcim/svg/racks.py +++ b/netbox/dcim/svg/racks.py @@ -37,15 +37,28 @@ def get_device_name(device): def get_device_description(device): - return '{} ({}) — {} {} ({}U) {} {}'.format( - device.name, - device.device_role, - device.device_type.manufacturer.name, - device.device_type.model, - floatformat(device.device_type.u_height), - device.asset_tag or '', - device.serial or '' - ) + """ + Return a description for a device to be rendered in the rack elevation in the following format + + Name: + Role: + Device Type: () + Asset tag: (if defined) + Serial: (if defined) + Description: (if defined) + """ + description = f'Name: {device.name}' + description += f'\nRole: {device.device_role}' + u_height = f'{floatformat(device.device_type.u_height)}U' + description += f'\nDevice Type: {device.device_type.manufacturer.name} {device.device_type.model} ({u_height})' + if device.asset_tag: + description += f'\nAsset tag: {device.asset_tag}' + if device.serial: + description += f'\nSerial: {device.serial}' + if device.description: + description += f'\nDescription: {device.description}' + + return description class RackElevationSVG: diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 8a39ee16c..db2655d27 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -39,6 +39,10 @@ __all__ = ( 'VirtualDeviceContextTable' ) +MODULEBAY_STATUS = """ +{% badge record.installed_module.get_status_display bg_color=record.installed_module.get_status_color %} +""" + def get_cabletermination_row_class(record): if record.mark_connected: @@ -212,6 +216,16 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): config_template = tables.Column( linkify=True ) + parent_device = tables.Column( + verbose_name='Parent Device', + linkify=True, + accessor='parent_bay__device' + ) + device_bay_position = tables.Column( + verbose_name='Position (Device Bay)', + accessor='parent_bay', + linkify=True + ) comments = columns.MarkdownColumn() tags = columns.TagColumn( url_name='dcim:device_list' @@ -221,9 +235,10 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): model = models.Device fields = ( 'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'device_role', 'manufacturer', 'device_type', - 'platform', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'location', 'rack', 'position', 'face', - 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', - 'vc_priority', 'description', 'config_template', 'comments', 'contacts', 'tags', 'created', 'last_updated', + 'platform', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'location', 'rack', 'parent_device', + 'device_bay_position', 'position', 'face', 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', + 'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'config_template', 'comments', 'contacts', + 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type', @@ -781,14 +796,17 @@ class ModuleBayTable(DeviceComponentTable): tags = columns.TagColumn( url_name='dcim:modulebay_list' ) + module_status = columns.TemplateColumn( + template_code=MODULEBAY_STATUS + ) class Meta(DeviceComponentTable.Meta): model = models.ModuleBay fields = ( - 'pk', 'id', 'name', 'device', 'label', 'position', 'installed_module', 'module_serial', 'module_asset_tag', - 'description', 'tags', + 'pk', 'id', 'name', 'device', 'label', 'position', 'installed_module', 'module_status', 'module_serial', + 'module_asset_tag', 'description', 'tags', ) - default_columns = ('pk', 'name', 'device', 'label', 'installed_module', 'description') + default_columns = ('pk', 'name', 'device', 'label', 'installed_module', 'module_status', 'description') class DeviceModuleBayTable(ModuleBayTable): @@ -799,10 +817,10 @@ class DeviceModuleBayTable(ModuleBayTable): class Meta(DeviceComponentTable.Meta): model = models.ModuleBay fields = ( - 'pk', 'id', 'name', 'label', 'position', 'installed_module', 'module_serial', 'module_asset_tag', + 'pk', 'id', 'name', 'label', 'position', 'installed_module', 'module_status', 'module_serial', 'module_asset_tag', 'description', 'tags', 'actions', ) - default_columns = ('pk', 'name', 'label', 'installed_module', 'description') + default_columns = ('pk', 'name', 'label', 'installed_module', 'module_status', 'description') class InventoryItemTable(DeviceComponentTable): diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index ee97900a4..bcbbf1739 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -371,7 +371,7 @@ class SiteView(generic.ObjectView): (VLANGroup.objects.restrict(request.user, 'view').filter( scope_type=ContentType.objects.get_for_model(Site), scope_id=instance.pk - ), 'site_id'), + ), 'site'), (VLAN.objects.restrict(request.user, 'view').filter(site=instance), 'site_id'), # Circuits (Circuit.objects.restrict(request.user, 'view').filter(terminations__site=instance).distinct(), 'site_id'), diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index f302024b0..3f796d7f8 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -187,11 +187,10 @@ class ReportViewSet(ViewSet): """ Compile all reports and their related results (if any). Result data is deferred in the list view. """ - report_content_type = ContentType.objects.get(app_label='extras', model='report') results = { - r.name: r - for r in Job.objects.filter( - object_type=report_content_type, + job.name: job + for job in Job.objects.filter( + object_type=ContentType.objects.get(app_label='extras', model='reportmodule'), status__in=JobStatusChoices.TERMINAL_STATE_CHOICES ).order_by('name', '-created').distinct('name').defer('data') } @@ -202,7 +201,7 @@ class ReportViewSet(ViewSet): # Attach Job objects to each report (if any) for report in report_list: - report.result = results.get(report.full_name, None) + report.result = results.get(report.name, None) serializer = serializers.ReportSerializer(report_list, many=True, context={ 'request': request, @@ -290,12 +289,10 @@ class ScriptViewSet(ViewSet): return module, script def list(self, request): - - script_content_type = ContentType.objects.get(app_label='extras', model='script') results = { - r.name: r - for r in Job.objects.filter( - object_type=script_content_type, + job.name: job + for job in Job.objects.filter( + object_type=ContentType.objects.get(app_label='extras', model='scriptmodule'), status__in=JobStatusChoices.TERMINAL_STATE_CHOICES ).order_by('name', '-created').distinct('name').defer('data') } @@ -306,7 +303,7 @@ class ScriptViewSet(ViewSet): # Attach Job objects to each script (if any) for script in script_list: - script.result = results.get(script.full_name, None) + script.result = results.get(script.name, None) serializer = serializers.ScriptSerializer(script_list, many=True, context={'request': request}) diff --git a/netbox/extras/dashboard/widgets.py b/netbox/extras/dashboard/widgets.py index 95460eb75..69d1cc36d 100644 --- a/netbox/extras/dashboard/widgets.py +++ b/netbox/extras/dashboard/widgets.py @@ -4,10 +4,12 @@ from hashlib import sha256 from urllib.parse import urlencode import feedparser +import requests from django import forms from django.conf import settings from django.contrib.contenttypes.models import ContentType from django.core.cache import cache +from django.db.models import Q from django.template.loader import render_to_string from django.urls import NoReverseMatch, reverse from django.utils.translation import gettext as _ @@ -33,7 +35,7 @@ def get_content_type_labels(): return [ (content_type_identifier(ct), content_type_name(ct)) for ct in ContentType.objects.filter( - FeatureQuery('export_templates').get_query() + FeatureQuery('export_templates').get_query() | Q(app_label='extras', model='objectchange') ).order_by('app_label', 'model') ] @@ -227,7 +229,11 @@ class ObjectListWidget(DashboardWidget): htmx_url = reverse(viewname) except NoReverseMatch: htmx_url = None - if parameters := self.config.get('url_params'): + parameters = self.config.get('url_params') or {} + if page_size := self.config.get('page_size'): + parameters['per_page'] = page_size + + if parameters: try: htmx_url = f'{htmx_url}?{urlencode(parameters, doseq=True)}' except ValueError: @@ -236,7 +242,6 @@ class ObjectListWidget(DashboardWidget): 'viewname': viewname, 'has_permission': has_permission, 'htmx_url': htmx_url, - 'page_size': self.config.get('page_size'), }) @@ -268,12 +273,9 @@ class RSSFeedWidget(DashboardWidget): ) def render(self, request): - url = self.config['feed_url'] - feed = self.get_feed() - return render_to_string(self.template_name, { - 'url': url, - 'feed': feed, + 'url': self.config['feed_url'], + **self.get_feed() }) @cached_property @@ -285,17 +287,33 @@ class RSSFeedWidget(DashboardWidget): def get_feed(self): # Fetch RSS content from cache if available if feed_content := cache.get(self.cache_key): - feed = feedparser.FeedParserDict(feed_content) - else: - feed = feedparser.parse( - self.config['feed_url'], - request_headers={'User-Agent': f'NetBox/{settings.VERSION}'} - ) - if not feed.bozo: - # Cap number of entries - max_entries = self.config.get('max_entries') - feed['entries'] = feed['entries'][:max_entries] - # Cache the feed content - cache.set(self.cache_key, dict(feed), self.config.get('cache_timeout')) + return { + 'feed': feedparser.FeedParserDict(feed_content), + } - return feed + # Fetch feed content from remote server + try: + response = requests.get( + url=self.config['feed_url'], + headers={'User-Agent': f'NetBox/{settings.VERSION}'}, + proxies=settings.HTTP_PROXIES, + timeout=3 + ) + response.raise_for_status() + except requests.exceptions.RequestException as e: + return { + 'error': e, + } + + # Parse feed content + feed = feedparser.parse(response.content) + if not feed.bozo: + # Cap number of entries + max_entries = self.config.get('max_entries') + feed['entries'] = feed['entries'][:max_entries] + # Cache the feed content + cache.set(self.cache_key, dict(feed), self.config.get('cache_timeout')) + + return { + 'feed': feed, + } diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py index c344a3214..818b8a52f 100644 --- a/netbox/extras/forms/bulk_import.py +++ b/netbox/extras/forms/bulk_import.py @@ -4,9 +4,10 @@ from django.contrib.postgres.forms import SimpleArrayField from django.utils.safestring import mark_safe from django.utils.translation import gettext as _ -from extras.choices import CustomFieldVisibilityChoices, CustomFieldTypeChoices +from extras.choices import CustomFieldVisibilityChoices, CustomFieldTypeChoices, JournalEntryKindChoices from extras.models import * from extras.utils import FeatureQuery +from netbox.forms import NetBoxModelImportForm from utilities.forms import CSVModelForm from utilities.forms.fields import CSVChoiceField, CSVContentTypeField, CSVMultipleContentTypeField, SlugField @@ -15,6 +16,7 @@ __all__ = ( 'CustomFieldImportForm', 'CustomLinkImportForm', 'ExportTemplateImportForm', + 'JournalEntryImportForm', 'SavedFilterImportForm', 'TagImportForm', 'WebhookImportForm', @@ -132,3 +134,20 @@ class TagImportForm(CSVModelForm): help_texts = { 'color': mark_safe(_('RGB color in hexadecimal (e.g. 00ff00)')), } + + +class JournalEntryImportForm(NetBoxModelImportForm): + assigned_object_type = CSVContentTypeField( + queryset=ContentType.objects.all(), + label=_('Assigned object type'), + ) + kind = CSVChoiceField( + choices=JournalEntryKindChoices, + help_text=_('The classification of entry') + ) + + class Meta: + model = JournalEntry + fields = ( + 'assigned_object_type', 'assigned_object_id', 'created_by', 'kind', 'comments', 'tags' + ) diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index 056302343..fae15d041 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -11,7 +11,7 @@ from extras.utils import FeatureQuery from netbox.forms.base import NetBoxModelFilterSetForm from tenancy.models import Tenant, TenantGroup from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice -from utilities.forms.fields import ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, TagFilterField +from utilities.forms.fields import ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, TagFilterField from utilities.forms.widgets import APISelectMultiple, DateTimePicker from virtualization.models import Cluster, ClusterGroup, ClusterType from .mixins import SavedFiltersMixin @@ -22,6 +22,7 @@ __all__ = ( 'CustomFieldFilterForm', 'CustomLinkFilterForm', 'ExportTemplateFilterForm', + 'ImageAttachmentFilterForm', 'JournalEntryFilterForm', 'LocalConfigContextFilterForm', 'ObjectChangeFilterForm', @@ -137,6 +138,20 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm): ) +class ImageAttachmentFilterForm(SavedFiltersMixin, FilterForm): + fieldsets = ( + (None, ('q', 'filter_id')), + ('Attributes', ('content_type_id', 'name',)), + ) + content_type_id = ContentTypeChoiceField( + queryset=ContentType.objects.filter(FeatureQuery('custom_fields').get_query()), + required=False + ) + name = forms.CharField( + required=False + ) + + class SavedFilterFilterForm(SavedFiltersMixin, FilterForm): fieldsets = ( (None, ('q', 'filter_id')), diff --git a/netbox/extras/management/commands/runscript.py b/netbox/extras/management/commands/runscript.py index 76ceeb239..b42e9b47d 100644 --- a/netbox/extras/management/commands/runscript.py +++ b/netbox/extras/management/commands/runscript.py @@ -111,7 +111,7 @@ class Command(BaseCommand): # Create the job job = Job.objects.create( - instance=module, + object=module, name=script.name, user=User.objects.filter(is_superuser=True).order_by('pk')[0], job_id=uuid.uuid4() diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 18430300f..439d15edc 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -606,5 +606,18 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): f"Invalid choice(s) ({', '.join(value)}). Available choices are: {', '.join(self.choices)}" ) + # Validate selected object + elif self.type == CustomFieldTypeChoices.TYPE_OBJECT: + if type(value) is not int: + raise ValidationError(f"Value must be an object ID, not {type(value).__name__}") + + # Validate selected objects + elif self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT: + if type(value) is not list: + raise ValidationError(f"Value must be a list of object IDs, not {type(value).__name__}") + for id in value: + if type(id) is not int: + raise ValidationError(f"Found invalid object ID: {id}") + elif self.required: raise ValidationError("Required field cannot be empty.") diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 735ada468..16e4fb577 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -285,7 +285,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): text = clean_html(text, allowed_schemes) # Sanitize link - link = urllib.parse.quote_plus(link, safe='/:?&') + link = urllib.parse.quote_plus(link, safe='/:?&=%+[]@#') # Verify link scheme is allowed result = urllib.parse.urlparse(link) diff --git a/netbox/extras/models/scripts.py b/netbox/extras/models/scripts.py index 1a7559e53..de48aae8e 100644 --- a/netbox/extras/models/scripts.py +++ b/netbox/extras/models/scripts.py @@ -1,4 +1,5 @@ import inspect +import logging from functools import cached_property from django.db import models @@ -16,6 +17,8 @@ __all__ = ( 'ScriptModule', ) +logger = logging.getLogger('netbox.data_backends') + class Script(WebhooksMixin, models.Model): """ @@ -53,7 +56,12 @@ class ScriptModule(PythonModuleMixin, JobsMixin, ManagedFile): # For child objects in submodules use the full import path w/o the root module as the name return cls.full_name.split(".", maxsplit=1)[1] - module = self.get_module() + try: + module = self.get_module() + except Exception as e: + logger.debug(f"Failed to load script: {self.python_name} error: {e}") + module = None + scripts = {} ordered = getattr(module, 'script_order', []) diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index 6787b0c75..e6d014302 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -13,6 +13,7 @@ __all__ = ( 'CustomFieldTable', 'CustomLinkTable', 'ExportTemplateTable', + 'ImageAttachmentTable', 'JournalEntryTable', 'ObjectChangeTable', 'SavedFilterTable', @@ -29,6 +30,7 @@ class CustomFieldTable(NetBoxTable): content_types = columns.ContentTypesColumn() required = columns.BooleanColumn() ui_visibility = columns.ChoiceFieldColumn(verbose_name="UI visibility") + description = columns.MarkdownColumn() is_cloneable = columns.BooleanColumn() class Meta(NetBoxTable.Meta): @@ -85,6 +87,28 @@ class ExportTemplateTable(NetBoxTable): ) +class ImageAttachmentTable(NetBoxTable): + id = tables.Column( + linkify=False + ) + content_type = columns.ContentTypeColumn() + parent = tables.Column( + linkify=True + ) + size = tables.Column( + orderable=False, + verbose_name='Size (bytes)' + ) + + class Meta(NetBoxTable.Meta): + model = ImageAttachment + fields = ( + 'pk', 'content_type', 'parent', 'image', 'name', 'image_height', 'image_width', 'size', 'created', + 'last_updated', + ) + default_columns = ('content_type', 'parent', 'image', 'name', 'size', 'created') + + class SavedFilterTable(NetBoxTable): name = tables.Column( linkify=True diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index f04c53add..c4fc3d938 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -73,6 +73,7 @@ urlpatterns = [ path('config-templates//', include(get_model_urls('extras', 'configtemplate'))), # Image attachments + path('image-attachments/', views.ImageAttachmentListView.as_view(), name='imageattachment_list'), path('image-attachments/add/', views.ImageAttachmentEditView.as_view(), name='imageattachment_add'), path('image-attachments//', include(get_model_urls('extras', 'imageattachment'))), @@ -81,6 +82,7 @@ urlpatterns = [ path('journal-entries/add/', views.JournalEntryEditView.as_view(), name='journalentry_add'), path('journal-entries/edit/', views.JournalEntryBulkEditView.as_view(), name='journalentry_bulk_edit'), path('journal-entries/delete/', views.JournalEntryBulkDeleteView.as_view(), name='journalentry_bulk_delete'), + path('journal-entries/import/', views.JournalEntryBulkImportView.as_view(), name='journalentry_import'), path('journal-entries//', include(get_model_urls('extras', 'journalentry'))), # Change logging diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 286ec76cd..6cbadf09d 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -577,6 +577,14 @@ class ObjectChangeView(generic.ObjectView): # Image attachments # +class ImageAttachmentListView(generic.ObjectListView): + queryset = ImageAttachment.objects.all() + filterset = filtersets.ImageAttachmentFilterSet + filterset_form = forms.ImageAttachmentFilterForm + table = tables.ImageAttachmentTable + actions = ('export',) + + @register_model_view(ImageAttachment, 'edit') class ImageAttachmentEditView(generic.ObjectEditView): queryset = ImageAttachment.objects.all() @@ -617,7 +625,7 @@ class JournalEntryListView(generic.ObjectListView): filterset = filtersets.JournalEntryFilterSet filterset_form = forms.JournalEntryFilterForm table = tables.JournalEntryTable - actions = ('export', 'bulk_edit', 'bulk_delete') + actions = ('import', 'export', 'bulk_edit', 'bulk_delete') @register_model_view(JournalEntry) @@ -666,6 +674,11 @@ class JournalEntryBulkDeleteView(generic.BulkDeleteView): table = tables.JournalEntryTable +class JournalEntryBulkImportView(generic.BulkImportView): + queryset = JournalEntry.objects.all() + model_form = forms.JournalEntryImportForm + + # # Dashboard & widgets # @@ -1033,7 +1046,6 @@ class ScriptView(ContentTypePermissionRequiredMixin, View): return 'extras.view_script' def get(self, request, module, name): - print(module) module = get_object_or_404(ScriptModule.objects.restrict(request.user), file_path__startswith=module) script = module.scripts[name]() form = script.as_form(initial=normalize_querydict(request.GET)) diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py index 9951b72e4..cf8117bf7 100644 --- a/netbox/ipam/forms/model_forms.py +++ b/netbox/ipam/forms/model_forms.py @@ -262,38 +262,21 @@ class IPRangeForm(TenancyForm, NetBoxModelForm): class IPAddressForm(TenancyForm, NetBoxModelForm): - device = DynamicModelChoiceField( - queryset=Device.objects.all(), - required=False, - initial_params={ - 'interfaces': '$interface' - } - ) interface = DynamicModelChoiceField( queryset=Interface.objects.all(), required=False, - query_params={ - 'device_id': '$device' - } - ) - virtual_machine = DynamicModelChoiceField( - queryset=VirtualMachine.objects.all(), - required=False, - initial_params={ - 'interfaces': '$vminterface' - } + selector=True, ) vminterface = DynamicModelChoiceField( queryset=VMInterface.objects.all(), required=False, + selector=True, label=_('Interface'), - query_params={ - 'virtual_machine_id': '$virtual_machine' - } ) fhrpgroup = DynamicModelChoiceField( queryset=FHRPGroup.objects.all(), required=False, + selector=True, label=_('FHRP Group') ) vrf = DynamicModelChoiceField( @@ -301,33 +284,11 @@ class IPAddressForm(TenancyForm, NetBoxModelForm): required=False, label=_('VRF') ) - nat_device = DynamicModelChoiceField( - queryset=Device.objects.all(), - required=False, - selector=True, - label=_('Device') - ) - nat_virtual_machine = DynamicModelChoiceField( - queryset=VirtualMachine.objects.all(), - required=False, - selector=True, - label=_('Virtual Machine') - ) - nat_vrf = DynamicModelChoiceField( - queryset=VRF.objects.all(), - required=False, - selector=True, - label=_('VRF') - ) nat_inside = DynamicModelChoiceField( queryset=IPAddress.objects.all(), required=False, + selector=True, label=_('IP Address'), - query_params={ - 'device_id': '$nat_device', - 'virtual_machine_id': '$nat_virtual_machine', - 'vrf_id': '$nat_vrf', - } ) primary_for_parent = forms.BooleanField( required=False, @@ -338,8 +299,8 @@ class IPAddressForm(TenancyForm, NetBoxModelForm): class Meta: model = IPAddress fields = [ - 'address', 'vrf', 'status', 'role', 'dns_name', 'primary_for_parent', 'nat_device', 'nat_virtual_machine', - 'nat_vrf', 'nat_inside', 'tenant_group', 'tenant', 'description', 'comments', 'tags', + 'address', 'vrf', 'status', 'role', 'dns_name', 'primary_for_parent', 'nat_inside', 'tenant_group', + 'tenant', 'description', 'comments', 'tags', ] def __init__(self, *args, **kwargs): @@ -354,17 +315,6 @@ class IPAddressForm(TenancyForm, NetBoxModelForm): initial['vminterface'] = instance.assigned_object elif type(instance.assigned_object) is FHRPGroup: initial['fhrpgroup'] = instance.assigned_object - if instance.nat_inside: - nat_inside_parent = instance.nat_inside.assigned_object - if type(nat_inside_parent) is Interface: - initial['nat_site'] = nat_inside_parent.device.site.pk - if nat_inside_parent.device.rack: - initial['nat_rack'] = nat_inside_parent.device.rack.pk - initial['nat_device'] = nat_inside_parent.device.pk - elif type(nat_inside_parent) is VMInterface: - if cluster := nat_inside_parent.virtual_machine.cluster: - initial['nat_cluster'] = cluster.pk - initial['nat_virtual_machine'] = nat_inside_parent.virtual_machine.pk kwargs['initial'] = initial super().__init__(*args, **kwargs) diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index a49c4aab3..93d0dc8bb 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -14,6 +14,7 @@ from utilities.views import ViewTab, register_model_view from virtualization.filtersets import VMInterfaceFilterSet from virtualization.models import VMInterface from . import filtersets, forms, tables +from .choices import PrefixStatusChoices from .constants import * from .models import * from .tables.l2vpn import L2VPNTable, L2VPNTerminationTable @@ -495,7 +496,7 @@ class PrefixView(generic.ObjectView): # Parent prefixes table parent_prefixes = Prefix.objects.restrict(request.user, 'view').filter( - Q(vrf=instance.vrf) | Q(vrf__isnull=True) + Q(vrf=instance.vrf) | Q(vrf__isnull=True, status=PrefixStatusChoices.STATUS_CONTAINER) ).filter( prefix__net_contains=str(instance.prefix) ).prefetch_related( diff --git a/netbox/netbox/api/serializers/features.py b/netbox/netbox/api/serializers/features.py index 5332a22d6..1374ba526 100644 --- a/netbox/netbox/api/serializers/features.py +++ b/netbox/netbox/api/serializers/features.py @@ -14,35 +14,13 @@ __all__ = ( class CustomFieldModelSerializer(serializers.Serializer): """ - Introduces support for custom field assignment. Adds `custom_fields` serialization and ensures - that custom field data is populated upon initialization. + Introduces support for custom field assignment and representation. """ custom_fields = CustomFieldsDataField( source='custom_field_data', default=CreateOnlyDefault(CustomFieldDefaultValues()) ) - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - if self.instance is not None: - - # Retrieve the set of CustomFields which apply to this type of object - content_type = ContentType.objects.get_for_model(self.Meta.model) - fields = CustomField.objects.filter(content_types=content_type) - - # Populate custom field values for each instance from database - if type(self.instance) in (list, tuple): - for obj in self.instance: - self._populate_custom_fields(obj, fields) - else: - self._populate_custom_fields(self.instance, fields) - - def _populate_custom_fields(self, instance, custom_fields): - instance.custom_fields = {} - for field in custom_fields: - instance.custom_fields[field.name] = instance.cf.get(field.name) - class TaggableModelSerializer(serializers.Serializer): """ diff --git a/netbox/netbox/configuration_example.py b/netbox/netbox/configuration_example.py index 4878ec520..f415ca42f 100644 --- a/netbox/netbox/configuration_example.py +++ b/netbox/netbox/configuration_example.py @@ -13,6 +13,7 @@ ALLOWED_HOSTS = [] # PostgreSQL database configuration. See the Django documentation for a complete list of available parameters: # https://docs.djangoproject.com/en/stable/ref/settings/#databases DATABASE = { + 'ENGINE': 'django.db.backends.postgresql', # Database engine 'NAME': 'netbox', # Database name 'USER': '', # PostgreSQL username 'PASSWORD': '', # PostgreSQL password diff --git a/netbox/netbox/models/__init__.py b/netbox/netbox/models/__init__.py index fe25bb837..c0f679e4f 100644 --- a/netbox/netbox/models/__init__.py +++ b/netbox/netbox/models/__init__.py @@ -67,8 +67,8 @@ class NetBoxModel(CloningMixin, NetBoxFeatureSet, models.Model): for field in self._meta.get_fields(): if isinstance(field, GenericForeignKey): - ct_value = getattr(self, field.ct_field) - fk_value = getattr(self, field.fk_field) + ct_value = getattr(self, field.ct_field, None) + fk_value = getattr(self, field.fk_field, None) if ct_value is None and fk_value is not None: raise ValidationError({ diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index 2b1428d27..6e5bcfc23 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -292,6 +292,7 @@ CUSTOMIZATION_MENU = Menu( get_model_item('extras', 'exporttemplate', _('Export Templates')), get_model_item('extras', 'savedfilter', _('Saved Filters')), get_model_item('extras', 'tag', 'Tags'), + get_model_item('extras', 'imageattachment', _('Image Attachments'), actions=()), ), ), MenuGroup( @@ -336,7 +337,7 @@ OPERATIONS_MENU = Menu( MenuGroup( label=_('Logging'), items=( - get_model_item('extras', 'journalentry', _('Journal Entries'), actions=[]), + get_model_item('extras', 'journalentry', _('Journal Entries'), actions=['import']), get_model_item('extras', 'objectchange', _('Change Log'), actions=[]), ), ), diff --git a/netbox/netbox/search/backends.py b/netbox/netbox/search/backends.py index f428842f5..4487b6bb8 100644 --- a/netbox/netbox/search/backends.py +++ b/netbox/netbox/search/backends.py @@ -145,9 +145,12 @@ class CachedValueSearchBackend(SearchBackend): ) # Omit any results pertaining to an object the user does not have permission to view - return [ - r for r in results if r.object is not None - ] + ret = [] + for r in results: + if r.object is not None: + r.name = str(r.object) + ret.append(r) + return ret def cache(self, instances, indexer=None, remove_existing=True): content_type = None diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 8df61a7c9..3f3f96736 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -25,7 +25,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW # Environment setup # -VERSION = '3.5-beta2' +VERSION = '3.5.2-dev' # Hostname HOSTNAME = platform.node() @@ -68,6 +68,15 @@ DATABASE = getattr(configuration, 'DATABASE') REDIS = getattr(configuration, 'REDIS') SECRET_KEY = getattr(configuration, 'SECRET_KEY') +# Enforce minimum length for SECRET_KEY +if type(SECRET_KEY) is not str: + raise ImproperlyConfigured(f"SECRET_KEY must be a string (found {type(SECRET_KEY).__name__})") +if len(SECRET_KEY) < 50: + raise ImproperlyConfigured( + f"SECRET_KEY must be at least 50 characters in length. To generate a suitable key, run the following command:\n" + f" python {BASE_DIR}/generate_secret_key.py" + ) + # Calculate a unique deployment ID from the secret key DEPLOYMENT_ID = hashlib.sha256(SECRET_KEY.encode('utf-8')).hexdigest()[:16] @@ -173,15 +182,16 @@ if RELEASE_CHECK_URL: # Database # -# Only PostgreSQL is supported -if METRICS_ENABLED: - DATABASE.update({ - 'ENGINE': 'django_prometheus.db.backends.postgresql' - }) -else: - DATABASE.update({ - 'ENGINE': 'django.db.backends.postgresql' - }) +if 'ENGINE' not in DATABASE: + # Only PostgreSQL is supported + if METRICS_ENABLED: + DATABASE.update({ + 'ENGINE': 'django_prometheus.db.backends.postgresql' + }) + else: + DATABASE.update({ + 'ENGINE': 'django.db.backends.postgresql' + }) DATABASES = { 'default': DATABASE, @@ -607,13 +617,15 @@ REST_FRAMEWORK = { # SPECTACULAR_SETTINGS = { - 'TITLE': 'NetBox API', - 'DESCRIPTION': 'API to access NetBox', + 'TITLE': 'NetBox REST API', 'LICENSE': {'name': 'Apache v2 License'}, 'VERSION': VERSION, 'COMPONENT_SPLIT_REQUEST': True, 'REDOC_DIST': 'SIDECAR', - 'SERVERS': [{'url': f'/{BASE_PATH}api'}], + 'SERVERS': [{ + 'url': BASE_PATH, + 'description': 'NetBox', + }], 'SWAGGER_UI_DIST': 'SIDECAR', 'SWAGGER_UI_FAVICON_HREF': 'SIDECAR', 'POSTPROCESSING_HOOKS': [], diff --git a/netbox/netbox/tables/tables.py b/netbox/netbox/tables/tables.py index ac000aed8..839d85996 100644 --- a/netbox/netbox/tables/tables.py +++ b/netbox/netbox/tables/tables.py @@ -219,7 +219,8 @@ class SearchTable(tables.Table): order_by="object___meta__verbose_name", ) object = tables.Column( - linkify=True + linkify=True, + order_by=('name', ) ) field = tables.Column() value = tables.Column() diff --git a/netbox/project-static/dist/netbox-dark.css b/netbox/project-static/dist/netbox-dark.css index a690cfcac..11110069e 100644 Binary files a/netbox/project-static/dist/netbox-dark.css and b/netbox/project-static/dist/netbox-dark.css differ diff --git a/netbox/project-static/dist/netbox-light.css b/netbox/project-static/dist/netbox-light.css index fad60f89b..8a3c83af9 100644 Binary files a/netbox/project-static/dist/netbox-light.css and b/netbox/project-static/dist/netbox-light.css differ diff --git a/netbox/project-static/dist/netbox-print.css b/netbox/project-static/dist/netbox-print.css index 37814cd20..ef2682e0a 100644 Binary files a/netbox/project-static/dist/netbox-print.css and b/netbox/project-static/dist/netbox-print.css differ diff --git a/netbox/project-static/styles/netbox.scss b/netbox/project-static/styles/netbox.scss index 37f6c21c4..b294d67bd 100644 --- a/netbox/project-static/styles/netbox.scss +++ b/netbox/project-static/styles/netbox.scss @@ -231,6 +231,10 @@ table { p { // Remove spacing from paragraph elements within tables. + margin-bottom: 0.5em; + } + + p:last-child { margin-bottom: 0; } } diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html index 695202176..5a565ea29 100644 --- a/netbox/templates/circuits/provider.html +++ b/netbox/templates/circuits/provider.html @@ -29,17 +29,6 @@ {% endfor %} - - - Account - - {{ object.account|placeholder }} - Description {{ object.description|placeholder }} diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index d6de8f3cb..91fdba7be 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -87,11 +87,13 @@ Physical Address {% if object.physical_address %} - + {% if config.MAPS_URL %} + + {% endif %} {{ object.physical_address|linebreaksbr }} {% else %} {{ ''|placeholder }} @@ -106,11 +108,13 @@ GPS Coordinates {% if object.latitude and object.longitude %} - + {% if config.MAPS_URL %} + + {% endif %} {{ object.latitude }}, {{ object.longitude }} {% else %} {{ ''|placeholder }} diff --git a/netbox/templates/extras/customfield.html b/netbox/templates/extras/customfield.html index 4c103d4c6..b783c8a77 100644 --- a/netbox/templates/extras/customfield.html +++ b/netbox/templates/extras/customfield.html @@ -32,7 +32,7 @@ Description - {{ object.description|placeholder }} + {{ object.description|markdown|placeholder }} Required diff --git a/netbox/templates/extras/dashboard/widgets/objectlist.html b/netbox/templates/extras/dashboard/widgets/objectlist.html index 76c4e658c..54f8094b3 100644 --- a/netbox/templates/extras/dashboard/widgets/objectlist.html +++ b/netbox/templates/extras/dashboard/widgets/objectlist.html @@ -1,5 +1,5 @@ {% if htmx_url and has_permission %} -
+
{% elif htmx_url %}
No permission to view this content. diff --git a/netbox/templates/extras/dashboard/widgets/rssfeed.html b/netbox/templates/extras/dashboard/widgets/rssfeed.html index 5de3c3105..c304b7c07 100644 --- a/netbox/templates/extras/dashboard/widgets/rssfeed.html +++ b/netbox/templates/extras/dashboard/widgets/rssfeed.html @@ -1,4 +1,4 @@ -{% if not feed.bozo %} +{% if feed and not feed.bozo %}
{% for entry in feed.entries %}
@@ -16,7 +16,9 @@ There was a problem fetching the RSS feed: -
-Response status: {{ feed.status }}
-Error: {{ feed.bozo_exception|escape }}
+ {% if feed %} + {{ feed.bozo_exception|escape }} (HTTP {{ feed.status }}) + {% else %} + {{ error }} + {% endif %} {% endif %} diff --git a/netbox/templates/extras/script_list.html b/netbox/templates/extras/script_list.html index bccbce589..9a67e2b10 100644 --- a/netbox/templates/extras/script_list.html +++ b/netbox/templates/extras/script_list.html @@ -37,43 +37,49 @@
{% include 'inc/sync_warning.html' with object=module %} - - - - - - - - - - - {% with jobs=module.get_latest_jobs %} - {% for script_name, script_class in module.scripts.items %} - - - - {% with last_result=jobs|get_key:script_class.name %} - {% if last_result %} - - - {% else %} - - - {% endif %} - {% endwith %} - - {% endfor %} - {% endwith %} - -
NameDescriptionLast RunStatus
- {{ script_class.name }} - - {{ script_class.Meta.description|markdown|placeholder }} - - {{ last_result.created|annotated_date }} - - {% badge last_result.get_status_display last_result.get_status_color %} - Never{{ ''|placeholder }}
+ {% if not module.scripts %} + + {% else %} + + + + + + + + + + + {% with jobs=module.get_latest_jobs %} + {% for script_name, script_class in module.scripts.items %} + + + + {% with last_result=jobs|get_key:script_class.name %} + {% if last_result %} + + + {% else %} + + + {% endif %} + {% endwith %} + + {% endfor %} + {% endwith %} + +
NameDescriptionLast RunStatus
+ {{ script_class.name }} + + {{ script_class.Meta.description|markdown|placeholder }} + + {{ last_result.created|annotated_date }} + + {% badge last_result.get_status_display last_result.get_status_color %} + Never{{ ''|placeholder }}
+ {% endif %}
{% empty %} diff --git a/netbox/templates/inc/panels/image_attachments.html b/netbox/templates/inc/panels/image_attachments.html index 9706a7ffe..0c1d212d9 100644 --- a/netbox/templates/inc/panels/image_attachments.html +++ b/netbox/templates/inc/panels/image_attachments.html @@ -4,44 +4,9 @@
Images
-
- {% with images=object.images.all %} - {% if images.exists %} - - - - - - - - {% for attachment in images %} - - - - - - - {% endfor %} -
NameSizeCreated
- - {{ attachment }} - {{ attachment.size|filesizeformat }}{{ attachment.created|annotated_date }} - {% if perms.extras.change_imageattachment %} - - - - {% endif %} - {% if perms.extras.delete_imageattachment %} - - - - {% endif %} -
- {% else %} -
None
- {% endif %} - {% endwith %} -
+
{% if perms.extras.add_imageattachment %}
- {% render_field form.device %} {% render_field form.interface %}
- {% render_field form.virtual_machine %} {% render_field form.vminterface %}
@@ -75,60 +73,6 @@
NAT IP (Inside)
-
- -
-
-
-
- {% render_field form.nat_device %} -
-
- {% render_field form.nat_virtual_machine %} -
-
- {% render_field form.nat_vrf %} -
{% render_field form.nat_inside %}
diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index ba7249c8d..b9ada8640 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -352,6 +352,7 @@ class ContactAssignmentListView(generic.ObjectListView): filterset = filtersets.ContactAssignmentFilterSet filterset_form = forms.ContactAssignmentFilterForm table = tables.ContactAssignmentTable + actions = ('export', 'bulk_edit', 'bulk_delete') @register_model_view(ContactAssignment, 'edit') diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index 57092bb7d..b1504e62f 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -491,14 +491,14 @@ def clean_html(html, schemes): Also takes a list of allowed URI schemes. """ - ALLOWED_TAGS = [ + ALLOWED_TAGS = { "div", "pre", "code", "blockquote", "del", "hr", "h1", "h2", "h3", "h4", "h5", "h6", "ul", "ol", "li", "p", "br", "strong", "em", "a", "b", "i", "img", "table", "thead", "tbody", "tr", "th", "td", "dl", "dt", "dd", - ] + } ALLOWED_ATTRIBUTES = { "div": ['class'], diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py index 7d0f1107e..f72215b98 100644 --- a/netbox/virtualization/api/serializers.py +++ b/netbox/virtualization/api/serializers.py @@ -126,7 +126,11 @@ class VMInterfaceSerializer(NetBoxModelSerializer): l2vpn_termination = NestedL2VPNTerminationSerializer(read_only=True, allow_null=True) count_ipaddresses = serializers.IntegerField(read_only=True) count_fhrp_groups = serializers.IntegerField(read_only=True) - mac_address = serializers.CharField(required=False, default=None) + mac_address = serializers.CharField( + required=False, + default=None, + allow_null=True + ) class Meta: model = VMInterface diff --git a/requirements.txt b/requirements.txt index 024905a35..c3d9c8c38 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,40 +1,37 @@ -bleach==5.0.1 -boto3==1.26.115 -Django==4.1.8 +bleach==6.0.0 +boto3==1.26.127 +Django==4.1.9 django-cors-headers==3.14.0 django-debug-toolbar==4.0.0 -django-filter==23.1 +django-filter==23.2 django-graphiql-debug-toolbar==0.2.0 django-mptt==0.14 django-pglocks==1.0.4 -django-prometheus==2.2.0 +django-prometheus==2.3.1 django-redis==5.2.0 django-rich==1.5.0 -django-rq==2.7.0 +django-rq==2.8.0 django-tables2==2.5.3 -django-taggit==3.1.0 +django-taggit==4.0.0 django-timezone-field==5.0 djangorestframework==3.14.0 drf-spectacular==0.26.2 -drf-spectacular-sidecar==2023.4.1 -dulwich==0.21.3 +drf-spectacular-sidecar==2023.5.1 +dulwich==0.21.5 feedparser==6.0.10 graphene-django==3.0.0 gunicorn==20.1.0 Jinja2==3.1.2 Markdown==3.3.7 -mkdocs-material==9.1.8 +mkdocs-material==9.1.9 mkdocstrings[python-legacy]==0.21.2 netaddr==0.8.0 Pillow==9.5.0 psycopg2-binary==2.9.6 PyYAML==6.0 -sentry-sdk==1.21.0 -social-auth-app-django==5.0.0 +sentry-sdk==1.22.1 +social-auth-app-django==5.2.0 social-auth-core[openidconnect]==4.4.2 svgwrite==1.4.3 tablib==3.4.0 tzdata==2023.3 - -# Workaround for #7401 -jsonschema==3.2.0