diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 672f43b09..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.0 + 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 842454d6b..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.0 + placeholder: v3.5.1 validations: required: true - type: dropdown 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/required-parameters.md b/docs/configuration/required-parameters.md index c35f90f7b..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 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/version-3.5.md b/docs/release-notes/version-3.5.md index 13fee4473..203c439c2 100644 --- a/docs/release-notes/version-3.5.md +++ b/docs/release-notes/version-3.5.md @@ -1,5 +1,47 @@ # NetBox v3.5 +## 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 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/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/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..056d05c9a 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: @@ -781,14 +785,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 +806,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/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/settings.py b/netbox/netbox/settings.py index c35ebe992..dddceca9b 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.0' +VERSION = '3.5.1' # Hostname HOSTNAME = platform.node() @@ -182,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, @@ -616,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/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/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/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 3ac6d636f..c3d9c8c38 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,35 +1,35 @@ bleach==6.0.0 -boto3==1.26.121 -Django==4.1.8 +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 +sentry-sdk==1.22.1 social-auth-app-django==5.2.0 social-auth-core[openidconnect]==4.4.2 svgwrite==1.4.3