Merge pull request #12507 from netbox-community/develop

Release v3.5.1
This commit is contained in:
Jeremy Stretch 2023-05-05 12:50:30 -04:00 committed by GitHub
commit 5f184f2435
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
53 changed files with 480 additions and 353 deletions

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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.

View File

@ -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

View File

@ -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."

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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(),

View File

@ -1,23 +1,12 @@
import re import re
import typing import typing
from drf_spectacular.extensions import ( from drf_spectacular.extensions import OpenApiSerializerFieldExtension
OpenApiSerializerFieldExtension,
OpenApiViewExtension,
)
from drf_spectacular.openapi import AutoSchema from drf_spectacular.openapi import AutoSchema
from drf_spectacular.plumbing import ( from drf_spectacular.plumbing import (
ComponentRegistry, build_basic_type, build_choice_field, build_media_type_object, build_object_type, get_doc,
ResolvedComponent,
build_basic_type,
build_choice_field,
build_media_type_object,
build_object_type,
get_doc,
is_serializer,
) )
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

View File

@ -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') config = ConfigDict()
password = self.params.get('password') clone_args = {
branch = self.params.get('branch') "branch": self.params.get('branch'),
config = StackedConfig.default() "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( porcelain.clone(self.url, local_path.name, **clone_args)
self.url, local_path.name, depth=1, branch=branch, username=username, password=password,
config=config, quiet=True, errstream=porcelain.NoneStream()
)
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}")

View File

@ -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:

View File

@ -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)

View File

@ -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):

View File

@ -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'),

View File

@ -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( if device_id := self.data.get('device') or self.initial.get('device'):
pk=self.initial.get('device') or self.data.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.

View File

@ -37,15 +37,28 @@ def get_device_name(device):
def get_device_description(device): def get_device_description(device):
return '{} ({}) — {} {} ({}U) {} {}'.format( """
device.name, Return a description for a device to be rendered in the rack elevation in the following format
device.device_role,
device.device_type.manufacturer.name, Name: <name>
device.device_type.model, Role: <device_role>
floatformat(device.device_type.u_height), Device Type: <manufacturer> <model> (<u_height>)
device.asset_tag or '', Asset tag: <asset_tag> (if defined)
device.serial or '' 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:

View File

@ -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', 'pk', 'id', 'name', 'device', 'label', 'position', 'installed_module', 'module_status', 'module_serial',
'description', 'tags', '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):

View File

@ -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'),

View File

@ -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 job.name: job
for r in Job.objects.filter( for job in Job.objects.filter(
object_type=report_content_type, 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 job.name: job
for r in Job.objects.filter( for job in Job.objects.filter(
object_type=script_content_type, 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})

View File

@ -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, 'url': self.config['feed_url'],
'feed': feed, **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) return {
else: 'feed': feedparser.FeedParserDict(feed_content),
feed = feedparser.parse( }
self.config['feed_url'],
request_headers={'User-Agent': f'NetBox/{settings.VERSION}'}
)
if not feed.bozo:
# Cap number of entries
max_entries = self.config.get('max_entries')
feed['entries'] = feed['entries'][:max_entries]
# Cache the feed content
cache.set(self.cache_key, dict(feed), self.config.get('cache_timeout'))
return feed # Fetch feed content from remote server
try:
response = requests.get(
url=self.config['feed_url'],
headers={'User-Agent': f'NetBox/{settings.VERSION}'},
proxies=settings.HTTP_PROXIES,
timeout=3
)
response.raise_for_status()
except requests.exceptions.RequestException as e:
return {
'error': e,
}
# Parse feed content
feed = feedparser.parse(response.content)
if not feed.bozo:
# Cap number of entries
max_entries = self.config.get('max_entries')
feed['entries'] = feed['entries'][:max_entries]
# Cache the feed content
cache.set(self.cache_key, dict(feed), self.config.get('cache_timeout'))
return {
'feed': feed,
}

View File

@ -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'
)

View File

@ -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')),

View File

@ -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()

View File

@ -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.")

View File

@ -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', [])

View File

@ -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

View File

@ -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

View File

@ -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))

View File

@ -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={ selector=True,
'device_id': '$device'
}
)
virtual_machine = DynamicModelChoiceField(
queryset=VirtualMachine.objects.all(),
required=False,
initial_params={
'interfaces': '$vminterface'
}
) )
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', 'address', 'vrf', 'status', 'role', 'dns_name', 'primary_for_parent', 'nat_inside', 'tenant_group',
'nat_vrf', 'nat_inside', 'tenant_group', 'tenant', 'description', 'comments', 'tags', '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)

View File

@ -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(

View File

@ -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 Introduces support for custom field assignment and representation.
that custom field data is populated upon initialization.
""" """
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):
""" """

View File

@ -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

View File

@ -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) ct_value = getattr(self, field.ct_field, None)
fk_value = getattr(self, field.fk_field) 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({

View File

@ -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=[]),
), ),
), ),

View File

@ -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 'ENGINE' not in DATABASE:
if METRICS_ENABLED: # Only PostgreSQL is supported
DATABASE.update({ if METRICS_ENABLED:
'ENGINE': 'django_prometheus.db.backends.postgresql' DATABASE.update({
}) 'ENGINE': 'django_prometheus.db.backends.postgresql'
else: })
DATABASE.update({ else:
'ENGINE': 'django.db.backends.postgresql' 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', 'TITLE': 'NetBox REST API',
'DESCRIPTION': 'API to access NetBox',
'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': [],

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -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;
} }
} }

View File

@ -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>

View File

@ -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>

View File

@ -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.

View File

@ -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"> {% if feed %}
Response status: {{ feed.status }} {{ feed.bozo_exception|escape }} (HTTP {{ feed.status }})
Error: {{ feed.bozo_exception|escape }}</pre> {% else %}
{{ error }}
{% endif %}
{% endif %} {% endif %}

View File

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

View File

@ -4,44 +4,9 @@
<h5 class="card-header"> <h5 class="card-header">
Images Images
</h5> </h5>
<div class="card-body"> <div class="card-body htmx-container table-responsive"
{% with images=object.images.all %} hx-get="{% url 'extras:imageattachment_list' %}?content_type_id={{ object|content_type_id }}&object_id={{ object.pk }}"
{% if images.exists %} hx-trigger="load"></div>
<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>
{% 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">

View File

@ -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>

View File

@ -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')

View File

@ -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

View File

@ -1,35 +1,35 @@
bleach==6.0.0 bleach==6.0.0
boto3==1.26.121 boto3==1.26.127
Django==4.1.8 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 drf-spectacular-sidecar==2023.5.1
dulwich==0.21.3 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