فهرست منبع

Merge pull request #12507 from netbox-community/develop

Release v3.5.1
Jeremy Stretch 2 سال پیش
والد
کامیت
5f184f2435
53فایلهای تغییر یافته به همراه480 افزوده شده و 353 حذف شده
  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:
     attributes:
       label: NetBox version
       label: NetBox version
       description: What version of NetBox are you currently running?
       description: What version of NetBox are you currently running?
-      placeholder: v3.5.0
+      placeholder: v3.5.1
     validations:
     validations:
       required: true
       required: true
   - type: dropdown
   - type: dropdown

+ 1 - 1
.github/ISSUE_TEMPLATE/feature_request.yaml

@@ -14,7 +14,7 @@ body:
     attributes:
     attributes:
       label: NetBox version
       label: NetBox version
       description: What version of NetBox are you currently running?
       description: What version of NetBox are you currently running?
-      placeholder: v3.5.0
+      placeholder: v3.5.1
     validations:
     validations:
       required: true
       required: true
   - type: dropdown
   - 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)
 * 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)
 * 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
 ```shell
 sudo ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
 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
 !!! 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.
     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)
 * `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)
 * `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)
 * `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:
 Example:
 
 
 ```python
 ```python
 DATABASE = {
 DATABASE = {
+    'ENGINE': 'django.db.backends.postgresql',
     'NAME': 'netbox',               # Database name
     'NAME': 'netbox',               # Database name
     'USER': 'netbox',               # PostgreSQL username
     'USER': 'netbox',               # PostgreSQL username
     'PASSWORD': 'J5brHrAXFLQSif0K', # PostgreSQL password
     'PASSWORD': 'J5brHrAXFLQSif0K', # PostgreSQL password
@@ -50,6 +52,9 @@ DATABASE = {
 !!! note
 !!! 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).
     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
 ## 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 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:
 For example, you might define a link like this:
 
 
 * Text: `View NMS`
 * 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:
 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
 For example, if you only want to display a link for active devices, you could set the link text to
 
 
 ```jinja2
 ```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."
 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:
 As another example, if you wanted to show only devices belonging to a certain manufacturer, you could do something like this:
 
 
 ```jinja2
 ```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."
 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.Circuit](../models/circuits/circuit.md)
 * [circuits.Provider](../models/circuits/provider.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)
 * [circuits.ProviderNetwork](../models/circuits/providernetwork.md)
 * [core.DataSource](../models/core/datasource.md)
 * [core.DataSource](../models/core/datasource.md)
 * [dcim.Cable](../models/dcim/cable.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.
 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.
 The assignment of platforms to devices is an optional feature, and may be disregarded if not desired.
 
 
 ## Fields
 ## Fields

+ 42 - 0
docs/release-notes/version-3.5.md

@@ -1,5 +1,47 @@
 # NetBox v3.5
 # 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)
 ## v3.5.0 (2023-04-27)
 
 
 ### Breaking Changes
 ### Breaking Changes

+ 1 - 1
netbox/circuits/api/serializers.py

@@ -106,7 +106,7 @@ class CircuitCircuitTerminationSerializer(WritableNestedSerializer):
 class CircuitSerializer(NetBoxModelSerializer):
 class CircuitSerializer(NetBoxModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuit-detail')
     url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuit-detail')
     provider = NestedProviderSerializer()
     provider = NestedProviderSerializer()
-    provider_account = NestedProviderAccountSerializer()
+    provider_account = NestedProviderAccountSerializer(required=False, allow_null=True)
     status = ChoiceField(choices=CircuitStatusChoices, required=False)
     status = ChoiceField(choices=CircuitStatusChoices, required=False)
     type = NestedCircuitTypeSerializer()
     type = NestedCircuitTypeSerializer()
     tenant = NestedTenantSerializer(required=False, allow_null=True)
     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(
     provider_account = CSVModelChoiceField(
         queryset=ProviderAccount.objects.all(),
         queryset=ProviderAccount.objects.all(),
         to_field_name='name',
         to_field_name='name',
-        help_text=_('Assigned provider account')
+        help_text=_('Assigned provider account'),
+        required=False
     )
     )
     type = CSVModelChoiceField(
     type = CSVModelChoiceField(
         queryset=CircuitType.objects.all(),
         queryset=CircuitType.objects.all(),

+ 2 - 13
netbox/core/api/schema.py

@@ -1,23 +1,12 @@
 import re
 import re
 import typing
 import typing
 
 
-from drf_spectacular.extensions import (
-    OpenApiSerializerFieldExtension,
-    OpenApiViewExtension,
-)
+from drf_spectacular.extensions import OpenApiSerializerFieldExtension
 from drf_spectacular.openapi import AutoSchema
 from drf_spectacular.openapi import AutoSchema
 from drf_spectacular.plumbing import (
 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.types import OpenApiTypes
-from drf_spectacular.utils import extend_schema
 from rest_framework.relations import ManyRelatedField
 from rest_framework.relations import ManyRelatedField
 
 
 from netbox.api.fields import ChoiceField, SerializedPKRelatedField
 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.conf import settings
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
 from dulwich import porcelain
 from dulwich import porcelain
-from dulwich.config import StackedConfig
+from dulwich.config import ConfigDict
 
 
 from netbox.registry import registry
 from netbox.registry import registry
 from .choices import DataSourceTypeChoices
 from .choices import DataSourceTypeChoices
@@ -31,6 +31,7 @@ def register_backend(name):
     """
     """
     Decorator for registering a DataBackend class.
     Decorator for registering a DataBackend class.
     """
     """
+
     def _wrapper(cls):
     def _wrapper(cls):
         registry['data_backends'][name] = cls
         registry['data_backends'][name] = cls
         return cls
         return cls
@@ -56,7 +57,6 @@ class DataBackend:
 
 
 @register_backend(DataSourceTypeChoices.LOCAL)
 @register_backend(DataSourceTypeChoices.LOCAL)
 class LocalBackend(DataBackend):
 class LocalBackend(DataBackend):
-
     @contextmanager
     @contextmanager
     def fetch(self):
     def fetch(self):
         logger.debug(f"Data source type is local; skipping fetch")
         logger.debug(f"Data source type is local; skipping fetch")
@@ -71,12 +71,14 @@ class GitBackend(DataBackend):
         'username': forms.CharField(
         'username': forms.CharField(
             required=False,
             required=False,
             label=_('Username'),
             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(
         'password': forms.CharField(
             required=False,
             required=False,
             label=_('Password'),
             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(
         'branch': forms.CharField(
             required=False,
             required=False,
@@ -89,10 +91,22 @@ class GitBackend(DataBackend):
     def fetch(self):
     def fetch(self):
         local_path = tempfile.TemporaryDirectory()
         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 settings.HTTP_PROXIES and self.url_scheme in ('http', 'https'):
             if proxy := settings.HTTP_PROXIES.get(self.url_scheme):
             if proxy := settings.HTTP_PROXIES.get(self.url_scheme):
@@ -100,10 +114,7 @@ class GitBackend(DataBackend):
 
 
         logger.debug(f"Cloning git repo: {self.url}")
         logger.debug(f"Cloning git repo: {self.url}")
         try:
         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:
         except BaseException as e:
             raise SyncError(f"Fetching remote data failed ({type(e).__name__}): {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_ipaddresses = serializers.IntegerField(read_only=True)
     count_fhrp_groups = 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)
     wwn = serializers.CharField(required=False, default=None)
 
 
     class Meta:
     class Meta:

+ 1 - 0
netbox/dcim/filtersets.py

@@ -1900,6 +1900,7 @@ class PowerFeedFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet, PathEndpoi
             return queryset
             return queryset
         qs_filter = (
         qs_filter = (
             Q(name__icontains=value) |
             Q(name__icontains=value) |
+            Q(power_panel__name__icontains=value) |
             Q(comments__icontains=value)
             Q(comments__icontains=value)
         )
         )
         return queryset.filter(qs_filter)
         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 import BulkEditForm, add_blank_choice, form_from_model
 from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField
 from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField
 from utilities.forms.widgets import BulkEditNullBooleanSelect, NumberWithOptions
 from utilities.forms.widgets import BulkEditNullBooleanSelect, NumberWithOptions
+from wireless.models import WirelessLAN, WirelessLANGroup
 
 
 __all__ = (
 __all__ = (
     'CableBulkEditForm',
     'CableBulkEditForm',
@@ -1139,7 +1140,7 @@ class InterfaceBulkEditForm(
     form_from_model(Interface, [
     form_from_model(Interface, [
         'label', 'type', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'mtu', 'mgmt_only',
         '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',
         'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width',
-        'tx_power',
+        'tx_power', 'wireless_lans'
     ]),
     ]),
     ComponentBulkEditForm
     ComponentBulkEditForm
 ):
 ):
@@ -1229,6 +1230,19 @@ class InterfaceBulkEditForm(
         required=False,
         required=False,
         label=_('VRF')
         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
     model = Interface
     fieldsets = (
     fieldsets = (
@@ -1238,12 +1252,14 @@ class InterfaceBulkEditForm(
         ('PoE', ('poe_mode', 'poe_type')),
         ('PoE', ('poe_mode', 'poe_type')),
         ('Related Interfaces', ('parent', 'bridge', 'lag')),
         ('Related Interfaces', ('parent', 'bridge', 'lag')),
         ('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')),
         ('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 = (
     nullable_fields = (
         'module', 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'vdcs', 'mtu', 'description',
         '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',
         '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):
     def __init__(self, *args, **kwargs):

+ 9 - 0
netbox/dcim/forms/filtersets.py

@@ -298,6 +298,15 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
 
 
 
 
 class RackElevationFilterForm(RackFilterForm):
 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(
     id = DynamicModelMultipleChoiceField(
         queryset=Rack.objects.all(),
         queryset=Rack.objects.all(),
         label=_('Rack'),
         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 dcim.models import *
 from netbox.forms import NetBoxModelForm
 from netbox.forms import NetBoxModelForm
 from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField
 from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField
+from utilities.forms.widgets import APISelect
 from . import model_forms
 from . import model_forms
 
 
 __all__ = (
 __all__ = (
@@ -225,6 +226,18 @@ class InterfaceCreateForm(ComponentCreateForm, model_forms.InterfaceForm):
 
 
 
 
 class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm):
 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(
     rear_port = forms.MultipleChoiceField(
         choices=[],
         choices=[],
         label=_('Rear ports'),
         label=_('Rear ports'),
@@ -244,9 +257,10 @@ class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm):
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
         super().__init__(*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
         # Determine which rear port positions are occupied. These will be excluded from the list of available
         # mappings.
         # mappings.

+ 22 - 9
netbox/dcim/svg/racks.py

@@ -37,15 +37,28 @@ def get_device_name(device):
 
 
 
 
 def get_device_description(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:
 class RackElevationSVG:

+ 12 - 5
netbox/dcim/tables/devices.py

@@ -39,6 +39,10 @@ __all__ = (
     'VirtualDeviceContextTable'
     'VirtualDeviceContextTable'
 )
 )
 
 
+MODULEBAY_STATUS = """
+{% badge record.installed_module.get_status_display bg_color=record.installed_module.get_status_color %}
+"""
+
 
 
 def get_cabletermination_row_class(record):
 def get_cabletermination_row_class(record):
     if record.mark_connected:
     if record.mark_connected:
@@ -781,14 +785,17 @@ class ModuleBayTable(DeviceComponentTable):
     tags = columns.TagColumn(
     tags = columns.TagColumn(
         url_name='dcim:modulebay_list'
         url_name='dcim:modulebay_list'
     )
     )
+    module_status = columns.TemplateColumn(
+        template_code=MODULEBAY_STATUS
+    )
 
 
     class Meta(DeviceComponentTable.Meta):
     class Meta(DeviceComponentTable.Meta):
         model = models.ModuleBay
         model = models.ModuleBay
         fields = (
         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):
 class DeviceModuleBayTable(ModuleBayTable):
@@ -799,10 +806,10 @@ class DeviceModuleBayTable(ModuleBayTable):
     class Meta(DeviceComponentTable.Meta):
     class Meta(DeviceComponentTable.Meta):
         model = models.ModuleBay
         model = models.ModuleBay
         fields = (
         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',
             'description', 'tags', 'actions',
         )
         )
-        default_columns = ('pk', 'name', 'label', 'installed_module', 'description')
+        default_columns = ('pk', 'name', 'label', 'installed_module', 'module_status', 'description')
 
 
 
 
 class InventoryItemTable(DeviceComponentTable):
 class InventoryItemTable(DeviceComponentTable):

+ 1 - 1
netbox/dcim/views.py

@@ -371,7 +371,7 @@ class SiteView(generic.ObjectView):
             (VLANGroup.objects.restrict(request.user, 'view').filter(
             (VLANGroup.objects.restrict(request.user, 'view').filter(
                 scope_type=ContentType.objects.get_for_model(Site),
                 scope_type=ContentType.objects.get_for_model(Site),
                 scope_id=instance.pk
                 scope_id=instance.pk
-            ), 'site_id'),
+            ), 'site'),
             (VLAN.objects.restrict(request.user, 'view').filter(site=instance), 'site_id'),
             (VLAN.objects.restrict(request.user, 'view').filter(site=instance), 'site_id'),
             # Circuits
             # Circuits
             (Circuit.objects.restrict(request.user, 'view').filter(terminations__site=instance).distinct(), 'site_id'),
             (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.
         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 = {
         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
                 status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
             ).order_by('name', '-created').distinct('name').defer('data')
             ).order_by('name', '-created').distinct('name').defer('data')
         }
         }
@@ -202,7 +201,7 @@ class ReportViewSet(ViewSet):
 
 
         # Attach Job objects to each report (if any)
         # Attach Job objects to each report (if any)
         for report in report_list:
         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={
         serializer = serializers.ReportSerializer(report_list, many=True, context={
             'request': request,
             'request': request,
@@ -290,12 +289,10 @@ class ScriptViewSet(ViewSet):
         return module, script
         return module, script
 
 
     def list(self, request):
     def list(self, request):
-
-        script_content_type = ContentType.objects.get(app_label='extras', model='script')
         results = {
         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
                 status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
             ).order_by('name', '-created').distinct('name').defer('data')
             ).order_by('name', '-created').distinct('name').defer('data')
         }
         }
@@ -306,7 +303,7 @@ class ScriptViewSet(ViewSet):
 
 
         # Attach Job objects to each script (if any)
         # Attach Job objects to each script (if any)
         for script in script_list:
         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})
         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
 from urllib.parse import urlencode
 
 
 import feedparser
 import feedparser
+import requests
 from django import forms
 from django import forms
 from django.conf import settings
 from django.conf import settings
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
 from django.core.cache import cache
 from django.core.cache import cache
+from django.db.models import Q
 from django.template.loader import render_to_string
 from django.template.loader import render_to_string
 from django.urls import NoReverseMatch, reverse
 from django.urls import NoReverseMatch, reverse
 from django.utils.translation import gettext as _
 from django.utils.translation import gettext as _
@@ -33,7 +35,7 @@ def get_content_type_labels():
     return [
     return [
         (content_type_identifier(ct), content_type_name(ct))
         (content_type_identifier(ct), content_type_name(ct))
         for ct in ContentType.objects.filter(
         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')
         ).order_by('app_label', 'model')
     ]
     ]
 
 
@@ -227,7 +229,11 @@ class ObjectListWidget(DashboardWidget):
             htmx_url = reverse(viewname)
             htmx_url = reverse(viewname)
         except NoReverseMatch:
         except NoReverseMatch:
             htmx_url = None
             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:
             try:
                 htmx_url = f'{htmx_url}?{urlencode(parameters, doseq=True)}'
                 htmx_url = f'{htmx_url}?{urlencode(parameters, doseq=True)}'
             except ValueError:
             except ValueError:
@@ -236,7 +242,6 @@ class ObjectListWidget(DashboardWidget):
             'viewname': viewname,
             'viewname': viewname,
             'has_permission': has_permission,
             'has_permission': has_permission,
             'htmx_url': htmx_url,
             'htmx_url': htmx_url,
-            'page_size': self.config.get('page_size'),
         })
         })
 
 
 
 
@@ -268,12 +273,9 @@ class RSSFeedWidget(DashboardWidget):
         )
         )
 
 
     def render(self, request):
     def render(self, request):
-        url = self.config['feed_url']
-        feed = self.get_feed()
-
         return render_to_string(self.template_name, {
         return render_to_string(self.template_name, {
-            'url': url,
-            'feed': feed,
+            'url': self.config['feed_url'],
+            **self.get_feed()
         })
         })
 
 
     @cached_property
     @cached_property
@@ -285,17 +287,33 @@ class RSSFeedWidget(DashboardWidget):
     def get_feed(self):
     def get_feed(self):
         # Fetch RSS content from cache if available
         # Fetch RSS content from cache if available
         if feed_content := cache.get(self.cache_key):
         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.safestring import mark_safe
 from django.utils.translation import gettext as _
 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.models import *
 from extras.utils import FeatureQuery
 from extras.utils import FeatureQuery
+from netbox.forms import NetBoxModelImportForm
 from utilities.forms import CSVModelForm
 from utilities.forms import CSVModelForm
 from utilities.forms.fields import CSVChoiceField, CSVContentTypeField, CSVMultipleContentTypeField, SlugField
 from utilities.forms.fields import CSVChoiceField, CSVContentTypeField, CSVMultipleContentTypeField, SlugField
 
 
@@ -15,6 +16,7 @@ __all__ = (
     'CustomFieldImportForm',
     'CustomFieldImportForm',
     'CustomLinkImportForm',
     'CustomLinkImportForm',
     'ExportTemplateImportForm',
     'ExportTemplateImportForm',
+    'JournalEntryImportForm',
     'SavedFilterImportForm',
     'SavedFilterImportForm',
     'TagImportForm',
     'TagImportForm',
     'WebhookImportForm',
     'WebhookImportForm',
@@ -132,3 +134,20 @@ class TagImportForm(CSVModelForm):
         help_texts = {
         help_texts = {
             'color': mark_safe(_('RGB color in hexadecimal (e.g. <code>00ff00</code>)')),
             '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 netbox.forms.base import NetBoxModelFilterSetForm
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
 from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
 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 utilities.forms.widgets import APISelectMultiple, DateTimePicker
 from virtualization.models import Cluster, ClusterGroup, ClusterType
 from virtualization.models import Cluster, ClusterGroup, ClusterType
 from .mixins import SavedFiltersMixin
 from .mixins import SavedFiltersMixin
@@ -22,6 +22,7 @@ __all__ = (
     'CustomFieldFilterForm',
     'CustomFieldFilterForm',
     'CustomLinkFilterForm',
     'CustomLinkFilterForm',
     'ExportTemplateFilterForm',
     'ExportTemplateFilterForm',
+    'ImageAttachmentFilterForm',
     'JournalEntryFilterForm',
     'JournalEntryFilterForm',
     'LocalConfigContextFilterForm',
     'LocalConfigContextFilterForm',
     'ObjectChangeFilterForm',
     '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):
 class SavedFilterFilterForm(SavedFiltersMixin, FilterForm):
     fieldsets = (
     fieldsets = (
         (None, ('q', 'filter_id')),
         (None, ('q', 'filter_id')),

+ 1 - 1
netbox/extras/management/commands/runscript.py

@@ -111,7 +111,7 @@ class Command(BaseCommand):
 
 
         # Create the job
         # Create the job
         job = Job.objects.create(
         job = Job.objects.create(
-            instance=module,
+            object=module,
             name=script.name,
             name=script.name,
             user=User.objects.filter(is_superuser=True).order_by('pk')[0],
             user=User.objects.filter(is_superuser=True).order_by('pk')[0],
             job_id=uuid.uuid4()
             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)}"
                         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:
         elif self.required:
             raise ValidationError("Required field cannot be empty.")
             raise ValidationError("Required field cannot be empty.")

+ 9 - 1
netbox/extras/models/scripts.py

@@ -1,4 +1,5 @@
 import inspect
 import inspect
+import logging
 from functools import cached_property
 from functools import cached_property
 
 
 from django.db import models
 from django.db import models
@@ -16,6 +17,8 @@ __all__ = (
     'ScriptModule',
     'ScriptModule',
 )
 )
 
 
+logger = logging.getLogger('netbox.data_backends')
+
 
 
 class Script(WebhooksMixin, models.Model):
 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
             # 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]
             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 = {}
         scripts = {}
         ordered = getattr(module, 'script_order', [])
         ordered = getattr(module, 'script_order', [])
 
 

+ 24 - 0
netbox/extras/tables/tables.py

@@ -13,6 +13,7 @@ __all__ = (
     'CustomFieldTable',
     'CustomFieldTable',
     'CustomLinkTable',
     'CustomLinkTable',
     'ExportTemplateTable',
     'ExportTemplateTable',
+    'ImageAttachmentTable',
     'JournalEntryTable',
     'JournalEntryTable',
     'ObjectChangeTable',
     'ObjectChangeTable',
     'SavedFilterTable',
     'SavedFilterTable',
@@ -29,6 +30,7 @@ class CustomFieldTable(NetBoxTable):
     content_types = columns.ContentTypesColumn()
     content_types = columns.ContentTypesColumn()
     required = columns.BooleanColumn()
     required = columns.BooleanColumn()
     ui_visibility = columns.ChoiceFieldColumn(verbose_name="UI visibility")
     ui_visibility = columns.ChoiceFieldColumn(verbose_name="UI visibility")
+    description = columns.MarkdownColumn()
     is_cloneable = columns.BooleanColumn()
     is_cloneable = columns.BooleanColumn()
 
 
     class Meta(NetBoxTable.Meta):
     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):
 class SavedFilterTable(NetBoxTable):
     name = tables.Column(
     name = tables.Column(
         linkify=True
         linkify=True

+ 2 - 0
netbox/extras/urls.py

@@ -73,6 +73,7 @@ urlpatterns = [
     path('config-templates/<int:pk>/', include(get_model_urls('extras', 'configtemplate'))),
     path('config-templates/<int:pk>/', include(get_model_urls('extras', 'configtemplate'))),
 
 
     # Image attachments
     # 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/add/', views.ImageAttachmentEditView.as_view(), name='imageattachment_add'),
     path('image-attachments/<int:pk>/', include(get_model_urls('extras', 'imageattachment'))),
     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/add/', views.JournalEntryEditView.as_view(), name='journalentry_add'),
     path('journal-entries/edit/', views.JournalEntryBulkEditView.as_view(), name='journalentry_bulk_edit'),
     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/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'))),
     path('journal-entries/<int:pk>/', include(get_model_urls('extras', 'journalentry'))),
 
 
     # Change logging
     # Change logging

+ 14 - 2
netbox/extras/views.py

@@ -577,6 +577,14 @@ class ObjectChangeView(generic.ObjectView):
 # Image attachments
 # 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')
 @register_model_view(ImageAttachment, 'edit')
 class ImageAttachmentEditView(generic.ObjectEditView):
 class ImageAttachmentEditView(generic.ObjectEditView):
     queryset = ImageAttachment.objects.all()
     queryset = ImageAttachment.objects.all()
@@ -617,7 +625,7 @@ class JournalEntryListView(generic.ObjectListView):
     filterset = filtersets.JournalEntryFilterSet
     filterset = filtersets.JournalEntryFilterSet
     filterset_form = forms.JournalEntryFilterForm
     filterset_form = forms.JournalEntryFilterForm
     table = tables.JournalEntryTable
     table = tables.JournalEntryTable
-    actions = ('export', 'bulk_edit', 'bulk_delete')
+    actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
 
 
 
 
 @register_model_view(JournalEntry)
 @register_model_view(JournalEntry)
@@ -666,6 +674,11 @@ class JournalEntryBulkDeleteView(generic.BulkDeleteView):
     table = tables.JournalEntryTable
     table = tables.JournalEntryTable
 
 
 
 
+class JournalEntryBulkImportView(generic.BulkImportView):
+    queryset = JournalEntry.objects.all()
+    model_form = forms.JournalEntryImportForm
+
+
 #
 #
 # Dashboard & widgets
 # Dashboard & widgets
 #
 #
@@ -1033,7 +1046,6 @@ class ScriptView(ContentTypePermissionRequiredMixin, View):
         return 'extras.view_script'
         return 'extras.view_script'
 
 
     def get(self, request, module, name):
     def get(self, request, module, name):
-        print(module)
         module = get_object_or_404(ScriptModule.objects.restrict(request.user), file_path__startswith=module)
         module = get_object_or_404(ScriptModule.objects.restrict(request.user), file_path__startswith=module)
         script = module.scripts[name]()
         script = module.scripts[name]()
         form = script.as_form(initial=normalize_querydict(request.GET))
         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):
 class IPAddressForm(TenancyForm, NetBoxModelForm):
-    device = DynamicModelChoiceField(
-        queryset=Device.objects.all(),
-        required=False,
-        initial_params={
-            'interfaces': '$interface'
-        }
-    )
     interface = DynamicModelChoiceField(
     interface = DynamicModelChoiceField(
         queryset=Interface.objects.all(),
         queryset=Interface.objects.all(),
         required=False,
         required=False,
-        query_params={
-            'device_id': '$device'
-        }
-    )
-    virtual_machine = DynamicModelChoiceField(
-        queryset=VirtualMachine.objects.all(),
-        required=False,
-        initial_params={
-            'interfaces': '$vminterface'
-        }
+        selector=True,
     )
     )
     vminterface = DynamicModelChoiceField(
     vminterface = DynamicModelChoiceField(
         queryset=VMInterface.objects.all(),
         queryset=VMInterface.objects.all(),
         required=False,
         required=False,
+        selector=True,
         label=_('Interface'),
         label=_('Interface'),
-        query_params={
-            'virtual_machine_id': '$virtual_machine'
-        }
     )
     )
     fhrpgroup = DynamicModelChoiceField(
     fhrpgroup = DynamicModelChoiceField(
         queryset=FHRPGroup.objects.all(),
         queryset=FHRPGroup.objects.all(),
         required=False,
         required=False,
+        selector=True,
         label=_('FHRP Group')
         label=_('FHRP Group')
     )
     )
     vrf = DynamicModelChoiceField(
     vrf = DynamicModelChoiceField(
@@ -301,33 +284,11 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
         required=False,
         required=False,
         label=_('VRF')
         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(
     nat_inside = DynamicModelChoiceField(
         queryset=IPAddress.objects.all(),
         queryset=IPAddress.objects.all(),
         required=False,
         required=False,
+        selector=True,
         label=_('IP Address'),
         label=_('IP Address'),
-        query_params={
-            'device_id': '$nat_device',
-            'virtual_machine_id': '$nat_virtual_machine',
-            'vrf_id': '$nat_vrf',
-        }
     )
     )
     primary_for_parent = forms.BooleanField(
     primary_for_parent = forms.BooleanField(
         required=False,
         required=False,
@@ -338,8 +299,8 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
     class Meta:
     class Meta:
         model = IPAddress
         model = IPAddress
         fields = [
         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):
     def __init__(self, *args, **kwargs):
@@ -354,17 +315,6 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
                 initial['vminterface'] = instance.assigned_object
                 initial['vminterface'] = instance.assigned_object
             elif type(instance.assigned_object) is FHRPGroup:
             elif type(instance.assigned_object) is FHRPGroup:
                 initial['fhrpgroup'] = instance.assigned_object
                 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
         kwargs['initial'] = initial
 
 
         super().__init__(*args, **kwargs)
         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.filtersets import VMInterfaceFilterSet
 from virtualization.models import VMInterface
 from virtualization.models import VMInterface
 from . import filtersets, forms, tables
 from . import filtersets, forms, tables
+from .choices import PrefixStatusChoices
 from .constants import *
 from .constants import *
 from .models import *
 from .models import *
 from .tables.l2vpn import L2VPNTable, L2VPNTerminationTable
 from .tables.l2vpn import L2VPNTable, L2VPNTerminationTable
@@ -495,7 +496,7 @@ class PrefixView(generic.ObjectView):
 
 
         # Parent prefixes table
         # Parent prefixes table
         parent_prefixes = Prefix.objects.restrict(request.user, 'view').filter(
         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(
         ).filter(
             prefix__net_contains=str(instance.prefix)
             prefix__net_contains=str(instance.prefix)
         ).prefetch_related(
         ).prefetch_related(

+ 1 - 23
netbox/netbox/api/serializers/features.py

@@ -14,35 +14,13 @@ __all__ = (
 
 
 class CustomFieldModelSerializer(serializers.Serializer):
 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(
     custom_fields = CustomFieldsDataField(
         source='custom_field_data',
         source='custom_field_data',
         default=CreateOnlyDefault(CustomFieldDefaultValues())
         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):
 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:
 # PostgreSQL database configuration. See the Django documentation for a complete list of available parameters:
 #   https://docs.djangoproject.com/en/stable/ref/settings/#databases
 #   https://docs.djangoproject.com/en/stable/ref/settings/#databases
 DATABASE = {
 DATABASE = {
+    'ENGINE': 'django.db.backends.postgresql',  # Database engine
     'NAME': 'netbox',         # Database name
     'NAME': 'netbox',         # Database name
     'USER': '',               # PostgreSQL username
     'USER': '',               # PostgreSQL username
     'PASSWORD': '',           # PostgreSQL password
     '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():
         for field in self._meta.get_fields():
             if isinstance(field, GenericForeignKey):
             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:
                 if ct_value is None and fk_value is not None:
                     raise ValidationError({
                     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', 'exporttemplate', _('Export Templates')),
                 get_model_item('extras', 'savedfilter', _('Saved Filters')),
                 get_model_item('extras', 'savedfilter', _('Saved Filters')),
                 get_model_item('extras', 'tag', 'Tags'),
                 get_model_item('extras', 'tag', 'Tags'),
+                get_model_item('extras', 'imageattachment', _('Image Attachments'), actions=()),
             ),
             ),
         ),
         ),
         MenuGroup(
         MenuGroup(
@@ -336,7 +337,7 @@ OPERATIONS_MENU = Menu(
         MenuGroup(
         MenuGroup(
             label=_('Logging'),
             label=_('Logging'),
             items=(
             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=[]),
                 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
 # Environment setup
 #
 #
 
 
-VERSION = '3.5.0'
+VERSION = '3.5.1'
 
 
 # Hostname
 # Hostname
 HOSTNAME = platform.node()
 HOSTNAME = platform.node()
@@ -182,15 +182,16 @@ if RELEASE_CHECK_URL:
 # Database
 # 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 = {
 DATABASES = {
     'default': DATABASE,
     'default': DATABASE,
@@ -616,13 +617,15 @@ REST_FRAMEWORK = {
 #
 #
 
 
 SPECTACULAR_SETTINGS = {
 SPECTACULAR_SETTINGS = {
-    'TITLE': 'NetBox API',
-    'DESCRIPTION': 'API to access NetBox',
+    'TITLE': 'NetBox REST API',
     'LICENSE': {'name': 'Apache v2 License'},
     'LICENSE': {'name': 'Apache v2 License'},
     'VERSION': VERSION,
     'VERSION': VERSION,
     'COMPONENT_SPLIT_REQUEST': True,
     'COMPONENT_SPLIT_REQUEST': True,
     'REDOC_DIST': 'SIDECAR',
     'REDOC_DIST': 'SIDECAR',
-    'SERVERS': [{'url': f'/{BASE_PATH}api'}],
+    'SERVERS': [{
+        'url': BASE_PATH,
+        'description': 'NetBox',
+    }],
     'SWAGGER_UI_DIST': 'SIDECAR',
     'SWAGGER_UI_DIST': 'SIDECAR',
     'SWAGGER_UI_FAVICON_HREF': 'SIDECAR',
     'SWAGGER_UI_FAVICON_HREF': 'SIDECAR',
     'POSTPROCESSING_HOOKS': [],
     'POSTPROCESSING_HOOKS': [],

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
netbox/project-static/dist/netbox-dark.css


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
netbox/project-static/dist/netbox-light.css


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
netbox/project-static/dist/netbox-print.css


+ 4 - 0
netbox/project-static/styles/netbox.scss

@@ -231,6 +231,10 @@ table {
 
 
     p {
     p {
       // Remove spacing from paragraph elements within tables.
       // Remove spacing from paragraph elements within tables.
+      margin-bottom: 0.5em;
+    }
+
+    p:last-child {
       margin-bottom: 0;
       margin-bottom: 0;
     }
     }
   }
   }

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

@@ -29,17 +29,6 @@
                         {% endfor %}
                         {% endfor %}
                       </td>
                       </td>
                     </tr>
                     </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>
                     <tr>
                         <th scope="row">Description</th>
                         <th scope="row">Description</th>
                         <td>{{ object.description|placeholder }}</td>
                         <td>{{ object.description|placeholder }}</td>

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

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

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

@@ -1,5 +1,5 @@
 {% if htmx_url and has_permission %}
 {% 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 %}
 {% elif htmx_url %}
   <div class="text-muted text-center">
   <div class="text-muted text-center">
     <i class="mdi mdi-lock-outline"></i> No permission to view this content.
     <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">
   <div class="list-group list-group-flush">
     {% for entry in feed.entries %}
     {% for entry in feed.entries %}
       <div class="list-group-item px-1">
       <div class="list-group-item px-1">
@@ -16,7 +16,9 @@
   <span class="text-danger">
   <span class="text-danger">
     <i class="mdi mdi-alert"></i> There was a problem fetching the RSS feed:
     <i class="mdi mdi-alert"></i> There was a problem fetching the RSS feed:
   </span>
   </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 %}
 {% endif %}

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

@@ -37,43 +37,49 @@
         </h5>
         </h5>
         <div class="card-body">
         <div class="card-body">
           {% include 'inc/sync_warning.html' with object=module %}
           {% 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>
       </div>
       </div>
     {% empty %}
     {% empty %}

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

@@ -4,44 +4,9 @@
   <h5 class="card-header">
   <h5 class="card-header">
     Images
     Images
   </h5>
   </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 %}
   {% if perms.extras.add_imageattachment %}
     <div class="card-footer text-end noprint">
     <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">
       <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>
       <div class="tab-content p-0 border-0">
       <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">
         <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 %}
           {% render_field form.interface %}
         </div>
         </div>
         <div class="tab-pane {% if form.initial.vminterface %}active{% endif %}" id="vm" role="tabpanel" aria-labeled-by="vm_tab">
         <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 %}
           {% render_field form.vminterface %}
         </div>
         </div>
         <div class="tab-pane {% if form.initial.fhrpgroup %}active{% endif %}" id="fhrpgroup" role="tabpanel" aria-labeled-by="fhrpgroup_tab">
         <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>
         <h5 class="offset-sm-3">NAT IP (Inside)</h5>
       </div>
       </div>
       <div class="row mb-2">
       <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 %}
           {% render_field form.nat_inside %}
       </div>
       </div>
     </div>
     </div>

+ 1 - 0
netbox/tenancy/views.py

@@ -352,6 +352,7 @@ class ContactAssignmentListView(generic.ObjectListView):
     filterset = filtersets.ContactAssignmentFilterSet
     filterset = filtersets.ContactAssignmentFilterSet
     filterset_form = forms.ContactAssignmentFilterForm
     filterset_form = forms.ContactAssignmentFilterForm
     table = tables.ContactAssignmentTable
     table = tables.ContactAssignmentTable
+    actions = ('export', 'bulk_edit', 'bulk_delete')
 
 
 
 
 @register_model_view(ContactAssignment, 'edit')
 @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)
     l2vpn_termination = NestedL2VPNTerminationSerializer(read_only=True, allow_null=True)
     count_ipaddresses = serializers.IntegerField(read_only=True)
     count_ipaddresses = serializers.IntegerField(read_only=True)
     count_fhrp_groups = 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:
     class Meta:
         model = VMInterface
         model = VMInterface

+ 10 - 10
requirements.txt

@@ -1,35 +1,35 @@
 bleach==6.0.0
 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-cors-headers==3.14.0
 django-debug-toolbar==4.0.0
 django-debug-toolbar==4.0.0
-django-filter==23.1
+django-filter==23.2
 django-graphiql-debug-toolbar==0.2.0
 django-graphiql-debug-toolbar==0.2.0
 django-mptt==0.14
 django-mptt==0.14
 django-pglocks==1.0.4
 django-pglocks==1.0.4
-django-prometheus==2.2.0
+django-prometheus==2.3.1
 django-redis==5.2.0
 django-redis==5.2.0
 django-rich==1.5.0
 django-rich==1.5.0
-django-rq==2.7.0
+django-rq==2.8.0
 django-tables2==2.5.3
 django-tables2==2.5.3
-django-taggit==3.1.0
+django-taggit==4.0.0
 django-timezone-field==5.0
 django-timezone-field==5.0
 djangorestframework==3.14.0
 djangorestframework==3.14.0
 drf-spectacular==0.26.2
 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
 feedparser==6.0.10
 graphene-django==3.0.0
 graphene-django==3.0.0
 gunicorn==20.1.0
 gunicorn==20.1.0
 Jinja2==3.1.2
 Jinja2==3.1.2
 Markdown==3.3.7
 Markdown==3.3.7
-mkdocs-material==9.1.8
+mkdocs-material==9.1.9
 mkdocstrings[python-legacy]==0.21.2
 mkdocstrings[python-legacy]==0.21.2
 netaddr==0.8.0
 netaddr==0.8.0
 Pillow==9.5.0
 Pillow==9.5.0
 psycopg2-binary==2.9.6
 psycopg2-binary==2.9.6
 PyYAML==6.0
 PyYAML==6.0
-sentry-sdk==1.21.0
+sentry-sdk==1.22.1
 social-auth-app-django==5.2.0
 social-auth-app-django==5.2.0
 social-auth-core[openidconnect]==4.4.2
 social-auth-core[openidconnect]==4.4.2
 svgwrite==1.4.3
 svgwrite==1.4.3

برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است