Browse Source

Merge pull request #12507 from netbox-community/develop

Release v3.5.1
Jeremy Stretch 2 years ago
parent
commit
5f184f2435
53 changed files with 480 additions and 353 deletions
  1. 1 1
      .github/ISSUE_TEMPLATE/bug_report.yaml
  2. 1 1
      .github/ISSUE_TEMPLATE/feature_request.yaml
  3. 17 0
      contrib/netbox-housekeeping.service
  4. 13 0
      contrib/netbox-housekeeping.timer
  5. 32 2
      docs/administration/housekeeping.md
  6. 5 0
      docs/configuration/required-parameters.md
  7. 4 4
      docs/customization/custom-links.md
  8. 1 1
      docs/development/models.md
  9. 0 2
      docs/models/dcim/platform.md
  10. 42 0
      docs/release-notes/version-3.5.md
  11. 1 1
      netbox/circuits/api/serializers.py
  12. 2 1
      netbox/circuits/forms/bulk_import.py
  13. 2 13
      netbox/core/api/schema.py
  14. 23 12
      netbox/core/data_backends.py
  15. 5 1
      netbox/dcim/api/serializers.py
  16. 1 0
      netbox/dcim/filtersets.py
  17. 19 3
      netbox/dcim/forms/bulk_edit.py
  18. 9 0
      netbox/dcim/forms/filtersets.py
  19. 17 3
      netbox/dcim/forms/object_create.py
  20. 22 9
      netbox/dcim/svg/racks.py
  21. 12 5
      netbox/dcim/tables/devices.py
  22. 1 1
      netbox/dcim/views.py
  23. 8 11
      netbox/extras/api/views.py
  24. 39 21
      netbox/extras/dashboard/widgets.py
  25. 20 1
      netbox/extras/forms/bulk_import.py
  26. 16 1
      netbox/extras/forms/filtersets.py
  27. 1 1
      netbox/extras/management/commands/runscript.py
  28. 13 0
      netbox/extras/models/customfields.py
  29. 9 1
      netbox/extras/models/scripts.py
  30. 24 0
      netbox/extras/tables/tables.py
  31. 2 0
      netbox/extras/urls.py
  32. 14 2
      netbox/extras/views.py
  33. 6 56
      netbox/ipam/forms/model_forms.py
  34. 2 1
      netbox/ipam/views.py
  35. 1 23
      netbox/netbox/api/serializers/features.py
  36. 1 0
      netbox/netbox/configuration_example.py
  37. 2 2
      netbox/netbox/models/__init__.py
  38. 2 1
      netbox/netbox/navigation/menu.py
  39. 16 13
      netbox/netbox/settings.py
  40. 0 0
      netbox/project-static/dist/netbox-dark.css
  41. 0 0
      netbox/project-static/dist/netbox-light.css
  42. 0 0
      netbox/project-static/dist/netbox-print.css
  43. 4 0
      netbox/project-static/styles/netbox.scss
  44. 0 11
      netbox/templates/circuits/provider.html
  45. 1 1
      netbox/templates/extras/customfield.html
  46. 1 1
      netbox/templates/extras/dashboard/widgets/objectlist.html
  47. 6 4
      netbox/templates/extras/dashboard/widgets/rssfeed.html
  48. 43 37
      netbox/templates/extras/script_list.html
  49. 3 38
      netbox/templates/inc/panels/image_attachments.html
  50. 0 56
      netbox/templates/ipam/ipaddress_edit.html
  51. 1 0
      netbox/tenancy/views.py
  52. 5 1
      netbox/virtualization/api/serializers.py
  53. 10 10
      requirements.txt

+ 1 - 1
.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

+ 1 - 1
.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

+ 17 - 0
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

+ 13 - 0
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

+ 32 - 2
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.

+ 5 - 0
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

+ 4 - 4
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."

+ 1 - 1
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)

+ 0 - 2
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

+ 42 - 0
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

+ 1 - 1
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)

+ 2 - 1
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(),

+ 2 - 13
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

+ 23 - 12
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}")
 

+ 5 - 1
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:

+ 1 - 0
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)

+ 19 - 3
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):

+ 9 - 0
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'),

+ 17 - 3
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.

+ 22 - 9
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: <name>
+    Role: <device_role>
+    Device Type: <manufacturer> <model> (<u_height>)
+    Asset tag: <asset_tag> (if defined)
+    Serial: <serial> (if defined)
+    Description: <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:

+ 12 - 5
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):

+ 1 - 1
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'),

+ 8 - 11
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})
 

+ 39 - 21
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}'}
+            return {
+                'feed': feedparser.FeedParserDict(feed_content),
+            }
+
+        # 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
             )
-            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
+            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,
+        }

+ 20 - 1
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. <code>00ff00</code>)')),
         }
+
+
+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'
+        )

+ 16 - 1
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')),

+ 1 - 1
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()

+ 13 - 0
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.")

+ 9 - 1
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', [])
 

+ 24 - 0
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

+ 2 - 0
netbox/extras/urls.py

@@ -73,6 +73,7 @@ urlpatterns = [
     path('config-templates/<int:pk>/', 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/<int:pk>/', 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/<int:pk>/', include(get_model_urls('extras', 'journalentry'))),
 
     # Change logging

+ 14 - 2
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))

+ 6 - 56
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)

+ 2 - 1
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(

+ 1 - 23
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):
     """

+ 1 - 0
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

+ 2 - 2
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({

+ 2 - 1
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=[]),
             ),
         ),

+ 16 - 13
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': [],

File diff suppressed because it is too large
+ 0 - 0
netbox/project-static/dist/netbox-dark.css


File diff suppressed because it is too large
+ 0 - 0
netbox/project-static/dist/netbox-light.css


File diff suppressed because it is too large
+ 0 - 0
netbox/project-static/dist/netbox-print.css


+ 4 - 0
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;
     }
   }

+ 0 - 11
netbox/templates/circuits/provider.html

@@ -29,17 +29,6 @@
                         {% endfor %}
                       </td>
                     </tr>
-                    <tr>
-                        <th scope="row">
-                          Account <i
-                            class="mdi mdi-alert-box text-warning"
-                            data-bs-toggle="tooltip"
-                            data-bs-placement="right"
-                            title="This field has been deprecated, and will be removed in NetBox v3.5."
-                          ></i>
-                        </th>
-                        <td>{{ object.account|placeholder }}</td>
-                    </tr>
                     <tr>
                         <th scope="row">Description</th>
                         <td>{{ object.description|placeholder }}</td>

+ 1 - 1
netbox/templates/extras/customfield.html

@@ -32,7 +32,7 @@
           </tr>
           <tr>
             <th scope="row">Description</th>
-            <td>{{ object.description|placeholder }}</td>
+            <td>{{ object.description|markdown|placeholder }}</td>
           </tr>
           <tr>
             <th scope="row">Required</th>

+ 1 - 1
netbox/templates/extras/dashboard/widgets/objectlist.html

@@ -1,5 +1,5 @@
 {% if htmx_url and has_permission %}
-  <div class="htmx-container" hx-get="{{ htmx_url }}{% if page_size %}?per_page={{ page_size }}{% endif %}" hx-trigger="load"></div>
+  <div class="htmx-container" hx-get="{{ htmx_url }}" hx-trigger="load"></div>
 {% elif htmx_url %}
   <div class="text-muted text-center">
     <i class="mdi mdi-lock-outline"></i> No permission to view this content.

+ 6 - 4
netbox/templates/extras/dashboard/widgets/rssfeed.html

@@ -1,4 +1,4 @@
-{% if not feed.bozo %}
+{% if feed and not feed.bozo %}
   <div class="list-group list-group-flush">
     {% for entry in feed.entries %}
       <div class="list-group-item px-1">
@@ -16,7 +16,9 @@
   <span class="text-danger">
     <i class="mdi mdi-alert"></i> There was a problem fetching the RSS feed:
   </span>
-  <pre class="m-2">
-Response status: {{ feed.status }}
-Error: {{ feed.bozo_exception|escape }}</pre>
+  {% if feed %}
+    {{ feed.bozo_exception|escape }} (HTTP {{ feed.status }})
+  {% else %}
+    {{ error }}
+  {% endif %}
 {% endif %}

+ 43 - 37
netbox/templates/extras/script_list.html

@@ -37,43 +37,49 @@
         </h5>
         <div class="card-body">
           {% include 'inc/sync_warning.html' with object=module %}
-          <table class="table table-hover table-headings reports">
-            <thead>
-              <tr>
-                <th width="250">Name</th>
-                <th>Description</th>
-                <th>Last Run</th>
-                <th class="text-end">Status</th>
-              </tr>
-            </thead>
-            <tbody>
-              {% with jobs=module.get_latest_jobs %}
-                {% for script_name, script_class in module.scripts.items %}
-                  <tr>
-                    <td>
-                      <a href="{% url 'extras:script' module=module.python_name name=script_name %}" name="script.{{ script_name }}">{{ script_class.name }}</a>
-                    </td>
-                    <td>
-                      {{ script_class.Meta.description|markdown|placeholder }}
-                    </td>
-                    {% with last_result=jobs|get_key:script_class.name %}
-                      {% if last_result %}
-                        <td>
-                          <a href="{% url 'extras:script_result' job_pk=last_result.pk %}">{{ last_result.created|annotated_date }}</a>
-                        </td>
-                        <td class="text-end">
-                          {% badge last_result.get_status_display last_result.get_status_color %}
-                        </td>
-                      {% else %}
-                        <td class="text-muted">Never</td>
-                        <td class="text-end">{{ ''|placeholder }}</td>
-                      {% endif %}
-                    {% endwith %}
-                  </tr>
-                {% endfor %}
-              {% endwith %}
-            </tbody>
-          </table>
+          {% if not module.scripts %}
+            <div class="alert alert-warning d-flex align-items-center" role="alert">
+              <i class="mdi mdi-alert"></i>&nbsp; Script file at: {{module.full_path}} could not be loaded.
+            </div>
+          {% else %}
+            <table class="table table-hover table-headings reports">
+              <thead>
+                <tr>
+                  <th width="250">Name</th>
+                  <th>Description</th>
+                  <th>Last Run</th>
+                  <th class="text-end">Status</th>
+                </tr>
+              </thead>
+              <tbody>
+                {% with jobs=module.get_latest_jobs %}
+                  {% for script_name, script_class in module.scripts.items %}
+                    <tr>
+                      <td>
+                        <a href="{% url 'extras:script' module=module.python_name name=script_name %}" name="script.{{ script_name }}">{{ script_class.name }}</a>
+                      </td>
+                      <td>
+                        {{ script_class.Meta.description|markdown|placeholder }}
+                      </td>
+                      {% with last_result=jobs|get_key:script_class.name %}
+                        {% if last_result %}
+                          <td>
+                            <a href="{% url 'extras:script_result' job_pk=last_result.pk %}">{{ last_result.created|annotated_date }}</a>
+                          </td>
+                          <td class="text-end">
+                            {% badge last_result.get_status_display last_result.get_status_color %}
+                          </td>
+                        {% else %}
+                          <td class="text-muted">Never</td>
+                          <td class="text-end">{{ ''|placeholder }}</td>
+                        {% endif %}
+                      {% endwith %}
+                    </tr>
+                  {% endfor %}
+                {% endwith %}
+              </tbody>
+            </table>
+          {% endif %}
         </div>
       </div>
     {% empty %}

+ 3 - 38
netbox/templates/inc/panels/image_attachments.html

@@ -4,44 +4,9 @@
   <h5 class="card-header">
     Images
   </h5>
-  <div class="card-body">
-    {% with images=object.images.all %}
-      {% if images.exists %}
-        <table class="table table-hover">
-          <tr>
-            <th>Name</th>
-            <th>Size</th>
-            <th>Created</th>
-            <th></th>
-          </tr>
-          {% for attachment in images %}
-            <tr{% if not attachment.size %} class="table-danger"{% endif %}>
-              <td>
-                <i class="mdi mdi-file-image-outline"></i>
-                <a class="image-preview" href="{{ attachment.image.url }}" target="_blank">{{ attachment }}</a>
-              </td>
-              <td>{{ attachment.size|filesizeformat }}</td>
-              <td>{{ attachment.created|annotated_date }}</td>
-              <td class="text-end noprint">
-                {% if perms.extras.change_imageattachment %}
-                  <a href="{% url 'extras:imageattachment_edit' pk=attachment.pk %}" class="btn btn-warning btn-sm lh-1" title="Edit Image">
-                    <i class="mdi mdi-pencil" aria-hidden="true"></i>
-                  </a>
-                {% endif %}
-                {% if perms.extras.delete_imageattachment %}
-                  <a href="{% url 'extras:imageattachment_delete' pk=attachment.pk %}" class="btn btn-danger btn-sm lh-1" title="Delete Image">
-                    <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i>
-                  </a>
-                {% endif %}
-              </td>
-            </tr>
-          {% endfor %}
-        </table>
-      {% else %}
-        <div class="text-muted">None</div>
-      {% endif %}
-    {% endwith %}
-  </div>
+  <div class="card-body htmx-container table-responsive"
+  hx-get="{% url 'extras:imageattachment_list' %}?content_type_id={{ object|content_type_id }}&object_id={{ object.pk }}"
+  hx-trigger="load"></div>
   {% if perms.extras.add_imageattachment %}
     <div class="card-footer text-end noprint">
       <a href="{% url 'extras:imageattachment_add' %}?content_type={{ object|content_type_id }}&object_id={{ object.pk }}" class="btn btn-primary btn-sm">

+ 0 - 56
netbox/templates/ipam/ipaddress_edit.html

@@ -56,11 +56,9 @@
       </div>
       <div class="tab-content p-0 border-0">
         <div class="tab-pane {% if not form.initial.vminterface and not form.initial.fhrpgroup %}active{% endif %}" id="device" role="tabpanel" aria-labeled-by="device_tab">
-          {% render_field form.device %}
           {% render_field form.interface %}
         </div>
         <div class="tab-pane {% if form.initial.vminterface %}active{% endif %}" id="vm" role="tabpanel" aria-labeled-by="vm_tab">
-          {% render_field form.virtual_machine %}
           {% render_field form.vminterface %}
         </div>
         <div class="tab-pane {% if form.initial.fhrpgroup %}active{% endif %}" id="fhrpgroup" role="tabpanel" aria-labeled-by="fhrpgroup_tab">
@@ -75,60 +73,6 @@
         <h5 class="offset-sm-3">NAT IP (Inside)</h5>
       </div>
       <div class="row mb-2">
-        <div class="offset-sm-3">
-          <ul class="nav nav-pills" role="tablist">
-            <li class="nav-item" role="presentation">
-                <button
-                    role="tab"
-                    type="button"
-                    id="device_tab"
-                    data-bs-toggle="tab"
-                    class="nav-link active"
-                    data-bs-target="#by_device"
-                    aria-controls="by_device"
-                >
-                    By Device
-                </button>
-            </li>
-            <li class="nav-item" role="presentation">
-                <button
-                    role="tab"
-                    type="button"
-                    id="vm_tab"
-                    data-bs-toggle="tab"
-                    class="nav-link"
-                    data-bs-target="#by_vm"
-                    aria-controls="by_vm"
-                >
-                    By VM
-                </button>
-            </li>
-            <li class="nav-item" role="presentation">
-                <button
-                    role="tab"
-                    type="button"
-                    id="vrf_tab"
-                    data-bs-toggle="tab"
-                    class="nav-link"
-                    data-bs-target="#by_vrf"
-                    aria-controls="by_vrf"
-                >
-                    By IP
-                </button>
-            </li>
-          </ul>
-        </div>
-      </div>
-      <div class="tab-content p-0 border-0">
-          <div class="tab-pane active" id="by_device" aria-labelledby="device_tab" role="tabpanel">
-              {% render_field form.nat_device %}
-          </div>
-          <div class="tab-pane" id="by_vm" aria-labelledby="vm_tab" role="tabpanel">
-              {% render_field form.nat_virtual_machine %}
-          </div>
-          <div class="tab-pane" id="by_vrf" aria-labelledby="vrf_tab" role="tabpanel">
-              {% render_field form.nat_vrf %}
-          </div>
           {% render_field form.nat_inside %}
       </div>
     </div>

+ 1 - 0
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')

+ 5 - 1
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

+ 10 - 10
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

Some files were not shown because too many files changed in this diff