Merge branch 'feature' into 10500-nested-modules

This commit is contained in:
Arthur Hanson 2024-07-26 15:25:25 +07:00
commit c7c6e78cb4
31 changed files with 727 additions and 96 deletions

View File

@ -39,3 +39,7 @@ An alternative part number to uniquely identify the module type.
### Weight
The numeric weight of the module, including a unit designation (e.g. 3 kilograms or 1 pound).
### Airflow
The direction in which air circulates through the device chassis for cooling.

View File

@ -54,4 +54,7 @@ The maximum total weight capacity for all installed devices, inclusive of the ra
If selected, the rack's elevation will display unit 1 at the top of the rack. (Most racks use ascending numbering, with unit 1 assigned to the bottommost position.)
### Airflow
The direction in which air circulates through the rack for cooling.

209
netbox/core/plugins.py Normal file
View File

@ -0,0 +1,209 @@
import datetime
import importlib
import importlib.util
from dataclasses import dataclass, field
from typing import Optional
import requests
from django.conf import settings
from django.core.cache import cache
from django.utils.translation import gettext_lazy as _
from netbox.plugins import PluginConfig
from utilities.datetime import datetime_from_timestamp
USER_AGENT_STRING = f'NetBox/{settings.RELEASE.version} {settings.RELEASE.edition}'
@dataclass
class PluginAuthor:
"""
Identifying information for the author of a plugin.
"""
name: str
org_id: str = ''
url: str = ''
@dataclass
class PluginVersion:
"""
Details for a specific versioned release of a plugin.
"""
date: datetime.datetime = None
version: str = ''
netbox_min_version: str = ''
netbox_max_version: str = ''
has_model: bool = False
is_certified: bool = False
is_feature: bool = False
is_integration: bool = False
is_netboxlabs_supported: bool = False
@dataclass
class Plugin:
"""
The representation of a NetBox plugin in the catalog API.
"""
id: str = ''
status: str = ''
title_short: str = ''
title_long: str = ''
tag_line: str = ''
description_short: str = ''
slug: str = ''
author: Optional[PluginAuthor] = None
created_at: datetime.datetime = None
updated_at: datetime.datetime = None
license_type: str = ''
homepage_url: str = ''
package_name_pypi: str = ''
config_name: str = ''
is_certified: bool = False
release_latest: PluginVersion = field(default_factory=PluginVersion)
release_recent_history: list[PluginVersion] = field(default_factory=list)
is_local: bool = False # extra field for locally installed plugins
is_installed: bool = False
installed_version: str = ''
def get_local_plugins():
"""
Return a dictionary of all locally-installed plugins, mapped by name.
"""
plugins = {}
for plugin_name in settings.PLUGINS:
plugin = importlib.import_module(plugin_name)
plugin_config: PluginConfig = plugin.config
plugins[plugin_config.name] = Plugin(
slug=plugin_config.name,
title_short=plugin_config.verbose_name,
tag_line=plugin_config.description,
description_short=plugin_config.description,
is_local=True,
is_installed=True,
installed_version=plugin_config.version,
)
return plugins
def get_catalog_plugins():
"""
Return a dictionary of all entries in the plugins catalog, mapped by name.
"""
session = requests.Session()
plugins = {}
def get_pages():
# TODO: pagination is currently broken in API
payload = {'page': '1', 'per_page': '50'}
first_page = session.get(
settings.PLUGIN_CATALOG_URL,
headers={'User-Agent': USER_AGENT_STRING},
proxies=settings.HTTP_PROXIES,
timeout=3,
params=payload
).json()
yield first_page
num_pages = first_page['metadata']['pagination']['last_page']
for page in range(2, num_pages + 1):
payload['page'] = page
next_page = session.get(
settings.PLUGIN_CATALOG_URL,
headers={'User-Agent': USER_AGENT_STRING},
proxies=settings.HTTP_PROXIES,
timeout=3,
params=payload
).json()
yield next_page
for page in get_pages():
for data in page['data']:
# Populate releases
releases = []
for version in data['release_recent_history']:
releases.append(
PluginVersion(
date=datetime_from_timestamp(version['date']),
version=version['version'],
netbox_min_version=version['netbox_min_version'],
netbox_max_version=version['netbox_max_version'],
has_model=version['has_model'],
is_certified=version['is_certified'],
is_feature=version['is_feature'],
is_integration=version['is_integration'],
is_netboxlabs_supported=version['is_netboxlabs_supported'],
)
)
releases = sorted(releases, key=lambda x: x.date, reverse=True)
latest_release = PluginVersion(
date=datetime_from_timestamp(data['release_latest']['date']),
version=data['release_latest']['version'],
netbox_min_version=data['release_latest']['netbox_min_version'],
netbox_max_version=data['release_latest']['netbox_max_version'],
has_model=data['release_latest']['has_model'],
is_certified=data['release_latest']['is_certified'],
is_feature=data['release_latest']['is_feature'],
is_integration=data['release_latest']['is_integration'],
is_netboxlabs_supported=data['release_latest']['is_netboxlabs_supported'],
)
# Populate author (if any)
if data['author']:
print(data['author'])
author = PluginAuthor(
name=data['author']['name'],
org_id=data['author']['org_id'],
url=data['author']['url'],
)
else:
author = None
# Populate plugin data
plugins[data['slug']] = Plugin(
id=data['id'],
status=data['status'],
title_short=data['title_short'],
title_long=data['title_long'],
tag_line=data['tag_line'],
description_short=data['description_short'],
slug=data['slug'],
author=author,
created_at=datetime_from_timestamp(data['created_at']),
updated_at=datetime_from_timestamp(data['updated_at']),
license_type=data['license_type'],
homepage_url=data['homepage_url'],
package_name_pypi=data['package_name_pypi'],
config_name=data['config_name'],
is_certified=data['is_certified'],
release_latest=latest_release,
release_recent_history=releases,
)
return plugins
def get_plugins():
"""
Return a dictionary of all plugins (both catalog and locally installed), mapped by name.
"""
local_plugins = get_local_plugins()
catalog_plugins = cache.get('plugins-catalog-feed')
if not catalog_plugins:
catalog_plugins = get_catalog_plugins()
cache.set('plugins-catalog-feed', catalog_plugins, 3600)
plugins = catalog_plugins
for k, v in local_plugins.items():
if k in plugins:
plugins[k].is_local = True
plugins[k].is_installed = True
else:
plugins[k] = v
return plugins

View File

@ -1,39 +1,80 @@
import django_tables2 as tables
from django.utils.translation import gettext_lazy as _
from netbox.tables import BaseTable
from netbox.tables import BaseTable, columns
__all__ = (
'PluginTable',
'CatalogPluginTable',
'PluginVersionTable',
)
class PluginTable(BaseTable):
name = tables.Column(
accessor=tables.A('verbose_name'),
verbose_name=_('Name')
)
class PluginVersionTable(BaseTable):
version = tables.Column(
verbose_name=_('Version')
)
package = tables.Column(
accessor=tables.A('name'),
verbose_name=_('Package')
last_updated = columns.DateTimeColumn(
accessor=tables.A('date'),
timespec='minutes',
verbose_name=_('Last Updated')
)
author = tables.Column(
verbose_name=_('Author')
min_version = tables.Column(
accessor=tables.A('netbox_min_version'),
verbose_name=_('Minimum NetBox Version')
)
author_email = tables.Column(
verbose_name=_('Author Email')
)
description = tables.Column(
verbose_name=_('Description')
max_version = tables.Column(
accessor=tables.A('netbox_max_version'),
verbose_name=_('Maximum NetBox Version')
)
class Meta(BaseTable.Meta):
empty_text = _('No plugins found')
empty_text = _('No plugin data found')
fields = (
'name', 'version', 'package', 'author', 'author_email', 'description',
'version', 'last_updated', 'min_version', 'max_version',
)
default_columns = (
'name', 'version', 'package', 'description',
'version', 'last_updated', 'min_version', 'max_version',
)
orderable = False
class CatalogPluginTable(BaseTable):
title_short = tables.Column(
linkify=('core:plugin', [tables.A('slug')]),
verbose_name=_('Name')
)
author = tables.Column(
accessor=tables.A('author.name'),
verbose_name=_('Author')
)
is_local = columns.BooleanColumn(
verbose_name=_('Local')
)
is_installed = columns.BooleanColumn(
verbose_name=_('Installed')
)
is_certified = columns.BooleanColumn(
verbose_name=_('Certified')
)
created_at = columns.DateTimeColumn(
verbose_name=_('Published')
)
updated_at = columns.DateTimeColumn(
verbose_name=_('Updated')
)
installed_version = tables.Column(
verbose_name=_('Installed version')
)
class Meta(BaseTable.Meta):
empty_text = _('No plugin data found')
fields = (
'title_short', 'author', 'is_local', 'is_installed', 'is_certified', 'created_at', 'updated_at',
'installed_version',
)
default_columns = (
'title_short', 'author', 'is_local', 'is_installed', 'is_certified', 'created_at', 'updated_at',
)
# List installed plugins first, then certified plugins, then
# everything else (with each tranche ordered alphabetically)
order_by = ('-is_installed', '-is_certified', 'name')

View File

@ -49,4 +49,8 @@ urlpatterns = (
# System
path('system/', views.SystemView.as_view(), name='system'),
# Plugins
path('plugins/', views.PluginListView.as_view(), name='plugin_list'),
path('plugins/<str:name>/', views.PluginView.as_view(), name='plugin'),
)

View File

@ -2,7 +2,6 @@ import json
import platform
from django import __version__ as DJANGO_VERSION
from django.apps import apps
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.mixins import UserPassesTestMixin
@ -36,6 +35,8 @@ from utilities.query import count_related
from utilities.views import ContentTypePermissionRequiredMixin, GetRelatedModelsMixin, register_model_view
from . import filtersets, forms, tables
from .models import *
from .plugins import get_plugins
from .tables import CatalogPluginTable, PluginVersionTable
#
@ -581,7 +582,7 @@ class WorkerView(BaseRQView):
#
# Plugins
# System
#
class SystemView(UserPassesTestMixin, View):
@ -614,12 +615,6 @@ class SystemView(UserPassesTestMixin, View):
'rq_worker_count': Worker.count(get_connection('default')),
}
# Plugins
plugins = [
# Look up app config by package name
apps.get_app_config(plugin.rsplit('.', 1)[-1]) for plugin in settings.PLUGINS
]
# Configuration
try:
config = ConfigRevision.objects.get(pk=cache.get('config_version'))
@ -631,9 +626,6 @@ class SystemView(UserPassesTestMixin, View):
if 'export' in request.GET:
data = {
**stats,
'plugins': {
plugin.name: plugin.version for plugin in plugins
},
'config': {
k: config.data[k] for k in sorted(config.data)
},
@ -642,11 +634,58 @@ class SystemView(UserPassesTestMixin, View):
response['Content-Disposition'] = 'attachment; filename="netbox.json"'
return response
plugins_table = tables.PluginTable(plugins, orderable=False)
plugins_table.configure(request)
return render(request, 'core/system.html', {
'stats': stats,
'plugins_table': plugins_table,
'config': config,
})
#
# Plugins
#
class PluginListView(UserPassesTestMixin, View):
def test_func(self):
return self.request.user.is_staff
def get(self, request):
q = request.GET.get('q', None)
plugins = get_plugins().values()
if q:
plugins = [obj for obj in plugins if q.casefold() in obj.title_short.casefold()]
table = CatalogPluginTable(plugins, user=request.user)
table.configure(request)
# If this is an HTMX request, return only the rendered table HTML
if htmx_partial(request):
return render(request, 'htmx/table.html', {
'table': table,
})
return render(request, 'core/plugin_list.html', {
'table': table,
})
class PluginView(UserPassesTestMixin, View):
def test_func(self):
return self.request.user.is_staff
def get(self, request, name):
plugins = get_plugins()
if name not in plugins:
raise Http404(_("Plugin {name} not found").format(name=name))
plugin = plugins[name]
table = PluginVersionTable(plugin.release_recent_history, user=request.user)
table.configure(request)
return render(request, 'core/plugin.html', {
'plugin': plugin,
'table': table,
})

View File

@ -62,13 +62,27 @@ class DeviceTypeSerializer(NetBoxModelSerializer):
class ModuleTypeSerializer(NetBoxModelSerializer):
manufacturer = ManufacturerSerializer(nested=True)
weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False, allow_null=True)
manufacturer = ManufacturerSerializer(
nested=True
)
weight_unit = ChoiceField(
choices=WeightUnitChoices,
allow_blank=True,
required=False,
allow_null=True
)
airflow = ChoiceField(
choices=ModuleAirflowChoices,
allow_blank=True,
required=False,
allow_null=True
)
class Meta:
model = ModuleType
fields = [
'id', 'url', 'display_url', 'display', 'manufacturer', 'model', 'part_number', 'weight', 'weight_unit',
'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
'id', 'url', 'display_url', 'display', 'manufacturer', 'model', 'part_number', 'airflow',
'weight', 'weight_unit', 'description', 'comments', 'tags', 'custom_fields',
'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'description')

View File

@ -64,14 +64,19 @@ class RackTypeSerializer(RackBaseSerializer):
manufacturer = ManufacturerSerializer(
nested=True
)
airflow = ChoiceField(
choices=RackAirflowChoices,
allow_blank=True,
required=False
)
class Meta:
model = RackType
fields = [
'id', 'url', 'display_url', 'display', 'manufacturer', 'name', 'slug', 'description', 'form_factor',
'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'weight',
'max_weight', 'weight_unit', 'mounting_depth', 'description', 'comments', 'tags', 'custom_fields',
'created', 'last_updated',
'max_weight', 'weight_unit', 'mounting_depth', 'airflow', 'description', 'comments', 'tags',
'custom_fields', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'manufacturer', 'name', 'slug', 'description')
@ -95,6 +100,11 @@ class RackSerializer(RackBaseSerializer):
choices=RackStatusChoices,
required=False
)
airflow = ChoiceField(
choices=RackAirflowChoices,
allow_blank=True,
required=False
)
role = RackRoleSerializer(
nested=True,
required=False,
@ -124,7 +134,7 @@ class RackSerializer(RackBaseSerializer):
'id', 'url', 'display_url', 'display', 'name', 'facility_id', 'site', 'location', 'tenant', 'status',
'role', 'serial', 'asset_tag', 'rack_type', 'form_factor', 'width', 'u_height', 'starting_unit', 'weight',
'max_weight', 'weight_unit', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth',
'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count',
'airflow', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count',
'powerfeed_count',
]
brief_fields = ('id', 'url', 'display', 'name', 'description', 'device_count')

View File

@ -127,6 +127,17 @@ class RackElevationDetailRenderChoices(ChoiceSet):
)
class RackAirflowChoices(ChoiceSet):
AIRFLOW_FRONT_TO_REAR = 'front-to-rear'
AIRFLOW_REAR_TO_FRONT = 'rear-to-front'
CHOICES = (
(AIRFLOW_FRONT_TO_REAR, _('Front to rear')),
(AIRFLOW_REAR_TO_FRONT, _('Rear to front')),
)
#
# DeviceTypes
#
@ -224,6 +235,25 @@ class ModuleStatusChoices(ChoiceSet):
]
class ModuleAirflowChoices(ChoiceSet):
AIRFLOW_FRONT_TO_REAR = 'front-to-rear'
AIRFLOW_REAR_TO_FRONT = 'rear-to-front'
AIRFLOW_LEFT_TO_RIGHT = 'left-to-right'
AIRFLOW_RIGHT_TO_LEFT = 'right-to-left'
AIRFLOW_SIDE_TO_REAR = 'side-to-rear'
AIRFLOW_PASSIVE = 'passive'
CHOICES = (
(AIRFLOW_FRONT_TO_REAR, _('Front to rear')),
(AIRFLOW_REAR_TO_FRONT, _('Rear to front')),
(AIRFLOW_LEFT_TO_RIGHT, _('Left to right')),
(AIRFLOW_RIGHT_TO_LEFT, _('Right to left')),
(AIRFLOW_SIDE_TO_REAR, _('Side to rear')),
(AIRFLOW_PASSIVE, _('Passive')),
)
#
# ConsolePorts
#

View File

@ -312,7 +312,7 @@ class RackTypeFilterSet(NetBoxModelFilterSet):
model = RackType
fields = (
'id', 'name', 'slug', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit',
'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'description',
'mounting_depth', 'airflow', 'weight', 'max_weight', 'weight_unit', 'description',
)
def search(self, queryset, name, value):
@ -413,7 +413,8 @@ class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe
model = Rack
fields = (
'id', 'name', 'facility_id', 'asset_tag', 'u_height', 'starting_unit', 'desc_units', 'outer_width',
'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'description',
'outer_depth', 'outer_unit', 'mounting_depth', 'airflow', 'weight', 'max_weight', 'weight_unit',
'description',
)
def search(self, queryset, name, value):
@ -698,7 +699,7 @@ class ModuleTypeFilterSet(NetBoxModelFilterSet):
class Meta:
model = ModuleType
fields = ('id', 'model', 'part_number', 'weight', 'weight_unit', 'description')
fields = ('id', 'model', 'part_number', 'airflow', 'weight', 'weight_unit', 'description')
def search(self, queryset, name, value):
if not value.strip():

View File

@ -268,6 +268,11 @@ class RackTypeBulkEditForm(NetBoxModelBulkEditForm):
required=False,
min_value=1
)
airflow = forms.ChoiceField(
label=_('Airflow'),
choices=add_blank_choice(RackAirflowChoices),
required=False
)
weight = forms.DecimalField(
label=_('Weight'),
min_value=0,
@ -293,10 +298,8 @@ class RackTypeBulkEditForm(NetBoxModelBulkEditForm):
model = RackType
fieldsets = (
FieldSet('manufacturer', 'description', 'form_factor', name=_('Rack Type')),
FieldSet('manufacturer', 'description', 'form_factor', 'width', 'u_height', 'airflow', name=_('Rack Type')),
FieldSet(
'width',
'u_height',
InlineFields('outer_width', 'outer_depth', 'outer_unit', label=_('Outer Dimensions')),
InlineFields('weight', 'max_weight', 'weight_unit', label=_('Weight')),
'mounting_depth',
@ -409,6 +412,11 @@ class RackBulkEditForm(NetBoxModelBulkEditForm):
required=False,
min_value=1
)
airflow = forms.ChoiceField(
label=_('Airflow'),
choices=add_blank_choice(RackAirflowChoices),
required=False
)
weight = forms.DecimalField(
label=_('Weight'),
min_value=0,
@ -437,7 +445,7 @@ class RackBulkEditForm(NetBoxModelBulkEditForm):
FieldSet('status', 'role', 'tenant', 'serial', 'asset_tag', 'description', name=_('Rack')),
FieldSet('region', 'site_group', 'site', 'location', name=_('Location')),
FieldSet(
'form_factor', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit',
'form_factor', 'width', 'u_height', 'desc_units', 'airflow', 'outer_width', 'outer_depth', 'outer_unit',
'mounting_depth', name=_('Hardware')
),
FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')),
@ -563,6 +571,11 @@ class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm):
label=_('Part number'),
required=False
)
airflow = forms.ChoiceField(
label=_('Airflow'),
choices=add_blank_choice(ModuleAirflowChoices),
required=False
)
weight = forms.DecimalField(
label=_('Weight'),
min_value=0,
@ -584,7 +597,11 @@ class ModuleTypeBulkEditForm(NetBoxModelBulkEditForm):
model = ModuleType
fieldsets = (
FieldSet('manufacturer', 'part_number', 'description', name=_('Module Type')),
FieldSet('weight', 'weight_unit', name=_('Weight')),
FieldSet(
'airflow',
InlineFields('weight', 'max_weight', 'weight_unit', label=_('Weight')),
name=_('Chassis')
),
)
nullable_fields = ('part_number', 'weight', 'weight_unit', 'description', 'comments')

View File

@ -206,6 +206,12 @@ class RackTypeImportForm(NetBoxModelImportForm):
required=False,
help_text=_('Unit for outer dimensions')
)
airflow = CSVChoiceField(
label=_('Airflow'),
choices=RackAirflowChoices,
required=False,
help_text=_('Airflow direction')
)
weight_unit = CSVChoiceField(
label=_('Weight unit'),
choices=WeightUnitChoices,
@ -217,8 +223,8 @@ class RackTypeImportForm(NetBoxModelImportForm):
model = RackType
fields = (
'manufacturer', 'name', 'slug', 'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units',
'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit',
'description', 'comments', 'tags',
'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'airflow', 'weight', 'max_weight',
'weight_unit', 'description', 'comments', 'tags',
)
def __init__(self, data=None, *args, **kwargs):
@ -273,6 +279,12 @@ class RackImportForm(NetBoxModelImportForm):
required=False,
help_text=_('Unit for outer dimensions')
)
airflow = CSVChoiceField(
label=_('Airflow'),
choices=RackAirflowChoices,
required=False,
help_text=_('Airflow direction')
)
weight_unit = CSVChoiceField(
label=_('Weight unit'),
choices=WeightUnitChoices,
@ -284,8 +296,8 @@ class RackImportForm(NetBoxModelImportForm):
model = Rack
fields = (
'site', 'location', 'name', 'facility_id', 'tenant', 'status', 'role', 'form_factor', 'serial', 'asset_tag',
'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'weight',
'max_weight', 'weight_unit', 'description', 'comments', 'tags',
'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'airflow',
'weight', 'max_weight', 'weight_unit', 'description', 'comments', 'tags',
)
def __init__(self, data=None, *args, **kwargs):
@ -400,6 +412,12 @@ class ModuleTypeImportForm(NetBoxModelImportForm):
queryset=Manufacturer.objects.all(),
to_field_name='name'
)
airflow = CSVChoiceField(
label=_('Airflow'),
choices=ModuleAirflowChoices,
required=False,
help_text=_('Airflow direction')
)
weight = forms.DecimalField(
label=_('Weight'),
required=False,
@ -414,7 +432,7 @@ class ModuleTypeImportForm(NetBoxModelImportForm):
class Meta:
model = ModuleType
fields = ['manufacturer', 'model', 'part_number', 'description', 'weight', 'weight_unit', 'comments', 'tags']
fields = ['manufacturer', 'model', 'part_number', 'description', 'airflow', 'weight', 'weight_unit', 'comments', 'tags']
class DeviceRoleImportForm(NetBoxModelImportForm):

View File

@ -267,6 +267,11 @@ class RackBaseFilterForm(NetBoxModelFilterSetForm):
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
airflow = forms.MultipleChoiceField(
label=_('Airflow'),
choices=add_blank_choice(RackAirflowChoices),
required=False
)
weight = forms.DecimalField(
label=_('Weight'),
required=False,
@ -288,7 +293,7 @@ class RackTypeFilterForm(RackBaseFilterForm):
model = RackType
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('form_factor', 'width', 'u_height', name=_('Rack Type')),
FieldSet('form_factor', 'width', 'u_height', 'airflow', name=_('Rack Type')),
FieldSet('starting_unit', 'desc_units', name=_('Numbering')),
FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')),
)
@ -308,7 +313,7 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, RackBaseFilterFo
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('status', 'role_id', 'serial', 'asset_tag', name=_('Rack')),
FieldSet('form_factor', 'width', 'u_height', name=_('Rack Type')),
FieldSet('form_factor', 'width', 'u_height', 'airflow', name=_('Rack Type')),
FieldSet('starting_unit', 'desc_units', name=_('Numbering')),
FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
@ -578,7 +583,7 @@ class ModuleTypeFilterForm(NetBoxModelFilterSetForm):
model = ModuleType
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('manufacturer_id', 'part_number', name=_('Hardware')),
FieldSet('manufacturer_id', 'part_number', 'airflow', name=_('Hardware')),
FieldSet(
'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces',
'pass_through_ports', name=_('Components')
@ -638,6 +643,11 @@ class ModuleTypeFilterForm(NetBoxModelFilterSetForm):
)
)
tag = TagFilterField(model)
airflow = forms.MultipleChoiceField(
label=_('Airflow'),
choices=add_blank_choice(ModuleAirflowChoices),
required=False
)
weight = forms.DecimalField(
label=_('Weight'),
required=False

View File

@ -211,7 +211,7 @@ class RackTypeForm(NetBoxModelForm):
slug = SlugField()
fieldsets = (
FieldSet('manufacturer', 'name', 'slug', 'description', 'form_factor', 'tags', name=_('Rack Type')),
FieldSet('manufacturer', 'name', 'slug', 'description', 'form_factor', 'airflow', 'tags', name=_('Rack Type')),
FieldSet(
'width', 'u_height',
InlineFields('outer_width', 'outer_depth', 'outer_unit', label=_('Outer Dimensions')),
@ -226,7 +226,7 @@ class RackTypeForm(NetBoxModelForm):
fields = [
'manufacturer', 'name', 'slug', 'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units',
'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit',
'description', 'comments', 'tags',
'airflow', 'description', 'comments', 'tags',
]
@ -268,8 +268,8 @@ class RackForm(TenancyForm, NetBoxModelForm):
fields = [
'site', 'location', 'name', 'facility_id', 'tenant_group', 'tenant', 'status', 'role', 'serial',
'asset_tag', 'rack_type', 'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width',
'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'description',
'comments', 'tags',
'outer_depth', 'outer_unit', 'mounting_depth', 'airflow', 'weight', 'max_weight', 'weight_unit',
'description', 'comments', 'tags',
]
def __init__(self, *args, **kwargs):
@ -290,7 +290,7 @@ class RackForm(TenancyForm, NetBoxModelForm):
self.fieldsets = (
*self.fieldsets,
FieldSet(
'form_factor', 'width', 'starting_unit', 'u_height',
'form_factor', 'width', 'starting_unit', 'u_height', 'airflow',
InlineFields('outer_width', 'outer_depth', 'outer_unit', label=_('Outer Dimensions')),
InlineFields('weight', 'max_weight', 'weight_unit', label=_('Weight')),
'mounting_depth', 'desc_units', name=_('Dimensions')
@ -398,13 +398,14 @@ class ModuleTypeForm(NetBoxModelForm):
fieldsets = (
FieldSet('manufacturer', 'model', 'part_number', 'description', 'tags', name=_('Module Type')),
FieldSet('weight', 'weight_unit', name=_('Weight'))
FieldSet('airflow', 'weight', 'weight_unit', name=_('Chassis'))
)
class Meta:
model = ModuleType
fields = [
'manufacturer', 'model', 'part_number', 'weight', 'weight_unit', 'description', 'comments', 'tags',
'manufacturer', 'model', 'part_number', 'airflow', 'weight', 'weight_unit', 'description',
'comments', 'tags',
]

View File

@ -0,0 +1,28 @@
# Generated by Django 5.0.7 on 2024-07-25 07:00
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0188_racktype'),
]
operations = [
migrations.AddField(
model_name='moduletype',
name='airflow',
field=models.CharField(blank=True, max_length=50),
),
migrations.AddField(
model_name='rack',
name='airflow',
field=models.CharField(blank=True, max_length=50),
),
migrations.AddField(
model_name='racktype',
name='airflow',
field=models.CharField(blank=True, max_length=50),
),
]

View File

@ -388,8 +388,14 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
blank=True,
help_text=_('Discrete part number (optional)')
)
airflow = models.CharField(
verbose_name=_('airflow'),
max_length=50,
choices=ModuleAirflowChoices,
blank=True
)
clone_fields = ('manufacturer', 'weight', 'weight_unit',)
clone_fields = ('manufacturer', 'weight', 'weight_unit', 'airflow')
prerequisite_models = (
'dcim.Manufacturer',
)

View File

@ -53,6 +53,12 @@ class RackBase(WeightMixin, PrimaryModel):
verbose_name=_('width'),
help_text=_('Rail-to-rail width')
)
airflow = models.CharField(
verbose_name=_('airflow'),
max_length=50,
choices=RackAirflowChoices,
blank=True
)
# Numbering
u_height = models.PositiveSmallIntegerField(
@ -232,10 +238,10 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, RackBase):
Each Rack is assigned to a Site and (optionally) a Location.
"""
# Fields which cannot be set locally if a RackType is assigned
RACKTYPE_FIELDS = [
'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit',
'mounting_depth', 'weight', 'weight_unit', 'max_weight'
]
RACKTYPE_FIELDS = (
'form_factor', 'width', 'airflow', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_depth',
'outer_unit', 'mounting_depth', 'weight', 'weight_unit', 'max_weight',
)
rack_type = models.ForeignKey(
to='dcim.RackType',
@ -316,8 +322,8 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, RackBase):
)
clone_fields = (
'site', 'location', 'tenant', 'status', 'role', 'form_factor', 'width', 'u_height', 'desc_units', 'outer_width',
'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit',
'site', 'location', 'tenant', 'status', 'role', 'form_factor', 'width', 'airflow', 'u_height', 'desc_units',
'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit',
)
prerequisite_models = (
'dcim.Site',

View File

@ -40,7 +40,7 @@ class ModuleTypeTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = ModuleType
fields = (
'pk', 'id', 'model', 'manufacturer', 'part_number', 'weight', 'description', 'comments', 'tags',
'pk', 'id', 'model', 'manufacturer', 'part_number', 'airflow', 'weight', 'description', 'comments', 'tags',
)
default_columns = (
'pk', 'model', 'manufacturer', 'part_number',

View File

@ -92,8 +92,8 @@ class RackTypeTable(NetBoxTable):
model = RackType
fields = (
'pk', 'id', 'name', 'manufacturer', 'form_factor', 'u_height', 'starting_unit', 'width', 'outer_width',
'outer_depth', 'mounting_depth', 'weight', 'max_weight', 'description', 'comments', 'tags', 'created',
'last_updated',
'outer_depth', 'mounting_depth', 'airflow', 'weight', 'max_weight', 'description', 'comments', 'tags',
'created', 'last_updated',
)
default_columns = (
'pk', 'name', 'manufacturer', 'type', 'u_height', 'description',
@ -171,7 +171,7 @@ class RackTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
fields = (
'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'tenant_group', 'role', 'serial',
'asset_tag', 'form_factor', 'u_height', 'starting_unit', 'width', 'outer_width', 'outer_depth',
'mounting_depth', 'weight', 'max_weight', 'comments', 'device_count', 'get_utilization',
'mounting_depth', 'airflow', 'weight', 'max_weight', 'comments', 'device_count', 'get_utilization',
'get_power_utilization', 'description', 'contacts', 'tags', 'created', 'last_updated',
)
default_columns = (

View File

@ -437,6 +437,11 @@ ADMIN_MENU = Menu(
link_text=_('System'),
auth_required=True
),
MenuItem(
link='core:plugin_list',
link_text=_('Plugins'),
auth_required=True
),
MenuItem(
link='core:configrevision_list',
link_text=_('Configuration History'),

View File

@ -769,6 +769,8 @@ STRAWBERRY_DJANGO = {
# Plugins
#
PLUGIN_CATALOG_URL = 'https://api.netbox.oss.netboxlabs.com/v1/plugins'
# Register any configured plugins
for plugin_name in PLUGINS:
try:

View File

@ -0,0 +1,29 @@
<p>You can install this plugin from the command line with PyPI.</p>
<p>The following commands may be helpful; always refer to <a href="{{ plugin.homepage_url }}" target="_blank">the plugin's own documentation <i class="mdi mdi-launch"></i></a> and the <a href="https://netboxlabs.com/docs/netbox/en/stable/plugins/installation/" target="_blank">Installing a Plugin unit <i class="mdi mdi-launch"></i></a> of the NetBox documentation.</p>
<p>1. Enter the NetBox virtual environment and install the plugin package:</p>
<pre class="block">
source /opt/netbox/venv/bin/activate
pip install {{ plugin.slug }}
</pre>
<p>2. In /opt/netbox/netbox/netbox/configuration.py, add the plugin to the PLUGINS list:</p>
<pre class="block">
PLUGINS=
"{{ plugin.config_name }}",
]
</pre>
<p>3. Still from the NetBox virtual environment, run database migrations and collect static files:</p>
<pre class="block">
python3 /opt/netbox/netbox/netbox/manage.py migrate
python3 /opt/netbox/netbox/netbox/manage.py collectstatic
</pre>
<p>4. Restart the NetBox services to complete the plugin installation:</p>
<pre class="block">
sudo systemctl restart netbox netbox-rq
</pre>

View File

@ -0,0 +1,113 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load form_helpers %}
{% load i18n %}
{% block title %}{{ plugin.title_short }}{% endblock %}
{% block object_identifier %}
{% endblock object_identifier %}
{% block breadcrumbs %}
<li class="breadcrumb-item"><a href="{% url 'core:plugin_list' %}">{% trans "Plugins" %}</a></li>
{% endblock breadcrumbs %}
{% block subtitle %}
<span class="text-secondary fs-5">
{% checkmark plugin.is_installed %}
{% if plugin.is_installed %}
v{{ plugin.installed_version }} {% trans "installed" %}
{% else %}
{% trans "Not installed" %}
{% endif %}
</span>
{% endblock %}
{% block controls %}{% endblock %}
{% block tabs %}
<ul class="nav nav-tabs" role="tablist">
<li class="nav-item" role="presentation">
<a class="nav-link active" id="overview-tab" data-bs-toggle="tab" data-bs-target="#overview" type="button" role="tab" aria-controls="edit-form" aria-selected="true">
{% trans "Overview" %}
</a>
</li>
{% if True or not plugin.is_local and 'commercial' not in settings.RELEASE.features %}
<li class="nav-item" role="presentation">
<button class="nav-link" id="install-tab" data-bs-toggle="tab" data-bs-target="#install" type="button" role="tab" aria-controls="object-list" aria-selected="false">
{% trans "Install" %}
</button>
</li>
{% endif %}
</ul>
{% endblock tabs %}
{% block content %}
<div class="tab-pane show active" id="overview" role="tabpanel" aria-labelledby="overview-tab">
<div class="row">
<div class="col col-6">
<div class="card">
<h5 class="card-header">{% trans "Plugin Details" %}</h5>
<table class="table table-hover attr-table">
<tr>
<th scope="row">{% trans "Name" %}</th>
<td>{{ plugin.title_short }}</td>
</tr>
<tr>
<th scope="row">{% trans "Summary" %}</th>
<td>{{ plugin.tag_line|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Author" %}</th>
<td>{{ plugin.author.name|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "URL" %}</th>
<td>
{% if plugin.homepage_url %}
<a href="{{ plugin.homepage_url }}">{{ plugin.homepage_url }}</a>
{% else %}
{{ ''|placeholder }}
{% endif %}
</td>
</tr>
<tr>
<th scope="row">{% trans "License" %}</th>
<td>{{ plugin.license_type|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Description" %}</th>
<td>{{ plugin.description_short|markdown }}</td>
</tr>
<tr>
<th scope="row">{% trans "Certified" %}</th>
<td>{% checkmark plugin.is_certified %}</td>
</tr>
<tr>
<th scope="row">{% trans "Local" %}</th>
<td>{% checkmark plugin.is_local %}</td>
</tr>
</table>
</div>
</div>
<div class="col col-6">
<div class="card">
<h5 class="card-header">{% trans "Version History" %}</h5>
<div class="htmx-container table-responsive" id="object_list">
{% include 'htmx/table.html' %}
</div>
</div>
</div>
</div>
</div>
{% if True or not plugin.is_local and 'commercial' not in settings.RELEASE.features %}
<div class="tab-pane" id="install" role="tabpanel" aria-labelledby="install-tab">
<div class="card">
<h5 class="card-header">{% trans "Local Installation Instructions" %}</h5>
<div class="card-body">
{% include 'core/inc/plugin_installation.html' %}
</div>
</div>
</div>
{% endif %}
{% endblock content %}

View File

@ -0,0 +1,16 @@
{% extends 'generic/object_list.html' %}
{% load buttons %}
{% load helpers %}
{% load i18n %}
{% load render_table from django_tables2 %}
{% block title %}{% trans "Plugins" %}{% endblock %}
{% block tabs %}
<ul class="nav nav-tabs px-3">
<li class="nav-item" role="presentation">
<a class="nav-link active" role="tab">{% trans "Plugins" %}</a>
</li>
</ul>
{% endblock tabs %}

View File

@ -78,16 +78,6 @@
</div>
</div>
{# Plugins #}
<div class="row mb-3">
<div class="col col-md-12">
<div class="card">
<h5 class="card-header">{% trans "Plugins" %}</h5>
{% render_table plugins_table %}
</div>
</div>
</div>
{# Configuration #}
<div class="row mb-3">
<div class="col col-md-12">

View File

@ -26,6 +26,12 @@
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Airflow" %}</th>
<td>
{{ object.get_airflow_display|placeholder }}
</td>
</tr>
<tr>
<th scope="row">{% trans "Weight" %}</th>
<td>

View File

@ -61,6 +61,10 @@
<th scope="row">{% trans "Asset Tag" %}</th>
<td class="font-monospace">{{ object.asset_tag|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Airflow" %}</th>
<td>{{ object.get_airflow_display|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Space Utilization" %}</th>
<td>{% utilization_graph object.get_utilization %}</td>

View File

@ -24,6 +24,10 @@
<th scope="row">{% trans "Description" %}</th>
<td>{{ object.description|placeholder }}</td>
</tr>
<tr>
<th scope="row">{% trans "Airflow" %}</th>
<td>{{ object.get_airflow_display|placeholder }}</td>
</tr>
</table>
</div>
{% include 'dcim/inc/panels/racktype_dimensions.html' %}

View File

@ -1,11 +1,18 @@
{% load i18n %}
{% load navigation %}
{% if 'help-center' in settings.RELEASE.features %}
{# Help center control #}
<a href="#" class="nav-link px-1" aria-label="{% trans "Help center" %}">
<i class="mdi mdi-forum-outline"></i>
</a>
{% endif %}
{% if request.user.is_authenticated %}
{# Notifications #}
{% with notifications=request.user.notifications.unread.exists %}
<div class="nav-item dropdown">
<a href="#" class="nav-link" data-bs-toggle="dropdown" hx-get="{% url 'extras:notifications' %}" hx-target="next .notifications" aria-label="Notifications">
<div class="dropdown">
<a href="#" class="nav-link px-1" data-bs-toggle="dropdown" hx-get="{% url 'extras:notifications' %}" hx-target="next .notifications" aria-label="{% trans "Notifications" %}">
{% include 'inc/notification_bell.html' %}
</a>
<div class="dropdown-menu dropdown-menu-end dropdown-menu-arrow notifications"></div>

View File

@ -1,4 +1,4 @@
from django.urls import include, path
from django.urls import include, path, re_path
from utilities.urls import get_model_urls
from . import views
@ -49,12 +49,16 @@ urlpatterns = [
path('virtual-machines/interfaces/add/', views.VirtualMachineBulkAddInterfaceView.as_view(), name='virtualmachine_bulk_add_vminterface'),
# Virtual disks
path('disks/', views.VirtualDiskListView.as_view(), name='virtualdisk_list'),
path('disks/add/', views.VirtualDiskCreateView.as_view(), name='virtualdisk_add'),
path('disks/import/', views.VirtualDiskBulkImportView.as_view(), name='virtualdisk_import'),
path('disks/edit/', views.VirtualDiskBulkEditView.as_view(), name='virtualdisk_bulk_edit'),
path('disks/rename/', views.VirtualDiskBulkRenameView.as_view(), name='virtualdisk_bulk_rename'),
path('disks/delete/', views.VirtualDiskBulkDeleteView.as_view(), name='virtualdisk_bulk_delete'),
path('disks/<int:pk>/', include(get_model_urls('virtualization', 'virtualdisk'))),
path('virtual-disks/', views.VirtualDiskListView.as_view(), name='virtualdisk_list'),
path('virtual-disks/add/', views.VirtualDiskCreateView.as_view(), name='virtualdisk_add'),
path('virtual-disks/import/', views.VirtualDiskBulkImportView.as_view(), name='virtualdisk_import'),
path('virtual-disks/edit/', views.VirtualDiskBulkEditView.as_view(), name='virtualdisk_bulk_edit'),
path('virtual-disks/rename/', views.VirtualDiskBulkRenameView.as_view(), name='virtualdisk_bulk_rename'),
path('virtual-disks/delete/', views.VirtualDiskBulkDeleteView.as_view(), name='virtualdisk_bulk_delete'),
path('virtual-disks/<int:pk>/', include(get_model_urls('virtualization', 'virtualdisk'))),
path('virtual-machines/disks/add/', views.VirtualMachineBulkAddVirtualDiskView.as_view(), name='virtualmachine_bulk_add_virtualdisk'),
# TODO: Remove in v4.2
# Redirect old (pre-v4.1) URLs for VirtualDisk views
re_path('disks/(?P<path>[a-z0-9/-]*)', views.VirtualDiskRedirectView.as_view()),
]

View File

@ -7,6 +7,7 @@ from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils.translation import gettext as _
from django.views.generic.base import RedirectView
from jinja2.exceptions import TemplateError
from dcim.filtersets import DeviceFilterSet
@ -630,6 +631,15 @@ class VirtualDiskBulkDeleteView(generic.BulkDeleteView):
table = tables.VirtualDiskTable
# TODO: Remove in v4.2
class VirtualDiskRedirectView(RedirectView):
"""
Redirect old (pre-v4.1) URLs for VirtualDisk views.
"""
def get_redirect_url(self, path):
return f"{reverse('virtualization:virtualdisk_list')}{path}"
#
# Bulk Device component creation
#