mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-23 04:22:01 -06:00
commit
5f184f2435
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@ -14,7 +14,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v3.5.0
|
||||
placeholder: v3.5.1
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
@ -14,7 +14,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v3.5.0
|
||||
placeholder: v3.5.1
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
17
contrib/netbox-housekeeping.service
Normal file
17
contrib/netbox-housekeeping.service
Normal 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
|
13
contrib/netbox-housekeeping.timer
Normal file
13
contrib/netbox-housekeeping.timer
Normal 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
|
@ -7,7 +7,13 @@ NetBox includes a `housekeeping` management command that should be run nightly.
|
||||
* Deleting job result records older than the configured [retention time](../configuration/miscellaneous.md#job_retention)
|
||||
* Check for new NetBox releases (if [`RELEASE_CHECK_URL`](../configuration/miscellaneous.md#release_check_url) is set)
|
||||
|
||||
This command can be invoked directly, or by using the shell script provided at `/opt/netbox/contrib/netbox-housekeeping.sh`. This script can be linked from your cron scheduler's daily jobs directory (e.g. `/etc/cron.daily`) or referenced directly within the cron configuration file.
|
||||
This command can be invoked directly, or by using the shell script provided at `/opt/netbox/contrib/netbox-housekeeping.sh`.
|
||||
|
||||
## Scheduling
|
||||
|
||||
### Using Cron
|
||||
|
||||
This script can be linked from your cron scheduler's daily jobs directory (e.g. `/etc/cron.daily`) or referenced directly within the cron configuration file.
|
||||
|
||||
```shell
|
||||
sudo ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
|
||||
@ -16,4 +22,28 @@ sudo ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-hou
|
||||
!!! note
|
||||
On Debian-based systems, be sure to omit the `.sh` file extension when linking to the script from within a cron directory. Otherwise, the task may not run.
|
||||
|
||||
The `housekeeping` command can also be run manually at any time: Running the command outside scheduled execution times will not interfere with its operation.
|
||||
### Using Systemd
|
||||
|
||||
First, create symbolic links for the systemd service and timer files. Link the existing service and timer files from the `/opt/netbox/contrib/` directory to the `/etc/systemd/system/` directory:
|
||||
|
||||
```bash
|
||||
sudo ln -s /opt/netbox/contrib/netbox-housekeeping.service /etc/systemd/system/netbox-housekeeping.service
|
||||
sudo ln -s /opt/netbox/contrib/netbox-housekeeping.timer /etc/systemd/system/netbox-housekeeping.timer
|
||||
```
|
||||
|
||||
Then, reload the systemd configuration and enable the timer to start automatically at boot:
|
||||
|
||||
```bash
|
||||
sudo systemctl daemon-reload
|
||||
sudo systemctl enable --now netbox-housekeeping.timer
|
||||
```
|
||||
|
||||
Check the status of your timer by running:
|
||||
|
||||
```bash
|
||||
sudo systemctl list-timers --all
|
||||
```
|
||||
|
||||
This command will show a list of all timers, including your `netbox-housekeeping.timer`. Make sure the timer is active and properly scheduled.
|
||||
|
||||
That's it! Your NetBox housekeeping service is now configured to run daily using systemd.
|
||||
|
@ -33,11 +33,13 @@ NetBox requires access to a PostgreSQL 11 or later database service to store dat
|
||||
* `HOST` - Name or IP address of the database server (use `localhost` if running locally)
|
||||
* `PORT` - TCP port of the PostgreSQL service; leave blank for default port (TCP/5432)
|
||||
* `CONN_MAX_AGE` - Lifetime of a [persistent database connection](https://docs.djangoproject.com/en/stable/ref/databases/#persistent-connections), in seconds (300 is the default)
|
||||
* `ENGINE` - The database backend to use; must be a PostgreSQL-compatible backend (e.g. `django.db.backends.postgresql`)
|
||||
|
||||
Example:
|
||||
|
||||
```python
|
||||
DATABASE = {
|
||||
'ENGINE': 'django.db.backends.postgresql',
|
||||
'NAME': 'netbox', # Database name
|
||||
'USER': 'netbox', # PostgreSQL username
|
||||
'PASSWORD': 'J5brHrAXFLQSif0K', # PostgreSQL password
|
||||
@ -50,6 +52,9 @@ DATABASE = {
|
||||
!!! note
|
||||
NetBox supports all PostgreSQL database options supported by the underlying Django framework. For a complete list of available parameters, please see [the Django documentation](https://docs.djangoproject.com/en/stable/ref/settings/#databases).
|
||||
|
||||
!!! warning
|
||||
Make sure to use a PostgreSQL-compatible backend for the ENGINE setting. If you don't specify an ENGINE, the default will be django.db.backends.postgresql.
|
||||
|
||||
---
|
||||
|
||||
## REDIS
|
||||
|
@ -2,12 +2,12 @@
|
||||
|
||||
Custom links allow users to display arbitrary hyperlinks to external content within NetBox object views. These are helpful for cross-referencing related records in systems outside NetBox. For example, you might create a custom link on the device view which links to the current device in a Network Monitoring System (NMS).
|
||||
|
||||
Custom links are created by navigating to Customization > Custom Links. Each link is associated with a particular NetBox object type (site, device, prefix, etc.) and will be displayed on relevant views. Each link has display text and a URL, and data from the NetBox item being viewed can be included in the link using [Jinja2 template code](https://jinja2docs.readthedocs.io/en/stable/) through the variable `obj`, and custom fields through `obj.cf`.
|
||||
Custom links are created by navigating to Customization > Custom Links. Each link is associated with a particular NetBox object type (site, device, prefix, etc.) and will be displayed on relevant views. Each link has display text and a URL, and data from the NetBox item being viewed can be included in the link using [Jinja2 template code](https://jinja2docs.readthedocs.io/en/stable/) through the variable `object`, and custom fields through `object.cf`.
|
||||
|
||||
For example, you might define a link like this:
|
||||
|
||||
* Text: `View NMS`
|
||||
* URL: `https://nms.example.com/nodes/?name={{ obj.name }}`
|
||||
* URL: `https://nms.example.com/nodes/?name={{ object.name }}`
|
||||
|
||||
When viewing a device named Router4, this link would render as:
|
||||
|
||||
@ -43,7 +43,7 @@ Only links which render with non-empty text are included on the page. You can em
|
||||
For example, if you only want to display a link for active devices, you could set the link text to
|
||||
|
||||
```jinja2
|
||||
{% if obj.status == 'active' %}View NMS{% endif %}
|
||||
{% if object.status == 'active' %}View NMS{% endif %}
|
||||
```
|
||||
|
||||
The link will not appear when viewing a device with any status other than "active."
|
||||
@ -51,7 +51,7 @@ The link will not appear when viewing a device with any status other than "activ
|
||||
As another example, if you wanted to show only devices belonging to a certain manufacturer, you could do something like this:
|
||||
|
||||
```jinja2
|
||||
{% if obj.device_type.manufacturer.name == 'Cisco' %}View NMS{% endif %}
|
||||
{% if object.device_type.manufacturer.name == 'Cisco' %}View NMS{% endif %}
|
||||
```
|
||||
|
||||
The link will only appear when viewing a device with a manufacturer name of "Cisco."
|
||||
|
@ -32,7 +32,7 @@ These are considered the "core" application models which are used to model netwo
|
||||
|
||||
* [circuits.Circuit](../models/circuits/circuit.md)
|
||||
* [circuits.Provider](../models/circuits/provider.md)
|
||||
* [circuits.ProviderAccount](../models/circuits/provideracount.md)
|
||||
* [circuits.ProviderAccount](../models/circuits/provideraccount.md)
|
||||
* [circuits.ProviderNetwork](../models/circuits/providernetwork.md)
|
||||
* [core.DataSource](../models/core/datasource.md)
|
||||
* [dcim.Cable](../models/dcim/cable.md)
|
||||
|
@ -4,8 +4,6 @@ A platform defines the type of software running on a [device](./device.md) or [v
|
||||
|
||||
Platforms may optionally be limited by [manufacturer](./manufacturer.md): If a platform is assigned to a particular manufacturer, it can only be assigned to devices with a type belonging to that manufacturer.
|
||||
|
||||
The platform model is also used to indicate which [NAPALM driver](../../integrations/napalm.md) (if any) and any associated arguments NetBox should use when connecting to a remote device. The name of the driver along with optional parameters are stored with the platform.
|
||||
|
||||
The assignment of platforms to devices is an optional feature, and may be disregarded if not desired.
|
||||
|
||||
## Fields
|
||||
|
@ -1,5 +1,47 @@
|
||||
# NetBox v3.5
|
||||
|
||||
## v3.5.1 (2023-05-05)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#10759](https://github.com/netbox-community/netbox/issues/10759) - Support Markdown rendering for custom field descriptions
|
||||
* [#11190](https://github.com/netbox-community/netbox/issues/11190) - Including systemd service & timer configurations for housekeeping tasks
|
||||
* [#11422](https://github.com/netbox-community/netbox/issues/11422) - Match on power panel name when searching for power feeds
|
||||
* [#11504](https://github.com/netbox-community/netbox/issues/11504) - Add filter to select individual racks under rack elevations view
|
||||
* [#11652](https://github.com/netbox-community/netbox/issues/11652) - Add a module status column to module bay tables
|
||||
* [#11791](https://github.com/netbox-community/netbox/issues/11791) - Enable configuration of custom database backend via `ENGINE` parameter
|
||||
* [#11801](https://github.com/netbox-community/netbox/issues/11801) - Include device description within rack elevation tooltip
|
||||
* [#11932](https://github.com/netbox-community/netbox/issues/11932) - Introduce a list view for image attachments, orderable by date and other attributes
|
||||
* [#12122](https://github.com/netbox-community/netbox/issues/12122) - Enable bulk import oj journal entries
|
||||
* [#12245](https://github.com/netbox-community/netbox/issues/12245) - Enable the assignment of wireless LANs to interfaces under bulk edit
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#10757](https://github.com/netbox-community/netbox/issues/10757) - Simplify IP address interface and NAT IP assignment form fields to avoid confusion
|
||||
* [#11715](https://github.com/netbox-community/netbox/issues/11715) - Prefix within a VRF should list global prefixes as parents only if they are containers
|
||||
* [#12363](https://github.com/netbox-community/netbox/issues/12363) - Fix whitespace for paragraph elements in Markdown-rendered table columns
|
||||
* [#12367](https://github.com/netbox-community/netbox/issues/12367) - Fix `RelatedObjectDoesNotExist` exception under certain conditions (regression from #11550)
|
||||
* [#12380](https://github.com/netbox-community/netbox/issues/12380) - Allow selecting object change as model under object list widget configuration
|
||||
* [#12384](https://github.com/netbox-community/netbox/issues/12384) - Add a three-second timeout for RSS reader widget
|
||||
* [#12395](https://github.com/netbox-community/netbox/issues/12395) - Fix "create & add another" action for objects with custom fields
|
||||
* [#12396](https://github.com/netbox-community/netbox/issues/12396) - Provider account should not be a required field in REST API serializer
|
||||
* [#12400](https://github.com/netbox-community/netbox/issues/12400) - Validate default values for object and multi-object custom fields
|
||||
* [#12401](https://github.com/netbox-community/netbox/issues/12401) - Support the creation of front ports without a pre-populated device ID
|
||||
* [#12405](https://github.com/netbox-community/netbox/issues/12405) - Fix filtering for VLAN groups displayed under site view
|
||||
* [#12410](https://github.com/netbox-community/netbox/issues/12410) - Fix base path for OpenAPI schema (fixes Swagger UI requests)
|
||||
* [#12416](https://github.com/netbox-community/netbox/issues/12416) - Fix `FileNotFoundError` exception when a managed script file is missing from disk
|
||||
* [#12412](https://github.com/netbox-community/netbox/issues/12412) - Device/VM interface MAC addresses can be nullified via REST API
|
||||
* [#12415](https://github.com/netbox-community/netbox/issues/12415) - Fix `ImportError` exception when running RQ worker
|
||||
* [#12433](https://github.com/netbox-community/netbox/issues/12433) - Correct the application of URL query parameters for object list dashboard widgets
|
||||
* [#12436](https://github.com/netbox-community/netbox/issues/12436) - Remove extraneous "add" button from contact assignments list
|
||||
* [#12463](https://github.com/netbox-community/netbox/issues/12463) - Fix the association of completed jobs with reports & scripts in the REST API
|
||||
* [#12464](https://github.com/netbox-community/netbox/issues/12464) - Apply credentials for git data source only when connecting via HTTP/S
|
||||
* [#12476](https://github.com/netbox-community/netbox/issues/12476) - Fix `TypeError` exception when running the `runscript` management command
|
||||
* [#12483](https://github.com/netbox-community/netbox/issues/12483) - Fix git remote data syncing when with HTTP proxies defined
|
||||
* [#12496](https://github.com/netbox-community/netbox/issues/12496) - Remove obsolete account field from provider UI view
|
||||
|
||||
---
|
||||
|
||||
## v3.5.0 (2023-04-27)
|
||||
|
||||
### Breaking Changes
|
||||
|
@ -106,7 +106,7 @@ class CircuitCircuitTerminationSerializer(WritableNestedSerializer):
|
||||
class CircuitSerializer(NetBoxModelSerializer):
|
||||
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuit-detail')
|
||||
provider = NestedProviderSerializer()
|
||||
provider_account = NestedProviderAccountSerializer()
|
||||
provider_account = NestedProviderAccountSerializer(required=False, allow_null=True)
|
||||
status = ChoiceField(choices=CircuitStatusChoices, required=False)
|
||||
type = NestedCircuitTypeSerializer()
|
||||
tenant = NestedTenantSerializer(required=False, allow_null=True)
|
||||
|
@ -74,7 +74,8 @@ class CircuitImportForm(NetBoxModelImportForm):
|
||||
provider_account = CSVModelChoiceField(
|
||||
queryset=ProviderAccount.objects.all(),
|
||||
to_field_name='name',
|
||||
help_text=_('Assigned provider account')
|
||||
help_text=_('Assigned provider account'),
|
||||
required=False
|
||||
)
|
||||
type = CSVModelChoiceField(
|
||||
queryset=CircuitType.objects.all(),
|
||||
|
@ -1,23 +1,12 @@
|
||||
import re
|
||||
import typing
|
||||
|
||||
from drf_spectacular.extensions import (
|
||||
OpenApiSerializerFieldExtension,
|
||||
OpenApiViewExtension,
|
||||
)
|
||||
from drf_spectacular.extensions import OpenApiSerializerFieldExtension
|
||||
from drf_spectacular.openapi import AutoSchema
|
||||
from drf_spectacular.plumbing import (
|
||||
ComponentRegistry,
|
||||
ResolvedComponent,
|
||||
build_basic_type,
|
||||
build_choice_field,
|
||||
build_media_type_object,
|
||||
build_object_type,
|
||||
get_doc,
|
||||
is_serializer,
|
||||
build_basic_type, build_choice_field, build_media_type_object, build_object_type, get_doc,
|
||||
)
|
||||
from drf_spectacular.types import OpenApiTypes
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from rest_framework.relations import ManyRelatedField
|
||||
|
||||
from netbox.api.fields import ChoiceField, SerializedPKRelatedField
|
||||
|
@ -12,7 +12,7 @@ from django import forms
|
||||
from django.conf import settings
|
||||
from django.utils.translation import gettext as _
|
||||
from dulwich import porcelain
|
||||
from dulwich.config import StackedConfig
|
||||
from dulwich.config import ConfigDict
|
||||
|
||||
from netbox.registry import registry
|
||||
from .choices import DataSourceTypeChoices
|
||||
@ -31,6 +31,7 @@ def register_backend(name):
|
||||
"""
|
||||
Decorator for registering a DataBackend class.
|
||||
"""
|
||||
|
||||
def _wrapper(cls):
|
||||
registry['data_backends'][name] = cls
|
||||
return cls
|
||||
@ -56,7 +57,6 @@ class DataBackend:
|
||||
|
||||
@register_backend(DataSourceTypeChoices.LOCAL)
|
||||
class LocalBackend(DataBackend):
|
||||
|
||||
@contextmanager
|
||||
def fetch(self):
|
||||
logger.debug(f"Data source type is local; skipping fetch")
|
||||
@ -71,12 +71,14 @@ class GitBackend(DataBackend):
|
||||
'username': forms.CharField(
|
||||
required=False,
|
||||
label=_('Username'),
|
||||
widget=forms.TextInput(attrs={'class': 'form-control'})
|
||||
widget=forms.TextInput(attrs={'class': 'form-control'}),
|
||||
help_text=_("Only used for cloning with HTTP / HTTPS"),
|
||||
),
|
||||
'password': forms.CharField(
|
||||
required=False,
|
||||
label=_('Password'),
|
||||
widget=forms.TextInput(attrs={'class': 'form-control'})
|
||||
widget=forms.TextInput(attrs={'class': 'form-control'}),
|
||||
help_text=_("Only used for cloning with HTTP / HTTPS"),
|
||||
),
|
||||
'branch': forms.CharField(
|
||||
required=False,
|
||||
@ -89,10 +91,22 @@ class GitBackend(DataBackend):
|
||||
def fetch(self):
|
||||
local_path = tempfile.TemporaryDirectory()
|
||||
|
||||
username = self.params.get('username')
|
||||
password = self.params.get('password')
|
||||
branch = self.params.get('branch')
|
||||
config = StackedConfig.default()
|
||||
config = ConfigDict()
|
||||
clone_args = {
|
||||
"branch": self.params.get('branch'),
|
||||
"config": config,
|
||||
"depth": 1,
|
||||
"errstream": porcelain.NoneStream(),
|
||||
"quiet": True,
|
||||
}
|
||||
|
||||
if self.url_scheme in ('http', 'https'):
|
||||
clone_args.update(
|
||||
{
|
||||
"username": self.params.get('username'),
|
||||
"password": self.params.get('password'),
|
||||
}
|
||||
)
|
||||
|
||||
if settings.HTTP_PROXIES and self.url_scheme in ('http', 'https'):
|
||||
if proxy := settings.HTTP_PROXIES.get(self.url_scheme):
|
||||
@ -100,10 +114,7 @@ class GitBackend(DataBackend):
|
||||
|
||||
logger.debug(f"Cloning git repo: {self.url}")
|
||||
try:
|
||||
porcelain.clone(
|
||||
self.url, local_path.name, depth=1, branch=branch, username=username, password=password,
|
||||
config=config, quiet=True, errstream=porcelain.NoneStream()
|
||||
)
|
||||
porcelain.clone(self.url, local_path.name, **clone_args)
|
||||
except BaseException as e:
|
||||
raise SyncError(f"Fetching remote data failed ({type(e).__name__}): {e}")
|
||||
|
||||
|
@ -904,7 +904,11 @@ class InterfaceSerializer(NetBoxModelSerializer, CabledObjectSerializer, Connect
|
||||
)
|
||||
count_ipaddresses = serializers.IntegerField(read_only=True)
|
||||
count_fhrp_groups = serializers.IntegerField(read_only=True)
|
||||
mac_address = serializers.CharField(required=False, default=None)
|
||||
mac_address = serializers.CharField(
|
||||
required=False,
|
||||
default=None,
|
||||
allow_null=True
|
||||
)
|
||||
wwn = serializers.CharField(required=False, default=None)
|
||||
|
||||
class Meta:
|
||||
|
@ -1900,6 +1900,7 @@ class PowerFeedFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet, PathEndpoi
|
||||
return queryset
|
||||
qs_filter = (
|
||||
Q(name__icontains=value) |
|
||||
Q(power_panel__name__icontains=value) |
|
||||
Q(comments__icontains=value)
|
||||
)
|
||||
return queryset.filter(qs_filter)
|
||||
|
@ -13,6 +13,7 @@ from tenancy.models import Tenant
|
||||
from utilities.forms import BulkEditForm, add_blank_choice, form_from_model
|
||||
from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField
|
||||
from utilities.forms.widgets import BulkEditNullBooleanSelect, NumberWithOptions
|
||||
from wireless.models import WirelessLAN, WirelessLANGroup
|
||||
|
||||
__all__ = (
|
||||
'CableBulkEditForm',
|
||||
@ -1139,7 +1140,7 @@ class InterfaceBulkEditForm(
|
||||
form_from_model(Interface, [
|
||||
'label', 'type', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'mtu', 'mgmt_only',
|
||||
'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width',
|
||||
'tx_power',
|
||||
'tx_power', 'wireless_lans'
|
||||
]),
|
||||
ComponentBulkEditForm
|
||||
):
|
||||
@ -1229,6 +1230,19 @@ class InterfaceBulkEditForm(
|
||||
required=False,
|
||||
label=_('VRF')
|
||||
)
|
||||
wireless_lan_group = DynamicModelChoiceField(
|
||||
queryset=WirelessLANGroup.objects.all(),
|
||||
required=False,
|
||||
label=_('Wireless LAN group')
|
||||
)
|
||||
wireless_lans = DynamicModelMultipleChoiceField(
|
||||
queryset=WirelessLAN.objects.all(),
|
||||
required=False,
|
||||
label=_('Wireless LANs'),
|
||||
query_params={
|
||||
'group_id': '$wireless_lan_group',
|
||||
}
|
||||
)
|
||||
|
||||
model = Interface
|
||||
fieldsets = (
|
||||
@ -1238,12 +1252,14 @@ class InterfaceBulkEditForm(
|
||||
('PoE', ('poe_mode', 'poe_type')),
|
||||
('Related Interfaces', ('parent', 'bridge', 'lag')),
|
||||
('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')),
|
||||
('Wireless', ('rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width')),
|
||||
('Wireless', (
|
||||
'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'wireless_lan_group', 'wireless_lans',
|
||||
)),
|
||||
)
|
||||
nullable_fields = (
|
||||
'module', 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'mac_address', 'wwn', 'vdcs', 'mtu', 'description',
|
||||
'poe_mode', 'poe_type', 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
|
||||
'vlan_group', 'untagged_vlan', 'tagged_vlans', 'vrf',
|
||||
'vlan_group', 'untagged_vlan', 'tagged_vlans', 'vrf', 'wireless_lans'
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
@ -298,6 +298,15 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
|
||||
|
||||
|
||||
class RackElevationFilterForm(RackFilterForm):
|
||||
fieldsets = (
|
||||
(None, ('q', 'filter_id', 'tag')),
|
||||
('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'id')),
|
||||
('Function', ('status', 'role_id')),
|
||||
('Hardware', ('type', 'width', 'serial', 'asset_tag')),
|
||||
('Tenant', ('tenant_group_id', 'tenant_id')),
|
||||
('Contacts', ('contact', 'contact_role', 'contact_group')),
|
||||
('Weight', ('weight', 'max_weight', 'weight_unit')),
|
||||
)
|
||||
id = DynamicModelMultipleChoiceField(
|
||||
queryset=Rack.objects.all(),
|
||||
label=_('Rack'),
|
||||
|
@ -4,6 +4,7 @@ from django.utils.translation import gettext as _
|
||||
from dcim.models import *
|
||||
from netbox.forms import NetBoxModelForm
|
||||
from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField
|
||||
from utilities.forms.widgets import APISelect
|
||||
from . import model_forms
|
||||
|
||||
__all__ = (
|
||||
@ -225,6 +226,18 @@ class InterfaceCreateForm(ComponentCreateForm, model_forms.InterfaceForm):
|
||||
|
||||
|
||||
class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm):
|
||||
device = DynamicModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
selector=True,
|
||||
widget=APISelect(
|
||||
# TODO: Clean up the application of HTMXSelect attributes
|
||||
attrs={
|
||||
'hx-get': '.',
|
||||
'hx-include': f'#form_fields',
|
||||
'hx-target': f'#form_fields',
|
||||
}
|
||||
)
|
||||
)
|
||||
rear_port = forms.MultipleChoiceField(
|
||||
choices=[],
|
||||
label=_('Rear ports'),
|
||||
@ -244,9 +257,10 @@ class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
device = Device.objects.get(
|
||||
pk=self.initial.get('device') or self.data.get('device')
|
||||
)
|
||||
if device_id := self.data.get('device') or self.initial.get('device'):
|
||||
device = Device.objects.get(pk=device_id)
|
||||
else:
|
||||
return
|
||||
|
||||
# Determine which rear port positions are occupied. These will be excluded from the list of available
|
||||
# mappings.
|
||||
|
@ -37,15 +37,28 @@ def get_device_name(device):
|
||||
|
||||
|
||||
def get_device_description(device):
|
||||
return '{} ({}) — {} {} ({}U) {} {}'.format(
|
||||
device.name,
|
||||
device.device_role,
|
||||
device.device_type.manufacturer.name,
|
||||
device.device_type.model,
|
||||
floatformat(device.device_type.u_height),
|
||||
device.asset_tag or '',
|
||||
device.serial or ''
|
||||
)
|
||||
"""
|
||||
Return a description for a device to be rendered in the rack elevation in the following format
|
||||
|
||||
Name: <name>
|
||||
Role: <device_role>
|
||||
Device Type: <manufacturer> <model> (<u_height>)
|
||||
Asset tag: <asset_tag> (if defined)
|
||||
Serial: <serial> (if defined)
|
||||
Description: <description> (if defined)
|
||||
"""
|
||||
description = f'Name: {device.name}'
|
||||
description += f'\nRole: {device.device_role}'
|
||||
u_height = f'{floatformat(device.device_type.u_height)}U'
|
||||
description += f'\nDevice Type: {device.device_type.manufacturer.name} {device.device_type.model} ({u_height})'
|
||||
if device.asset_tag:
|
||||
description += f'\nAsset tag: {device.asset_tag}'
|
||||
if device.serial:
|
||||
description += f'\nSerial: {device.serial}'
|
||||
if device.description:
|
||||
description += f'\nDescription: {device.description}'
|
||||
|
||||
return description
|
||||
|
||||
|
||||
class RackElevationSVG:
|
||||
|
@ -39,6 +39,10 @@ __all__ = (
|
||||
'VirtualDeviceContextTable'
|
||||
)
|
||||
|
||||
MODULEBAY_STATUS = """
|
||||
{% badge record.installed_module.get_status_display bg_color=record.installed_module.get_status_color %}
|
||||
"""
|
||||
|
||||
|
||||
def get_cabletermination_row_class(record):
|
||||
if record.mark_connected:
|
||||
@ -781,14 +785,17 @@ class ModuleBayTable(DeviceComponentTable):
|
||||
tags = columns.TagColumn(
|
||||
url_name='dcim:modulebay_list'
|
||||
)
|
||||
module_status = columns.TemplateColumn(
|
||||
template_code=MODULEBAY_STATUS
|
||||
)
|
||||
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = models.ModuleBay
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'device', 'label', 'position', 'installed_module', 'module_serial', 'module_asset_tag',
|
||||
'description', 'tags',
|
||||
'pk', 'id', 'name', 'device', 'label', 'position', 'installed_module', 'module_status', 'module_serial',
|
||||
'module_asset_tag', 'description', 'tags',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'device', 'label', 'installed_module', 'description')
|
||||
default_columns = ('pk', 'name', 'device', 'label', 'installed_module', 'module_status', 'description')
|
||||
|
||||
|
||||
class DeviceModuleBayTable(ModuleBayTable):
|
||||
@ -799,10 +806,10 @@ class DeviceModuleBayTable(ModuleBayTable):
|
||||
class Meta(DeviceComponentTable.Meta):
|
||||
model = models.ModuleBay
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'label', 'position', 'installed_module', 'module_serial', 'module_asset_tag',
|
||||
'pk', 'id', 'name', 'label', 'position', 'installed_module', 'module_status', 'module_serial', 'module_asset_tag',
|
||||
'description', 'tags', 'actions',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'label', 'installed_module', 'description')
|
||||
default_columns = ('pk', 'name', 'label', 'installed_module', 'module_status', 'description')
|
||||
|
||||
|
||||
class InventoryItemTable(DeviceComponentTable):
|
||||
|
@ -371,7 +371,7 @@ class SiteView(generic.ObjectView):
|
||||
(VLANGroup.objects.restrict(request.user, 'view').filter(
|
||||
scope_type=ContentType.objects.get_for_model(Site),
|
||||
scope_id=instance.pk
|
||||
), 'site_id'),
|
||||
), 'site'),
|
||||
(VLAN.objects.restrict(request.user, 'view').filter(site=instance), 'site_id'),
|
||||
# Circuits
|
||||
(Circuit.objects.restrict(request.user, 'view').filter(terminations__site=instance).distinct(), 'site_id'),
|
||||
|
@ -187,11 +187,10 @@ class ReportViewSet(ViewSet):
|
||||
"""
|
||||
Compile all reports and their related results (if any). Result data is deferred in the list view.
|
||||
"""
|
||||
report_content_type = ContentType.objects.get(app_label='extras', model='report')
|
||||
results = {
|
||||
r.name: r
|
||||
for r in Job.objects.filter(
|
||||
object_type=report_content_type,
|
||||
job.name: job
|
||||
for job in Job.objects.filter(
|
||||
object_type=ContentType.objects.get(app_label='extras', model='reportmodule'),
|
||||
status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
|
||||
).order_by('name', '-created').distinct('name').defer('data')
|
||||
}
|
||||
@ -202,7 +201,7 @@ class ReportViewSet(ViewSet):
|
||||
|
||||
# Attach Job objects to each report (if any)
|
||||
for report in report_list:
|
||||
report.result = results.get(report.full_name, None)
|
||||
report.result = results.get(report.name, None)
|
||||
|
||||
serializer = serializers.ReportSerializer(report_list, many=True, context={
|
||||
'request': request,
|
||||
@ -290,12 +289,10 @@ class ScriptViewSet(ViewSet):
|
||||
return module, script
|
||||
|
||||
def list(self, request):
|
||||
|
||||
script_content_type = ContentType.objects.get(app_label='extras', model='script')
|
||||
results = {
|
||||
r.name: r
|
||||
for r in Job.objects.filter(
|
||||
object_type=script_content_type,
|
||||
job.name: job
|
||||
for job in Job.objects.filter(
|
||||
object_type=ContentType.objects.get(app_label='extras', model='scriptmodule'),
|
||||
status__in=JobStatusChoices.TERMINAL_STATE_CHOICES
|
||||
).order_by('name', '-created').distinct('name').defer('data')
|
||||
}
|
||||
@ -306,7 +303,7 @@ class ScriptViewSet(ViewSet):
|
||||
|
||||
# Attach Job objects to each script (if any)
|
||||
for script in script_list:
|
||||
script.result = results.get(script.full_name, None)
|
||||
script.result = results.get(script.name, None)
|
||||
|
||||
serializer = serializers.ScriptSerializer(script_list, many=True, context={'request': request})
|
||||
|
||||
|
@ -4,10 +4,12 @@ from hashlib import sha256
|
||||
from urllib.parse import urlencode
|
||||
|
||||
import feedparser
|
||||
import requests
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.cache import cache
|
||||
from django.db.models import Q
|
||||
from django.template.loader import render_to_string
|
||||
from django.urls import NoReverseMatch, reverse
|
||||
from django.utils.translation import gettext as _
|
||||
@ -33,7 +35,7 @@ def get_content_type_labels():
|
||||
return [
|
||||
(content_type_identifier(ct), content_type_name(ct))
|
||||
for ct in ContentType.objects.filter(
|
||||
FeatureQuery('export_templates').get_query()
|
||||
FeatureQuery('export_templates').get_query() | Q(app_label='extras', model='objectchange')
|
||||
).order_by('app_label', 'model')
|
||||
]
|
||||
|
||||
@ -227,7 +229,11 @@ class ObjectListWidget(DashboardWidget):
|
||||
htmx_url = reverse(viewname)
|
||||
except NoReverseMatch:
|
||||
htmx_url = None
|
||||
if parameters := self.config.get('url_params'):
|
||||
parameters = self.config.get('url_params') or {}
|
||||
if page_size := self.config.get('page_size'):
|
||||
parameters['per_page'] = page_size
|
||||
|
||||
if parameters:
|
||||
try:
|
||||
htmx_url = f'{htmx_url}?{urlencode(parameters, doseq=True)}'
|
||||
except ValueError:
|
||||
@ -236,7 +242,6 @@ class ObjectListWidget(DashboardWidget):
|
||||
'viewname': viewname,
|
||||
'has_permission': has_permission,
|
||||
'htmx_url': htmx_url,
|
||||
'page_size': self.config.get('page_size'),
|
||||
})
|
||||
|
||||
|
||||
@ -268,12 +273,9 @@ class RSSFeedWidget(DashboardWidget):
|
||||
)
|
||||
|
||||
def render(self, request):
|
||||
url = self.config['feed_url']
|
||||
feed = self.get_feed()
|
||||
|
||||
return render_to_string(self.template_name, {
|
||||
'url': url,
|
||||
'feed': feed,
|
||||
'url': self.config['feed_url'],
|
||||
**self.get_feed()
|
||||
})
|
||||
|
||||
@cached_property
|
||||
@ -285,17 +287,33 @@ class RSSFeedWidget(DashboardWidget):
|
||||
def get_feed(self):
|
||||
# Fetch RSS content from cache if available
|
||||
if feed_content := cache.get(self.cache_key):
|
||||
feed = feedparser.FeedParserDict(feed_content)
|
||||
else:
|
||||
feed = feedparser.parse(
|
||||
self.config['feed_url'],
|
||||
request_headers={'User-Agent': f'NetBox/{settings.VERSION}'}
|
||||
)
|
||||
if not feed.bozo:
|
||||
# Cap number of entries
|
||||
max_entries = self.config.get('max_entries')
|
||||
feed['entries'] = feed['entries'][:max_entries]
|
||||
# Cache the feed content
|
||||
cache.set(self.cache_key, dict(feed), self.config.get('cache_timeout'))
|
||||
return {
|
||||
'feed': feedparser.FeedParserDict(feed_content),
|
||||
}
|
||||
|
||||
return feed
|
||||
# Fetch feed content from remote server
|
||||
try:
|
||||
response = requests.get(
|
||||
url=self.config['feed_url'],
|
||||
headers={'User-Agent': f'NetBox/{settings.VERSION}'},
|
||||
proxies=settings.HTTP_PROXIES,
|
||||
timeout=3
|
||||
)
|
||||
response.raise_for_status()
|
||||
except requests.exceptions.RequestException as e:
|
||||
return {
|
||||
'error': e,
|
||||
}
|
||||
|
||||
# Parse feed content
|
||||
feed = feedparser.parse(response.content)
|
||||
if not feed.bozo:
|
||||
# Cap number of entries
|
||||
max_entries = self.config.get('max_entries')
|
||||
feed['entries'] = feed['entries'][:max_entries]
|
||||
# Cache the feed content
|
||||
cache.set(self.cache_key, dict(feed), self.config.get('cache_timeout'))
|
||||
|
||||
return {
|
||||
'feed': feed,
|
||||
}
|
||||
|
@ -4,9 +4,10 @@ from django.contrib.postgres.forms import SimpleArrayField
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from extras.choices import CustomFieldVisibilityChoices, CustomFieldTypeChoices
|
||||
from extras.choices import CustomFieldVisibilityChoices, CustomFieldTypeChoices, JournalEntryKindChoices
|
||||
from extras.models import *
|
||||
from extras.utils import FeatureQuery
|
||||
from netbox.forms import NetBoxModelImportForm
|
||||
from utilities.forms import CSVModelForm
|
||||
from utilities.forms.fields import CSVChoiceField, CSVContentTypeField, CSVMultipleContentTypeField, SlugField
|
||||
|
||||
@ -15,6 +16,7 @@ __all__ = (
|
||||
'CustomFieldImportForm',
|
||||
'CustomLinkImportForm',
|
||||
'ExportTemplateImportForm',
|
||||
'JournalEntryImportForm',
|
||||
'SavedFilterImportForm',
|
||||
'TagImportForm',
|
||||
'WebhookImportForm',
|
||||
@ -132,3 +134,20 @@ class TagImportForm(CSVModelForm):
|
||||
help_texts = {
|
||||
'color': mark_safe(_('RGB color in hexadecimal (e.g. <code>00ff00</code>)')),
|
||||
}
|
||||
|
||||
|
||||
class JournalEntryImportForm(NetBoxModelImportForm):
|
||||
assigned_object_type = CSVContentTypeField(
|
||||
queryset=ContentType.objects.all(),
|
||||
label=_('Assigned object type'),
|
||||
)
|
||||
kind = CSVChoiceField(
|
||||
choices=JournalEntryKindChoices,
|
||||
help_text=_('The classification of entry')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = JournalEntry
|
||||
fields = (
|
||||
'assigned_object_type', 'assigned_object_id', 'created_by', 'kind', 'comments', 'tags'
|
||||
)
|
||||
|
@ -11,7 +11,7 @@ from extras.utils import FeatureQuery
|
||||
from netbox.forms.base import NetBoxModelFilterSetForm
|
||||
from tenancy.models import Tenant, TenantGroup
|
||||
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
|
||||
from utilities.forms.fields import ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, TagFilterField
|
||||
from utilities.forms.fields import ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, TagFilterField
|
||||
from utilities.forms.widgets import APISelectMultiple, DateTimePicker
|
||||
from virtualization.models import Cluster, ClusterGroup, ClusterType
|
||||
from .mixins import SavedFiltersMixin
|
||||
@ -22,6 +22,7 @@ __all__ = (
|
||||
'CustomFieldFilterForm',
|
||||
'CustomLinkFilterForm',
|
||||
'ExportTemplateFilterForm',
|
||||
'ImageAttachmentFilterForm',
|
||||
'JournalEntryFilterForm',
|
||||
'LocalConfigContextFilterForm',
|
||||
'ObjectChangeFilterForm',
|
||||
@ -137,6 +138,20 @@ class ExportTemplateFilterForm(SavedFiltersMixin, FilterForm):
|
||||
)
|
||||
|
||||
|
||||
class ImageAttachmentFilterForm(SavedFiltersMixin, FilterForm):
|
||||
fieldsets = (
|
||||
(None, ('q', 'filter_id')),
|
||||
('Attributes', ('content_type_id', 'name',)),
|
||||
)
|
||||
content_type_id = ContentTypeChoiceField(
|
||||
queryset=ContentType.objects.filter(FeatureQuery('custom_fields').get_query()),
|
||||
required=False
|
||||
)
|
||||
name = forms.CharField(
|
||||
required=False
|
||||
)
|
||||
|
||||
|
||||
class SavedFilterFilterForm(SavedFiltersMixin, FilterForm):
|
||||
fieldsets = (
|
||||
(None, ('q', 'filter_id')),
|
||||
|
@ -111,7 +111,7 @@ class Command(BaseCommand):
|
||||
|
||||
# Create the job
|
||||
job = Job.objects.create(
|
||||
instance=module,
|
||||
object=module,
|
||||
name=script.name,
|
||||
user=User.objects.filter(is_superuser=True).order_by('pk')[0],
|
||||
job_id=uuid.uuid4()
|
||||
|
@ -606,5 +606,18 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
|
||||
f"Invalid choice(s) ({', '.join(value)}). Available choices are: {', '.join(self.choices)}"
|
||||
)
|
||||
|
||||
# Validate selected object
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_OBJECT:
|
||||
if type(value) is not int:
|
||||
raise ValidationError(f"Value must be an object ID, not {type(value).__name__}")
|
||||
|
||||
# Validate selected objects
|
||||
elif self.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
|
||||
if type(value) is not list:
|
||||
raise ValidationError(f"Value must be a list of object IDs, not {type(value).__name__}")
|
||||
for id in value:
|
||||
if type(id) is not int:
|
||||
raise ValidationError(f"Found invalid object ID: {id}")
|
||||
|
||||
elif self.required:
|
||||
raise ValidationError("Required field cannot be empty.")
|
||||
|
@ -1,4 +1,5 @@
|
||||
import inspect
|
||||
import logging
|
||||
from functools import cached_property
|
||||
|
||||
from django.db import models
|
||||
@ -16,6 +17,8 @@ __all__ = (
|
||||
'ScriptModule',
|
||||
)
|
||||
|
||||
logger = logging.getLogger('netbox.data_backends')
|
||||
|
||||
|
||||
class Script(WebhooksMixin, models.Model):
|
||||
"""
|
||||
@ -53,7 +56,12 @@ class ScriptModule(PythonModuleMixin, JobsMixin, ManagedFile):
|
||||
# For child objects in submodules use the full import path w/o the root module as the name
|
||||
return cls.full_name.split(".", maxsplit=1)[1]
|
||||
|
||||
module = self.get_module()
|
||||
try:
|
||||
module = self.get_module()
|
||||
except Exception as e:
|
||||
logger.debug(f"Failed to load script: {self.python_name} error: {e}")
|
||||
module = None
|
||||
|
||||
scripts = {}
|
||||
ordered = getattr(module, 'script_order', [])
|
||||
|
||||
|
@ -13,6 +13,7 @@ __all__ = (
|
||||
'CustomFieldTable',
|
||||
'CustomLinkTable',
|
||||
'ExportTemplateTable',
|
||||
'ImageAttachmentTable',
|
||||
'JournalEntryTable',
|
||||
'ObjectChangeTable',
|
||||
'SavedFilterTable',
|
||||
@ -29,6 +30,7 @@ class CustomFieldTable(NetBoxTable):
|
||||
content_types = columns.ContentTypesColumn()
|
||||
required = columns.BooleanColumn()
|
||||
ui_visibility = columns.ChoiceFieldColumn(verbose_name="UI visibility")
|
||||
description = columns.MarkdownColumn()
|
||||
is_cloneable = columns.BooleanColumn()
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
@ -85,6 +87,28 @@ class ExportTemplateTable(NetBoxTable):
|
||||
)
|
||||
|
||||
|
||||
class ImageAttachmentTable(NetBoxTable):
|
||||
id = tables.Column(
|
||||
linkify=False
|
||||
)
|
||||
content_type = columns.ContentTypeColumn()
|
||||
parent = tables.Column(
|
||||
linkify=True
|
||||
)
|
||||
size = tables.Column(
|
||||
orderable=False,
|
||||
verbose_name='Size (bytes)'
|
||||
)
|
||||
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = ImageAttachment
|
||||
fields = (
|
||||
'pk', 'content_type', 'parent', 'image', 'name', 'image_height', 'image_width', 'size', 'created',
|
||||
'last_updated',
|
||||
)
|
||||
default_columns = ('content_type', 'parent', 'image', 'name', 'size', 'created')
|
||||
|
||||
|
||||
class SavedFilterTable(NetBoxTable):
|
||||
name = tables.Column(
|
||||
linkify=True
|
||||
|
@ -73,6 +73,7 @@ urlpatterns = [
|
||||
path('config-templates/<int:pk>/', include(get_model_urls('extras', 'configtemplate'))),
|
||||
|
||||
# Image attachments
|
||||
path('image-attachments/', views.ImageAttachmentListView.as_view(), name='imageattachment_list'),
|
||||
path('image-attachments/add/', views.ImageAttachmentEditView.as_view(), name='imageattachment_add'),
|
||||
path('image-attachments/<int:pk>/', include(get_model_urls('extras', 'imageattachment'))),
|
||||
|
||||
@ -81,6 +82,7 @@ urlpatterns = [
|
||||
path('journal-entries/add/', views.JournalEntryEditView.as_view(), name='journalentry_add'),
|
||||
path('journal-entries/edit/', views.JournalEntryBulkEditView.as_view(), name='journalentry_bulk_edit'),
|
||||
path('journal-entries/delete/', views.JournalEntryBulkDeleteView.as_view(), name='journalentry_bulk_delete'),
|
||||
path('journal-entries/import/', views.JournalEntryBulkImportView.as_view(), name='journalentry_import'),
|
||||
path('journal-entries/<int:pk>/', include(get_model_urls('extras', 'journalentry'))),
|
||||
|
||||
# Change logging
|
||||
|
@ -577,6 +577,14 @@ class ObjectChangeView(generic.ObjectView):
|
||||
# Image attachments
|
||||
#
|
||||
|
||||
class ImageAttachmentListView(generic.ObjectListView):
|
||||
queryset = ImageAttachment.objects.all()
|
||||
filterset = filtersets.ImageAttachmentFilterSet
|
||||
filterset_form = forms.ImageAttachmentFilterForm
|
||||
table = tables.ImageAttachmentTable
|
||||
actions = ('export',)
|
||||
|
||||
|
||||
@register_model_view(ImageAttachment, 'edit')
|
||||
class ImageAttachmentEditView(generic.ObjectEditView):
|
||||
queryset = ImageAttachment.objects.all()
|
||||
@ -617,7 +625,7 @@ class JournalEntryListView(generic.ObjectListView):
|
||||
filterset = filtersets.JournalEntryFilterSet
|
||||
filterset_form = forms.JournalEntryFilterForm
|
||||
table = tables.JournalEntryTable
|
||||
actions = ('export', 'bulk_edit', 'bulk_delete')
|
||||
actions = ('import', 'export', 'bulk_edit', 'bulk_delete')
|
||||
|
||||
|
||||
@register_model_view(JournalEntry)
|
||||
@ -666,6 +674,11 @@ class JournalEntryBulkDeleteView(generic.BulkDeleteView):
|
||||
table = tables.JournalEntryTable
|
||||
|
||||
|
||||
class JournalEntryBulkImportView(generic.BulkImportView):
|
||||
queryset = JournalEntry.objects.all()
|
||||
model_form = forms.JournalEntryImportForm
|
||||
|
||||
|
||||
#
|
||||
# Dashboard & widgets
|
||||
#
|
||||
@ -1033,7 +1046,6 @@ class ScriptView(ContentTypePermissionRequiredMixin, View):
|
||||
return 'extras.view_script'
|
||||
|
||||
def get(self, request, module, name):
|
||||
print(module)
|
||||
module = get_object_or_404(ScriptModule.objects.restrict(request.user), file_path__startswith=module)
|
||||
script = module.scripts[name]()
|
||||
form = script.as_form(initial=normalize_querydict(request.GET))
|
||||
|
@ -262,38 +262,21 @@ class IPRangeForm(TenancyForm, NetBoxModelForm):
|
||||
|
||||
|
||||
class IPAddressForm(TenancyForm, NetBoxModelForm):
|
||||
device = DynamicModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
required=False,
|
||||
initial_params={
|
||||
'interfaces': '$interface'
|
||||
}
|
||||
)
|
||||
interface = DynamicModelChoiceField(
|
||||
queryset=Interface.objects.all(),
|
||||
required=False,
|
||||
query_params={
|
||||
'device_id': '$device'
|
||||
}
|
||||
)
|
||||
virtual_machine = DynamicModelChoiceField(
|
||||
queryset=VirtualMachine.objects.all(),
|
||||
required=False,
|
||||
initial_params={
|
||||
'interfaces': '$vminterface'
|
||||
}
|
||||
selector=True,
|
||||
)
|
||||
vminterface = DynamicModelChoiceField(
|
||||
queryset=VMInterface.objects.all(),
|
||||
required=False,
|
||||
selector=True,
|
||||
label=_('Interface'),
|
||||
query_params={
|
||||
'virtual_machine_id': '$virtual_machine'
|
||||
}
|
||||
)
|
||||
fhrpgroup = DynamicModelChoiceField(
|
||||
queryset=FHRPGroup.objects.all(),
|
||||
required=False,
|
||||
selector=True,
|
||||
label=_('FHRP Group')
|
||||
)
|
||||
vrf = DynamicModelChoiceField(
|
||||
@ -301,33 +284,11 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
|
||||
required=False,
|
||||
label=_('VRF')
|
||||
)
|
||||
nat_device = DynamicModelChoiceField(
|
||||
queryset=Device.objects.all(),
|
||||
required=False,
|
||||
selector=True,
|
||||
label=_('Device')
|
||||
)
|
||||
nat_virtual_machine = DynamicModelChoiceField(
|
||||
queryset=VirtualMachine.objects.all(),
|
||||
required=False,
|
||||
selector=True,
|
||||
label=_('Virtual Machine')
|
||||
)
|
||||
nat_vrf = DynamicModelChoiceField(
|
||||
queryset=VRF.objects.all(),
|
||||
required=False,
|
||||
selector=True,
|
||||
label=_('VRF')
|
||||
)
|
||||
nat_inside = DynamicModelChoiceField(
|
||||
queryset=IPAddress.objects.all(),
|
||||
required=False,
|
||||
selector=True,
|
||||
label=_('IP Address'),
|
||||
query_params={
|
||||
'device_id': '$nat_device',
|
||||
'virtual_machine_id': '$nat_virtual_machine',
|
||||
'vrf_id': '$nat_vrf',
|
||||
}
|
||||
)
|
||||
primary_for_parent = forms.BooleanField(
|
||||
required=False,
|
||||
@ -338,8 +299,8 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
|
||||
class Meta:
|
||||
model = IPAddress
|
||||
fields = [
|
||||
'address', 'vrf', 'status', 'role', 'dns_name', 'primary_for_parent', 'nat_device', 'nat_virtual_machine',
|
||||
'nat_vrf', 'nat_inside', 'tenant_group', 'tenant', 'description', 'comments', 'tags',
|
||||
'address', 'vrf', 'status', 'role', 'dns_name', 'primary_for_parent', 'nat_inside', 'tenant_group',
|
||||
'tenant', 'description', 'comments', 'tags',
|
||||
]
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@ -354,17 +315,6 @@ class IPAddressForm(TenancyForm, NetBoxModelForm):
|
||||
initial['vminterface'] = instance.assigned_object
|
||||
elif type(instance.assigned_object) is FHRPGroup:
|
||||
initial['fhrpgroup'] = instance.assigned_object
|
||||
if instance.nat_inside:
|
||||
nat_inside_parent = instance.nat_inside.assigned_object
|
||||
if type(nat_inside_parent) is Interface:
|
||||
initial['nat_site'] = nat_inside_parent.device.site.pk
|
||||
if nat_inside_parent.device.rack:
|
||||
initial['nat_rack'] = nat_inside_parent.device.rack.pk
|
||||
initial['nat_device'] = nat_inside_parent.device.pk
|
||||
elif type(nat_inside_parent) is VMInterface:
|
||||
if cluster := nat_inside_parent.virtual_machine.cluster:
|
||||
initial['nat_cluster'] = cluster.pk
|
||||
initial['nat_virtual_machine'] = nat_inside_parent.virtual_machine.pk
|
||||
kwargs['initial'] = initial
|
||||
|
||||
super().__init__(*args, **kwargs)
|
||||
|
@ -14,6 +14,7 @@ from utilities.views import ViewTab, register_model_view
|
||||
from virtualization.filtersets import VMInterfaceFilterSet
|
||||
from virtualization.models import VMInterface
|
||||
from . import filtersets, forms, tables
|
||||
from .choices import PrefixStatusChoices
|
||||
from .constants import *
|
||||
from .models import *
|
||||
from .tables.l2vpn import L2VPNTable, L2VPNTerminationTable
|
||||
@ -495,7 +496,7 @@ class PrefixView(generic.ObjectView):
|
||||
|
||||
# Parent prefixes table
|
||||
parent_prefixes = Prefix.objects.restrict(request.user, 'view').filter(
|
||||
Q(vrf=instance.vrf) | Q(vrf__isnull=True)
|
||||
Q(vrf=instance.vrf) | Q(vrf__isnull=True, status=PrefixStatusChoices.STATUS_CONTAINER)
|
||||
).filter(
|
||||
prefix__net_contains=str(instance.prefix)
|
||||
).prefetch_related(
|
||||
|
@ -14,35 +14,13 @@ __all__ = (
|
||||
|
||||
class CustomFieldModelSerializer(serializers.Serializer):
|
||||
"""
|
||||
Introduces support for custom field assignment. Adds `custom_fields` serialization and ensures
|
||||
that custom field data is populated upon initialization.
|
||||
Introduces support for custom field assignment and representation.
|
||||
"""
|
||||
custom_fields = CustomFieldsDataField(
|
||||
source='custom_field_data',
|
||||
default=CreateOnlyDefault(CustomFieldDefaultValues())
|
||||
)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
if self.instance is not None:
|
||||
|
||||
# Retrieve the set of CustomFields which apply to this type of object
|
||||
content_type = ContentType.objects.get_for_model(self.Meta.model)
|
||||
fields = CustomField.objects.filter(content_types=content_type)
|
||||
|
||||
# Populate custom field values for each instance from database
|
||||
if type(self.instance) in (list, tuple):
|
||||
for obj in self.instance:
|
||||
self._populate_custom_fields(obj, fields)
|
||||
else:
|
||||
self._populate_custom_fields(self.instance, fields)
|
||||
|
||||
def _populate_custom_fields(self, instance, custom_fields):
|
||||
instance.custom_fields = {}
|
||||
for field in custom_fields:
|
||||
instance.custom_fields[field.name] = instance.cf.get(field.name)
|
||||
|
||||
|
||||
class TaggableModelSerializer(serializers.Serializer):
|
||||
"""
|
||||
|
@ -13,6 +13,7 @@ ALLOWED_HOSTS = []
|
||||
# PostgreSQL database configuration. See the Django documentation for a complete list of available parameters:
|
||||
# https://docs.djangoproject.com/en/stable/ref/settings/#databases
|
||||
DATABASE = {
|
||||
'ENGINE': 'django.db.backends.postgresql', # Database engine
|
||||
'NAME': 'netbox', # Database name
|
||||
'USER': '', # PostgreSQL username
|
||||
'PASSWORD': '', # PostgreSQL password
|
||||
|
@ -67,8 +67,8 @@ class NetBoxModel(CloningMixin, NetBoxFeatureSet, models.Model):
|
||||
|
||||
for field in self._meta.get_fields():
|
||||
if isinstance(field, GenericForeignKey):
|
||||
ct_value = getattr(self, field.ct_field)
|
||||
fk_value = getattr(self, field.fk_field)
|
||||
ct_value = getattr(self, field.ct_field, None)
|
||||
fk_value = getattr(self, field.fk_field, None)
|
||||
|
||||
if ct_value is None and fk_value is not None:
|
||||
raise ValidationError({
|
||||
|
@ -292,6 +292,7 @@ CUSTOMIZATION_MENU = Menu(
|
||||
get_model_item('extras', 'exporttemplate', _('Export Templates')),
|
||||
get_model_item('extras', 'savedfilter', _('Saved Filters')),
|
||||
get_model_item('extras', 'tag', 'Tags'),
|
||||
get_model_item('extras', 'imageattachment', _('Image Attachments'), actions=()),
|
||||
),
|
||||
),
|
||||
MenuGroup(
|
||||
@ -336,7 +337,7 @@ OPERATIONS_MENU = Menu(
|
||||
MenuGroup(
|
||||
label=_('Logging'),
|
||||
items=(
|
||||
get_model_item('extras', 'journalentry', _('Journal Entries'), actions=[]),
|
||||
get_model_item('extras', 'journalentry', _('Journal Entries'), actions=['import']),
|
||||
get_model_item('extras', 'objectchange', _('Change Log'), actions=[]),
|
||||
),
|
||||
),
|
||||
|
@ -25,7 +25,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW
|
||||
# Environment setup
|
||||
#
|
||||
|
||||
VERSION = '3.5.0'
|
||||
VERSION = '3.5.1'
|
||||
|
||||
# Hostname
|
||||
HOSTNAME = platform.node()
|
||||
@ -182,15 +182,16 @@ if RELEASE_CHECK_URL:
|
||||
# Database
|
||||
#
|
||||
|
||||
# Only PostgreSQL is supported
|
||||
if METRICS_ENABLED:
|
||||
DATABASE.update({
|
||||
'ENGINE': 'django_prometheus.db.backends.postgresql'
|
||||
})
|
||||
else:
|
||||
DATABASE.update({
|
||||
'ENGINE': 'django.db.backends.postgresql'
|
||||
})
|
||||
if 'ENGINE' not in DATABASE:
|
||||
# Only PostgreSQL is supported
|
||||
if METRICS_ENABLED:
|
||||
DATABASE.update({
|
||||
'ENGINE': 'django_prometheus.db.backends.postgresql'
|
||||
})
|
||||
else:
|
||||
DATABASE.update({
|
||||
'ENGINE': 'django.db.backends.postgresql'
|
||||
})
|
||||
|
||||
DATABASES = {
|
||||
'default': DATABASE,
|
||||
@ -616,13 +617,15 @@ REST_FRAMEWORK = {
|
||||
#
|
||||
|
||||
SPECTACULAR_SETTINGS = {
|
||||
'TITLE': 'NetBox API',
|
||||
'DESCRIPTION': 'API to access NetBox',
|
||||
'TITLE': 'NetBox REST API',
|
||||
'LICENSE': {'name': 'Apache v2 License'},
|
||||
'VERSION': VERSION,
|
||||
'COMPONENT_SPLIT_REQUEST': True,
|
||||
'REDOC_DIST': 'SIDECAR',
|
||||
'SERVERS': [{'url': f'/{BASE_PATH}api'}],
|
||||
'SERVERS': [{
|
||||
'url': BASE_PATH,
|
||||
'description': 'NetBox',
|
||||
}],
|
||||
'SWAGGER_UI_DIST': 'SIDECAR',
|
||||
'SWAGGER_UI_FAVICON_HREF': 'SIDECAR',
|
||||
'POSTPROCESSING_HOOKS': [],
|
||||
|
BIN
netbox/project-static/dist/netbox-dark.css
vendored
BIN
netbox/project-static/dist/netbox-dark.css
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox-light.css
vendored
BIN
netbox/project-static/dist/netbox-light.css
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox-print.css
vendored
BIN
netbox/project-static/dist/netbox-print.css
vendored
Binary file not shown.
@ -231,6 +231,10 @@ table {
|
||||
|
||||
p {
|
||||
// Remove spacing from paragraph elements within tables.
|
||||
margin-bottom: 0.5em;
|
||||
}
|
||||
|
||||
p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
@ -29,17 +29,6 @@
|
||||
{% endfor %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">
|
||||
Account <i
|
||||
class="mdi mdi-alert-box text-warning"
|
||||
data-bs-toggle="tooltip"
|
||||
data-bs-placement="right"
|
||||
title="This field has been deprecated, and will be removed in NetBox v3.5."
|
||||
></i>
|
||||
</th>
|
||||
<td>{{ object.account|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Description</th>
|
||||
<td>{{ object.description|placeholder }}</td>
|
||||
|
@ -32,7 +32,7 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Description</th>
|
||||
<td>{{ object.description|placeholder }}</td>
|
||||
<td>{{ object.description|markdown|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Required</th>
|
||||
|
@ -1,5 +1,5 @@
|
||||
{% if htmx_url and has_permission %}
|
||||
<div class="htmx-container" hx-get="{{ htmx_url }}{% if page_size %}?per_page={{ page_size }}{% endif %}" hx-trigger="load"></div>
|
||||
<div class="htmx-container" hx-get="{{ htmx_url }}" hx-trigger="load"></div>
|
||||
{% elif htmx_url %}
|
||||
<div class="text-muted text-center">
|
||||
<i class="mdi mdi-lock-outline"></i> No permission to view this content.
|
||||
|
@ -1,4 +1,4 @@
|
||||
{% if not feed.bozo %}
|
||||
{% if feed and not feed.bozo %}
|
||||
<div class="list-group list-group-flush">
|
||||
{% for entry in feed.entries %}
|
||||
<div class="list-group-item px-1">
|
||||
@ -16,7 +16,9 @@
|
||||
<span class="text-danger">
|
||||
<i class="mdi mdi-alert"></i> There was a problem fetching the RSS feed:
|
||||
</span>
|
||||
<pre class="m-2">
|
||||
Response status: {{ feed.status }}
|
||||
Error: {{ feed.bozo_exception|escape }}</pre>
|
||||
{% if feed %}
|
||||
{{ feed.bozo_exception|escape }} (HTTP {{ feed.status }})
|
||||
{% else %}
|
||||
{{ error }}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
@ -37,43 +37,49 @@
|
||||
</h5>
|
||||
<div class="card-body">
|
||||
{% include 'inc/sync_warning.html' with object=module %}
|
||||
<table class="table table-hover table-headings reports">
|
||||
<thead>
|
||||
<tr>
|
||||
<th width="250">Name</th>
|
||||
<th>Description</th>
|
||||
<th>Last Run</th>
|
||||
<th class="text-end">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% with jobs=module.get_latest_jobs %}
|
||||
{% for script_name, script_class in module.scripts.items %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{% url 'extras:script' module=module.python_name name=script_name %}" name="script.{{ script_name }}">{{ script_class.name }}</a>
|
||||
</td>
|
||||
<td>
|
||||
{{ script_class.Meta.description|markdown|placeholder }}
|
||||
</td>
|
||||
{% with last_result=jobs|get_key:script_class.name %}
|
||||
{% if last_result %}
|
||||
<td>
|
||||
<a href="{% url 'extras:script_result' job_pk=last_result.pk %}">{{ last_result.created|annotated_date }}</a>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
{% badge last_result.get_status_display last_result.get_status_color %}
|
||||
</td>
|
||||
{% else %}
|
||||
<td class="text-muted">Never</td>
|
||||
<td class="text-end">{{ ''|placeholder }}</td>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% if not module.scripts %}
|
||||
<div class="alert alert-warning d-flex align-items-center" role="alert">
|
||||
<i class="mdi mdi-alert"></i> Script file at: {{module.full_path}} could not be loaded.
|
||||
</div>
|
||||
{% else %}
|
||||
<table class="table table-hover table-headings reports">
|
||||
<thead>
|
||||
<tr>
|
||||
<th width="250">Name</th>
|
||||
<th>Description</th>
|
||||
<th>Last Run</th>
|
||||
<th class="text-end">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% with jobs=module.get_latest_jobs %}
|
||||
{% for script_name, script_class in module.scripts.items %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{% url 'extras:script' module=module.python_name name=script_name %}" name="script.{{ script_name }}">{{ script_class.name }}</a>
|
||||
</td>
|
||||
<td>
|
||||
{{ script_class.Meta.description|markdown|placeholder }}
|
||||
</td>
|
||||
{% with last_result=jobs|get_key:script_class.name %}
|
||||
{% if last_result %}
|
||||
<td>
|
||||
<a href="{% url 'extras:script_result' job_pk=last_result.pk %}">{{ last_result.created|annotated_date }}</a>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
{% badge last_result.get_status_display last_result.get_status_color %}
|
||||
</td>
|
||||
{% else %}
|
||||
<td class="text-muted">Never</td>
|
||||
<td class="text-end">{{ ''|placeholder }}</td>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% empty %}
|
||||
|
@ -4,44 +4,9 @@
|
||||
<h5 class="card-header">
|
||||
Images
|
||||
</h5>
|
||||
<div class="card-body">
|
||||
{% with images=object.images.all %}
|
||||
{% if images.exists %}
|
||||
<table class="table table-hover">
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Size</th>
|
||||
<th>Created</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
{% for attachment in images %}
|
||||
<tr{% if not attachment.size %} class="table-danger"{% endif %}>
|
||||
<td>
|
||||
<i class="mdi mdi-file-image-outline"></i>
|
||||
<a class="image-preview" href="{{ attachment.image.url }}" target="_blank">{{ attachment }}</a>
|
||||
</td>
|
||||
<td>{{ attachment.size|filesizeformat }}</td>
|
||||
<td>{{ attachment.created|annotated_date }}</td>
|
||||
<td class="text-end noprint">
|
||||
{% if perms.extras.change_imageattachment %}
|
||||
<a href="{% url 'extras:imageattachment_edit' pk=attachment.pk %}" class="btn btn-warning btn-sm lh-1" title="Edit Image">
|
||||
<i class="mdi mdi-pencil" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% if perms.extras.delete_imageattachment %}
|
||||
<a href="{% url 'extras:imageattachment_delete' pk=attachment.pk %}" class="btn btn-danger btn-sm lh-1" title="Delete Image">
|
||||
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="text-muted">None</div>
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
<div class="card-body htmx-container table-responsive"
|
||||
hx-get="{% url 'extras:imageattachment_list' %}?content_type_id={{ object|content_type_id }}&object_id={{ object.pk }}"
|
||||
hx-trigger="load"></div>
|
||||
{% if perms.extras.add_imageattachment %}
|
||||
<div class="card-footer text-end noprint">
|
||||
<a href="{% url 'extras:imageattachment_add' %}?content_type={{ object|content_type_id }}&object_id={{ object.pk }}" class="btn btn-primary btn-sm">
|
||||
|
@ -56,11 +56,9 @@
|
||||
</div>
|
||||
<div class="tab-content p-0 border-0">
|
||||
<div class="tab-pane {% if not form.initial.vminterface and not form.initial.fhrpgroup %}active{% endif %}" id="device" role="tabpanel" aria-labeled-by="device_tab">
|
||||
{% render_field form.device %}
|
||||
{% render_field form.interface %}
|
||||
</div>
|
||||
<div class="tab-pane {% if form.initial.vminterface %}active{% endif %}" id="vm" role="tabpanel" aria-labeled-by="vm_tab">
|
||||
{% render_field form.virtual_machine %}
|
||||
{% render_field form.vminterface %}
|
||||
</div>
|
||||
<div class="tab-pane {% if form.initial.fhrpgroup %}active{% endif %}" id="fhrpgroup" role="tabpanel" aria-labeled-by="fhrpgroup_tab">
|
||||
@ -75,60 +73,6 @@
|
||||
<h5 class="offset-sm-3">NAT IP (Inside)</h5>
|
||||
</div>
|
||||
<div class="row mb-2">
|
||||
<div class="offset-sm-3">
|
||||
<ul class="nav nav-pills" role="tablist">
|
||||
<li class="nav-item" role="presentation">
|
||||
<button
|
||||
role="tab"
|
||||
type="button"
|
||||
id="device_tab"
|
||||
data-bs-toggle="tab"
|
||||
class="nav-link active"
|
||||
data-bs-target="#by_device"
|
||||
aria-controls="by_device"
|
||||
>
|
||||
By Device
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button
|
||||
role="tab"
|
||||
type="button"
|
||||
id="vm_tab"
|
||||
data-bs-toggle="tab"
|
||||
class="nav-link"
|
||||
data-bs-target="#by_vm"
|
||||
aria-controls="by_vm"
|
||||
>
|
||||
By VM
|
||||
</button>
|
||||
</li>
|
||||
<li class="nav-item" role="presentation">
|
||||
<button
|
||||
role="tab"
|
||||
type="button"
|
||||
id="vrf_tab"
|
||||
data-bs-toggle="tab"
|
||||
class="nav-link"
|
||||
data-bs-target="#by_vrf"
|
||||
aria-controls="by_vrf"
|
||||
>
|
||||
By IP
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="tab-content p-0 border-0">
|
||||
<div class="tab-pane active" id="by_device" aria-labelledby="device_tab" role="tabpanel">
|
||||
{% render_field form.nat_device %}
|
||||
</div>
|
||||
<div class="tab-pane" id="by_vm" aria-labelledby="vm_tab" role="tabpanel">
|
||||
{% render_field form.nat_virtual_machine %}
|
||||
</div>
|
||||
<div class="tab-pane" id="by_vrf" aria-labelledby="vrf_tab" role="tabpanel">
|
||||
{% render_field form.nat_vrf %}
|
||||
</div>
|
||||
{% render_field form.nat_inside %}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -352,6 +352,7 @@ class ContactAssignmentListView(generic.ObjectListView):
|
||||
filterset = filtersets.ContactAssignmentFilterSet
|
||||
filterset_form = forms.ContactAssignmentFilterForm
|
||||
table = tables.ContactAssignmentTable
|
||||
actions = ('export', 'bulk_edit', 'bulk_delete')
|
||||
|
||||
|
||||
@register_model_view(ContactAssignment, 'edit')
|
||||
|
@ -126,7 +126,11 @@ class VMInterfaceSerializer(NetBoxModelSerializer):
|
||||
l2vpn_termination = NestedL2VPNTerminationSerializer(read_only=True, allow_null=True)
|
||||
count_ipaddresses = serializers.IntegerField(read_only=True)
|
||||
count_fhrp_groups = serializers.IntegerField(read_only=True)
|
||||
mac_address = serializers.CharField(required=False, default=None)
|
||||
mac_address = serializers.CharField(
|
||||
required=False,
|
||||
default=None,
|
||||
allow_null=True
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = VMInterface
|
||||
|
@ -1,35 +1,35 @@
|
||||
bleach==6.0.0
|
||||
boto3==1.26.121
|
||||
Django==4.1.8
|
||||
boto3==1.26.127
|
||||
Django==4.1.9
|
||||
django-cors-headers==3.14.0
|
||||
django-debug-toolbar==4.0.0
|
||||
django-filter==23.1
|
||||
django-filter==23.2
|
||||
django-graphiql-debug-toolbar==0.2.0
|
||||
django-mptt==0.14
|
||||
django-pglocks==1.0.4
|
||||
django-prometheus==2.2.0
|
||||
django-prometheus==2.3.1
|
||||
django-redis==5.2.0
|
||||
django-rich==1.5.0
|
||||
django-rq==2.7.0
|
||||
django-rq==2.8.0
|
||||
django-tables2==2.5.3
|
||||
django-taggit==3.1.0
|
||||
django-taggit==4.0.0
|
||||
django-timezone-field==5.0
|
||||
djangorestframework==3.14.0
|
||||
drf-spectacular==0.26.2
|
||||
drf-spectacular-sidecar==2023.4.1
|
||||
dulwich==0.21.3
|
||||
drf-spectacular-sidecar==2023.5.1
|
||||
dulwich==0.21.5
|
||||
feedparser==6.0.10
|
||||
graphene-django==3.0.0
|
||||
gunicorn==20.1.0
|
||||
Jinja2==3.1.2
|
||||
Markdown==3.3.7
|
||||
mkdocs-material==9.1.8
|
||||
mkdocs-material==9.1.9
|
||||
mkdocstrings[python-legacy]==0.21.2
|
||||
netaddr==0.8.0
|
||||
Pillow==9.5.0
|
||||
psycopg2-binary==2.9.6
|
||||
PyYAML==6.0
|
||||
sentry-sdk==1.21.0
|
||||
sentry-sdk==1.22.1
|
||||
social-auth-app-django==5.2.0
|
||||
social-auth-core[openidconnect]==4.4.2
|
||||
svgwrite==1.4.3
|
||||
|
Loading…
Reference in New Issue
Block a user