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

View File

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

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)
* 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.

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)
* `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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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