diff --git a/docs/models/dcim/moduletype.md b/docs/models/dcim/moduletype.md index 3122d2e00..7077e16c2 100644 --- a/docs/models/dcim/moduletype.md +++ b/docs/models/dcim/moduletype.md @@ -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. diff --git a/docs/models/dcim/racktype.md b/docs/models/dcim/racktype.md index d44e17b17..476dbe178 100644 --- a/docs/models/dcim/racktype.md +++ b/docs/models/dcim/racktype.md @@ -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. diff --git a/netbox/core/plugins.py b/netbox/core/plugins.py new file mode 100644 index 000000000..ab8fd97a1 --- /dev/null +++ b/netbox/core/plugins.py @@ -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 diff --git a/netbox/core/tables/plugins.py b/netbox/core/tables/plugins.py index 21e90cd6b..529fe60f4 100644 --- a/netbox/core/tables/plugins.py +++ b/netbox/core/tables/plugins.py @@ -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') diff --git a/netbox/core/urls.py b/netbox/core/urls.py index 58e96d735..fd6ec8996 100644 --- a/netbox/core/urls.py +++ b/netbox/core/urls.py @@ -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//', views.PluginView.as_view(), name='plugin'), ) diff --git a/netbox/core/views.py b/netbox/core/views.py index 508401585..79ef33e1d 100644 --- a/netbox/core/views.py +++ b/netbox/core/views.py @@ -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, + }) diff --git a/netbox/dcim/api/serializers_/devicetypes.py b/netbox/dcim/api/serializers_/devicetypes.py index a755247db..cda738862 100644 --- a/netbox/dcim/api/serializers_/devicetypes.py +++ b/netbox/dcim/api/serializers_/devicetypes.py @@ -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') diff --git a/netbox/dcim/api/serializers_/racks.py b/netbox/dcim/api/serializers_/racks.py index 4fb96e08c..17c1c174e 100644 --- a/netbox/dcim/api/serializers_/racks.py +++ b/netbox/dcim/api/serializers_/racks.py @@ -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') diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 464c396ff..3575aeeec 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -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 # diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 79232a474..943ad5527 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -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(): diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 4a017dd46..bc0f0bd71 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -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') diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 7f7efecf6..785b4fb42 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -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): diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 5d16a7b39..c8124aa11 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -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 diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index c66895788..ce79088d0 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -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', ] diff --git a/netbox/dcim/migrations/0189_moduletype_airflow_rack_airflow_racktype_airflow.py b/netbox/dcim/migrations/0189_moduletype_airflow_rack_airflow_racktype_airflow.py new file mode 100644 index 000000000..370df90e8 --- /dev/null +++ b/netbox/dcim/migrations/0189_moduletype_airflow_rack_airflow_racktype_airflow.py @@ -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), + ), + ] diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index abc9e0b08..a790cceef 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -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', ) diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index e6487c705..cfa8f28be 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -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', diff --git a/netbox/dcim/tables/modules.py b/netbox/dcim/tables/modules.py index 0cd9e438e..5b06e08b2 100644 --- a/netbox/dcim/tables/modules.py +++ b/netbox/dcim/tables/modules.py @@ -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', diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py index d269681c5..064a5a43d 100644 --- a/netbox/dcim/tables/racks.py +++ b/netbox/dcim/tables/racks.py @@ -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 = ( diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index 44f212f9c..4a9d103c7 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -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'), diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 64fb24f09..b2ce3eacd 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -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: diff --git a/netbox/templates/core/inc/plugin_installation.html b/netbox/templates/core/inc/plugin_installation.html new file mode 100644 index 000000000..f1878fb97 --- /dev/null +++ b/netbox/templates/core/inc/plugin_installation.html @@ -0,0 +1,29 @@ +

You can install this plugin from the command line with PyPI.

+

The following commands may be helpful; always refer to the plugin's own documentation and the Installing a Plugin unit of the NetBox documentation.

+

1. Enter the NetBox virtual environment and install the plugin package:

+ +
+source /opt/netbox/venv/bin/activate
+pip install {{ plugin.slug }}
+
+ +

2. In /opt/netbox/netbox/netbox/configuration.py, add the plugin to the PLUGINS list:

+ +
+PLUGINS=[
+"{{ plugin.config_name }}",
+]
+
+ +

3. Still from the NetBox virtual environment, run database migrations and collect static files:

+ +
+python3 /opt/netbox/netbox/netbox/manage.py migrate
+python3 /opt/netbox/netbox/netbox/manage.py collectstatic
+
+ +

4. Restart the NetBox services to complete the plugin installation:

+ +
+sudo systemctl restart netbox netbox-rq
+
diff --git a/netbox/templates/core/plugin.html b/netbox/templates/core/plugin.html new file mode 100644 index 000000000..94307dc14 --- /dev/null +++ b/netbox/templates/core/plugin.html @@ -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 %} + +{% endblock breadcrumbs %} + +{% block subtitle %} + + {% checkmark plugin.is_installed %} + {% if plugin.is_installed %} + v{{ plugin.installed_version }} {% trans "installed" %} + {% else %} + {% trans "Not installed" %} + {% endif %} + +{% endblock %} + +{% block controls %}{% endblock %} + +{% block tabs %} + +{% endblock tabs %} + +{% block content %} +
+
+
+
+
{% trans "Plugin Details" %}
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{% trans "Name" %}{{ plugin.title_short }}
{% trans "Summary" %}{{ plugin.tag_line|placeholder }}
{% trans "Author" %}{{ plugin.author.name|placeholder }}
{% trans "URL" %} + {% if plugin.homepage_url %} + {{ plugin.homepage_url }} + {% else %} + {{ ''|placeholder }} + {% endif %} +
{% trans "License" %}{{ plugin.license_type|placeholder }}
{% trans "Description" %}{{ plugin.description_short|markdown }}
{% trans "Certified" %}{% checkmark plugin.is_certified %}
{% trans "Local" %}{% checkmark plugin.is_local %}
+
+
+
+
+
{% trans "Version History" %}
+
+ {% include 'htmx/table.html' %} +
+
+
+
+
+ {% if True or not plugin.is_local and 'commercial' not in settings.RELEASE.features %} +
+
+
{% trans "Local Installation Instructions" %}
+
+ {% include 'core/inc/plugin_installation.html' %} +
+
+
+ {% endif %} +{% endblock content %} diff --git a/netbox/templates/core/plugin_list.html b/netbox/templates/core/plugin_list.html new file mode 100644 index 000000000..47a6c3686 --- /dev/null +++ b/netbox/templates/core/plugin_list.html @@ -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 %} + +{% endblock tabs %} + diff --git a/netbox/templates/core/system.html b/netbox/templates/core/system.html index 6f88643a0..5a7088e96 100644 --- a/netbox/templates/core/system.html +++ b/netbox/templates/core/system.html @@ -78,16 +78,6 @@ - {# Plugins #} -
-
-
-
{% trans "Plugins" %}
- {% render_table plugins_table %} -
-
-
- {# Configuration #}
diff --git a/netbox/templates/dcim/moduletype.html b/netbox/templates/dcim/moduletype.html index 77feece97..3ddeea89e 100644 --- a/netbox/templates/dcim/moduletype.html +++ b/netbox/templates/dcim/moduletype.html @@ -26,6 +26,12 @@ {% trans "Description" %} {{ object.description|placeholder }} + + {% trans "Airflow" %} + + {{ object.get_airflow_display|placeholder }} + + {% trans "Weight" %} diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 9bf2f1827..f69abe77a 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -61,6 +61,10 @@ {% trans "Asset Tag" %} {{ object.asset_tag|placeholder }} + + {% trans "Airflow" %} + {{ object.get_airflow_display|placeholder }} + {% trans "Space Utilization" %} {% utilization_graph object.get_utilization %} diff --git a/netbox/templates/dcim/racktype.html b/netbox/templates/dcim/racktype.html index 0c82b13d1..c4e445146 100644 --- a/netbox/templates/dcim/racktype.html +++ b/netbox/templates/dcim/racktype.html @@ -24,6 +24,10 @@ {% trans "Description" %} {{ object.description|placeholder }} + + {% trans "Airflow" %} + {{ object.get_airflow_display|placeholder }} +
{% include 'dcim/inc/panels/racktype_dimensions.html' %} diff --git a/netbox/templates/inc/user_menu.html b/netbox/templates/inc/user_menu.html index ab2c31239..2ee913b5c 100644 --- a/netbox/templates/inc/user_menu.html +++ b/netbox/templates/inc/user_menu.html @@ -1,11 +1,18 @@ {% load i18n %} {% load navigation %} +{% if 'help-center' in settings.RELEASE.features %} + {# Help center control #} + + + +{% endif %} + {% if request.user.is_authenticated %} {# Notifications #} {% with notifications=request.user.notifications.unread.exists %} -