diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 672f43b09..5e456d0df 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -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.3 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml index 1f8fdebd4..e6a5e76c2 100644 --- a/.github/ISSUE_TEMPLATE/config.yml +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -3,10 +3,13 @@ blank_issues_enabled: false contact_links: - name: 📖 Contributing Policy url: https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md - about: "Please read through our contributing policy before opening an issue or pull request" + about: "Please read through our contributing policy before opening an issue or pull request." - name: ❓ Discussion url: https://github.com/netbox-community/netbox/discussions - about: "If you're just looking for help, try starting a discussion instead" + about: "If you're just looking for help, try starting a discussion instead." + - name: 💡 Plugin Idea + url: https://plugin-ideas.netbox.dev + about: "Have an idea for a plugin? Head over to the ideas board!" - name: 💬 Community Slack - url: https://netdev.chat/ - about: "Join #netbox on the NetDev Community Slack for assistance with installation issues and other problems" + url: https://netdev.chat + about: "Join #netbox on the NetDev Community Slack for assistance with installation issues and other problems." diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index 842454d6b..e317dd64c 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -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.3 validations: required: true - type: dropdown diff --git a/README.md b/README.md index 480f0f856..6e2b34fb8 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,10 @@
NetBox logo - - The premiere source of truth powering network automation +

The premiere source of truth powering network automation

+ CI status +

-![Master branch build status](https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master) - NetBox is the leading solution for modeling and documenting modern networks. By combining the traditional disciplines of IP address management (IPAM) and datacenter infrastructure management (DCIM) with powerful APIs and extensions, diff --git a/base_requirements.txt b/base_requirements.txt index 1e9a45048..40e0224e2 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -84,7 +84,8 @@ feedparser # Django wrapper for Graphene (GraphQL support) # https://github.com/graphql-python/graphene-django/releases -graphene_django +# Pinned to v3.0.0 for GraphiQL UI issue (see #12762) +graphene_django==3.0.0 # WSGI HTTP server # https://docs.gunicorn.org/en/latest/news.html diff --git a/docs/administration/netbox-shell.md b/docs/administration/netbox-shell.md index 1a10c5c3e..21cef01b2 100644 --- a/docs/administration/netbox-shell.md +++ b/docs/administration/netbox-shell.md @@ -153,15 +153,10 @@ New objects can be created by instantiating the desired model, defining values f ``` >>> lab1 = Site.objects.get(pk=7) >>> myvlan = VLAN(vid=123, name='MyNewVLAN', site=lab1) +>>> myvlan.full_clean() >>> myvlan.save() ``` -Alternatively, the above can be performed as a single operation. (Note, however, that `save()` does _not_ return the new instance for reuse.) - -``` ->>> VLAN(vid=123, name='MyNewVLAN', site=Site.objects.get(pk=7)).save() -``` - To modify an existing object, we retrieve it, update the desired field(s), and call `save()` again. ``` @@ -169,6 +164,7 @@ To modify an existing object, we retrieve it, update the desired field(s), and c >>> vlan.name 'MyNewVLAN' >>> vlan.name = 'BetterName' +>>> vlan.full_clean() >>> vlan.save() >>> VLAN.objects.get(pk=1280).name 'BetterName' diff --git a/docs/configuration/miscellaneous.md b/docs/configuration/miscellaneous.md index 14f0b9151..fd410a9d4 100644 --- a/docs/configuration/miscellaneous.md +++ b/docs/configuration/miscellaneous.md @@ -29,6 +29,17 @@ This defines custom content to be displayed on the login page above the login fo --- +## BANNER_MAINTENANCE + +!!! tip "Dynamic Configuration Parameter" + +!!! note + This parameter was added in NetBox v3.5. + +This adds a banner to the top of every page when maintenance mode is enabled. HTML is allowed. + +--- + ## BANNER_TOP !!! tip "Dynamic Configuration Parameter" @@ -129,7 +140,7 @@ Setting this to True will display a "maintenance mode" banner at the top of ever Default: `https://maps.google.com/?q=` (Google Maps) -This specifies the URL to use when presenting a map of a physical location by street address or GPS coordinates. The URL must accept either a free-form street address or a comma-separated pair of numeric coordinates appended to it. +This specifies the URL to use when presenting a map of a physical location by street address or GPS coordinates. The URL must accept either a free-form street address or a comma-separated pair of numeric coordinates appended to it. Set this to `None` to disable the "map it" button within the UI. --- @@ -193,3 +204,25 @@ This parameter defines the URL of the repository that will be checked for new Ne Default: `300` The maximum execution time of a background task (such as running a custom script), in seconds. + +--- + +## RQ_RETRY_INTERVAL + +!!! note + This parameter was added in NetBox v3.5. + +Default: `60` + +This parameter controls how frequently a failed job is retried, up to the maximum number of times specified by `RQ_RETRY_MAX`. This must be either an integer specifying the number of seconds to wait between successive attempts, or a list of such values. For example, `[60, 300, 3600]` will retry the task after 1 minute, 5 minutes, and 1 hour. + +--- + +## RQ_RETRY_MAX + +!!! note + This parameter was added in NetBox v3.5. + +Default: `0` (retries disabled) + +The maximum number of times a background task will be retried before being marked as failed. diff --git a/docs/configuration/remote-authentication.md b/docs/configuration/remote-authentication.md index fd95adef5..fb789bd98 100644 --- a/docs/configuration/remote-authentication.md +++ b/docs/configuration/remote-authentication.md @@ -4,6 +4,14 @@ The configuration parameters listed here control remote authentication for NetBo --- +## REMOTE_AUTH_AUTO_CREATE_GROUPS + +Default: `False` + +If true, NetBox will automatically create groups specified in the `REMOTE_AUTH_GROUP_HEADER` header if they don't already exist. (Requires `REMOTE_AUTH_ENABLED`.) + +--- + ## REMOTE_AUTH_AUTO_CREATE_USER Default: `False` diff --git a/docs/customization/custom-scripts.md b/docs/customization/custom-scripts.md index 074da34cd..e20b09ae6 100644 --- a/docs/customization/custom-scripts.md +++ b/docs/customization/custom-scripts.md @@ -378,6 +378,7 @@ class NewBranchScript(Script): slug=slugify(data['site_name']), status=SiteStatusChoices.STATUS_PLANNED ) + site.full_clean() site.save() self.log_success(f"Created new site: {site}") @@ -391,6 +392,7 @@ class NewBranchScript(Script): status=DeviceStatusChoices.STATUS_PLANNED, device_role=switch_role ) + switch.full_clean() switch.save() self.log_success(f"Created new switch: {switch}") diff --git a/docs/installation/3-netbox.md b/docs/installation/3-netbox.md index dc6c38977..e0e656ce9 100644 --- a/docs/installation/3-netbox.md +++ b/docs/installation/3-netbox.md @@ -100,6 +100,8 @@ Create a system user account named `netbox`. We'll configure the WSGI and HTTP s ``` sudo adduser --system --group netbox sudo chown --recursive netbox /opt/netbox/netbox/media/ + sudo chown --recursive netbox /opt/netbox/netbox/reports/ + sudo chown --recursive netbox /opt/netbox/netbox/scripts/ ``` === "CentOS" @@ -108,6 +110,8 @@ Create a system user account named `netbox`. We'll configure the WSGI and HTTP s sudo groupadd --system netbox sudo adduser --system -g netbox netbox sudo chown --recursive netbox /opt/netbox/netbox/media/ + sudo chown --recursive netbox /opt/netbox/netbox/reports/ + sudo chown --recursive netbox /opt/netbox/netbox/scripts/ ``` ## Configuration diff --git a/docs/installation/6-ldap.md b/docs/installation/6-ldap.md index ffba6889b..1cd3e1f0a 100644 --- a/docs/installation/6-ldap.md +++ b/docs/installation/6-ldap.md @@ -15,7 +15,7 @@ sudo apt install -y libldap2-dev libsasl2-dev libssl-dev On CentOS: ```no-highlight -sudo yum install -y openldap-devel +sudo yum install -y openldap-devel python3-devel ``` ### Install django-auth-ldap diff --git a/docs/integrations/rest-api.md b/docs/integrations/rest-api.md index af2f86e4c..cf3d11126 100644 --- a/docs/integrations/rest-api.md +++ b/docs/integrations/rest-api.md @@ -63,7 +63,7 @@ Each attribute of the IP address is expressed as an attribute of the JSON object ## Interactive Documentation -Comprehensive, interactive documentation of all REST API endpoints is available on a running NetBox instance at `/api/docs/`. This interface provides a convenient sandbox for researching and experimenting with specific endpoints and request types. The API itself can also be explored using a web browser by navigating to its root at `/api/`. +Comprehensive, interactive documentation of all REST API endpoints is available on a running NetBox instance at `/api/schema/swagger-ui/`. This interface provides a convenient sandbox for researching and experimenting with specific endpoints and request types. The API itself can also be explored using a web browser by navigating to its root at `/api/`. ## Endpoint Hierarchy diff --git a/docs/models/extras/customfield.md b/docs/models/extras/customfield.md index 0d92ec656..df0408f7c 100644 --- a/docs/models/extras/customfield.md +++ b/docs/models/extras/customfield.md @@ -68,11 +68,12 @@ Defines how filters are evaluated against custom field values. Controls how and whether the custom field is displayed within the NetBox user interface. -| Option | Description | -|------------|--------------------------------------| -| Read/write | Display and permit editing (default) | -| Read-only | Display field but disallow editing | -| Hidden | Do not display field in the UI | +| Option | Description | +|-------------------|--------------------------------------------------| +| Read/write | Display and permit editing (default) | +| Read-only | Display field but disallow editing | +| Hidden | Do not display field in the UI | +| Hidden (if unset) | Display in the UI only when a value has been set | ### Default diff --git a/docs/release-notes/version-3.5.md b/docs/release-notes/version-3.5.md index ebf47800e..e0c34bb40 100644 --- a/docs/release-notes/version-3.5.md +++ b/docs/release-notes/version-3.5.md @@ -1,15 +1,94 @@ # NetBox v3.5 -## v3.5.1 (FUTURE) +## v3.5.4 (FUTURE) -## Enhancements +--- -* [#10759](https://github.com/netbox-community/netbox/issues/10759) - Support Markdown rendering for custom field descriptions -* [#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 +## v3.5.3 (2023-06-02) + +### Enhancements + +* [#9876](https://github.com/netbox-community/netbox/issues/9876) - Improve support for matching tags in conditional rules +* [#12015](https://github.com/netbox-community/netbox/issues/12015) - Add device type & role filters for device components +* [#12470](https://github.com/netbox-community/netbox/issues/12470) - Collapse context data by default when viewing a rendered device configuration +* [#12562](https://github.com/netbox-community/netbox/issues/12562) - Record client IP address when logging authentication failures +* [#12597](https://github.com/netbox-community/netbox/issues/12597) - Add an option to hide custom fields only if unset +* [#12599](https://github.com/netbox-community/netbox/issues/12599) - Apply filter parameters to links in object count dashboard widgets ### Bug Fixes +* [#7503](https://github.com/netbox-community/netbox/issues/7503) - Improve rack space validation when creating multiple devices via REST API +* [#11539](https://github.com/netbox-community/netbox/issues/11539) - Fix exception when applying "empty" filter lookup with invalid value +* [#11934](https://github.com/netbox-community/netbox/issues/11934) - Prevent reassignment of an IP address designated as primary for its parent object +* [#12538](https://github.com/netbox-community/netbox/issues/12538) - Redirect user to originating view after editing/deleting an image attachment +* [#12627](https://github.com/netbox-community/netbox/issues/12627) - Restore hover preview for embedded image attachment tables +* [#12694](https://github.com/netbox-community/netbox/issues/12694) - Strip leading & trailing whitespace from custom link URL & text +* [#12702](https://github.com/netbox-community/netbox/issues/12702) - Fix sizing of rear port selection widget on front port template creation form +* [#12715](https://github.com/netbox-community/netbox/issues/12715) - Use contact assignments table to display the contacts assigned to an object +* [#12730](https://github.com/netbox-community/netbox/issues/12730) - Fix extraneous contacts listed in object contact assignments view +* [#12742](https://github.com/netbox-community/netbox/issues/12742) - Object counts dashboard widget should support URL-compatible query filters +* [#12762](https://github.com/netbox-community/netbox/issues/12762) - Fix GraphiQL UI by reverting graphene-django to earlier version +* [#12745](https://github.com/netbox-community/netbox/issues/12745) - Escape display text in API-backed selection widgets +* [#12779](https://github.com/netbox-community/netbox/issues/12779) - Correct arithmetic for converting inches to meters + +--- + +## v3.5.2 (2023-05-22) + +### Enhancements + +* [#7671](https://github.com/netbox-community/netbox/issues/7671) - Introduce `REMOTE_AUTH_AUTO_CREATE_GROUPS` config parameter to enable the automatic creation of new groups when remote authentication is in use +* [#9068](https://github.com/netbox-community/netbox/issues/9068) - Disallow the assignment of network/broadcast IP addresses to interfaces +* [#11017](https://github.com/netbox-community/netbox/issues/11017) - Increase the maximum values for allocated and maximum power draws +* [#11233](https://github.com/netbox-community/netbox/issues/11233) - Intercept and cleanly report errors upon attempted database writes when maintenance mode is enabled +* [#11599](https://github.com/netbox-community/netbox/issues/11599) - Move contacts panels to separate tabs under object views +* [#11670](https://github.com/netbox-community/netbox/issues/11670) - Enable setting device type & module type weight via bulk import +* [#11900](https://github.com/netbox-community/netbox/issues/11900) - Add an outline to the reservation markers on rack elevations +* [#12131](https://github.com/netbox-community/netbox/issues/12131) - Show custom field description as an icon tooltip under object views +* [#12223](https://github.com/netbox-community/netbox/issues/12223) - Add columns for parent device bay and position to devices list +* [#12233](https://github.com/netbox-community/netbox/issues/12233) - Move related IP addresses table to a separate tab +* [#12286](https://github.com/netbox-community/netbox/issues/12286) - Show height and total weight under device view +* [#12323](https://github.com/netbox-community/netbox/issues/12323) - Add 100GE CXP interface type +* [#12327](https://github.com/netbox-community/netbox/issues/12327) - Introduce the ability to automatically retry failed background jobs +* [#12498](https://github.com/netbox-community/netbox/issues/12498) - Hide map button if `MAPS_URL` is empty +* [#12548](https://github.com/netbox-community/netbox/issues/12548) - Optimize REST API performance when retrieving interfaces with L2VPN assignments +* [#12554](https://github.com/netbox-community/netbox/issues/12554) - Allow customization or disabling of the maintenance mode banner +* [#12605](https://github.com/netbox-community/netbox/issues/12605) - Add LX.5 port types +* [#12629](https://github.com/netbox-community/netbox/issues/12629) - Add 400GE CDFP and CFP8 interface types +* [#12678](https://github.com/netbox-community/netbox/issues/12678) - Add 200GE QSFP-DD interface type + +### Bug Fixes + +* [#10686](https://github.com/netbox-community/netbox/issues/10686) - Enable specifying termination object by virtual chassis master when importing cables +* [#11619](https://github.com/netbox-community/netbox/issues/11619) - Enable assigning VLANs without a site to interfaces during bulk edit +* [#12468](https://github.com/netbox-community/netbox/issues/12468) - Custom field names should not permit double underscores +* [#12550](https://github.com/netbox-community/netbox/issues/12550) - Fix rear port selection widget under front port creation form +* [#12570](https://github.com/netbox-community/netbox/issues/12570) - Disable ordering of synchronized object tables by the "synced" attribute +* [#12594](https://github.com/netbox-community/netbox/issues/12594) - Enable selecting config context as object type in object counts dashboard widget +* [#12642](https://github.com/netbox-community/netbox/issues/12642) - Fix bulk tenant assignment via cluster import form + +--- + +## 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 @@ -19,10 +98,16 @@ * [#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 --- diff --git a/netbox/circuits/views.py b/netbox/circuits/views.py index e5f4faee1..f1cfdd1d5 100644 --- a/netbox/circuits/views.py +++ b/netbox/circuits/views.py @@ -1,10 +1,10 @@ from django.contrib import messages from django.db import transaction -from django.db.models import Q from django.shortcuts import get_object_or_404, redirect, render from dcim.views import PathTraceView from netbox.views import generic +from tenancy.views import ObjectContactsView from utilities.forms import ConfirmationForm from utilities.utils import count_related from utilities.views import register_model_view @@ -73,6 +73,11 @@ class ProviderBulkDeleteView(generic.BulkDeleteView): table = tables.ProviderTable +@register_model_view(Provider, 'contacts') +class ProviderContactsView(ObjectContactsView): + queryset = Provider.objects.all() + + # # ProviderAccounts # @@ -134,6 +139,11 @@ class ProviderAccountBulkDeleteView(generic.BulkDeleteView): table = tables.ProviderAccountTable +@register_model_view(ProviderAccount, 'contacts') +class ProviderAccountContactsView(ObjectContactsView): + queryset = ProviderAccount.objects.all() + + # # Provider networks # @@ -389,6 +399,11 @@ class CircuitSwapTerminations(generic.ObjectEditView): }) +@register_model_view(Circuit, 'contacts') +class CircuitContactsView(ObjectContactsView): + queryset = Circuit.objects.all() + + # # Circuit terminations # diff --git a/netbox/core/data_backends.py b/netbox/core/data_backends.py index 085598372..6cc534774 100644 --- a/netbox/core/data_backends.py +++ b/netbox/core/data_backends.py @@ -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 @@ -91,7 +91,7 @@ class GitBackend(DataBackend): def fetch(self): local_path = tempfile.TemporaryDirectory() - config = StackedConfig.default() + config = ConfigDict() clone_args = { "branch": self.params.get('branch'), "config": config, diff --git a/netbox/core/models/jobs.py b/netbox/core/models/jobs.py index 1d0eecd21..a91e75e61 100644 --- a/netbox/core/models/jobs.py +++ b/netbox/core/models/jobs.py @@ -16,7 +16,7 @@ from extras.utils import FeatureQuery from netbox.config import get_config from netbox.constants import RQ_QUEUE_DEFAULT from utilities.querysets import RestrictedQuerySet -from utilities.rqworker import get_queue_for_model +from utilities.rqworker import get_queue_for_model, get_rq_retry __all__ = ( 'Job', @@ -219,5 +219,6 @@ class Job(models.Model): event=event, data=self.data, timestamp=str(timezone.now()), - username=self.user.username + username=self.user.username, + retry=get_rq_retry() ) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 0d9fcdc5f..5b87c4e5d 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -1,12 +1,12 @@ from django.http import Http404, HttpResponse from django.shortcuts import get_object_or_404 -from drf_spectacular.utils import extend_schema, extend_schema_view, OpenApiParameter from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import extend_schema, OpenApiParameter from rest_framework.decorators import action from rest_framework.renderers import JSONRenderer from rest_framework.response import Response -from rest_framework.status import HTTP_400_BAD_REQUEST from rest_framework.routers import APIRootView +from rest_framework.status import HTTP_400_BAD_REQUEST from rest_framework.viewsets import ViewSet from circuits.models import Circuit @@ -14,7 +14,6 @@ from dcim import filtersets from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH from dcim.models import * from dcim.svg import CableTraceSVG -from extras.api.nested_serializers import NestedConfigTemplateSerializer from extras.api.mixins import ConfigContextQuerySetMixin, ConfigTemplateRenderMixin from ipam.models import Prefix, VLAN from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired @@ -22,6 +21,7 @@ from netbox.api.metadata import ContentTypeMetadata from netbox.api.pagination import StripCountAnnotationsPaginator from netbox.api.renderers import TextRenderer from netbox.api.viewsets import NetBoxModelViewSet +from netbox.api.viewsets.mixins import SequentialBulkCreatesMixin from netbox.constants import NESTED_SERIALIZER_PREFIX from utilities.api import get_serializer_for_model from utilities.utils import count_related @@ -386,7 +386,12 @@ class PlatformViewSet(NetBoxModelViewSet): # Devices/modules # -class DeviceViewSet(ConfigContextQuerySetMixin, ConfigTemplateRenderMixin, NetBoxModelViewSet): +class DeviceViewSet( + SequentialBulkCreatesMixin, + ConfigContextQuerySetMixin, + ConfigTemplateRenderMixin, + NetBoxModelViewSet +): queryset = Device.objects.prefetch_related( 'device_type__manufacturer', 'device_role', 'tenant', 'platform', 'site', 'location', 'rack', 'parent_bay', 'virtual_chassis__master', 'primary_ip4__nat_outside', 'primary_ip6__nat_outside', 'config_template', 'tags', @@ -493,7 +498,8 @@ class PowerOutletViewSet(PathEndpointMixin, NetBoxModelViewSet): class InterfaceViewSet(PathEndpointMixin, NetBoxModelViewSet): queryset = Interface.objects.prefetch_related( 'device', 'module__module_bay', 'parent', 'bridge', 'lag', '_path', 'cable__terminations', 'wireless_lans', - 'untagged_vlan', 'tagged_vlans', 'vrf', 'ip_addresses', 'fhrp_group_assignments', 'tags' + 'untagged_vlan', 'tagged_vlans', 'vrf', 'ip_addresses', 'fhrp_group_assignments', 'tags', 'l2vpn_terminations', + 'vdcs', ) serializer_class = serializers.InterfaceSerializer filterset_class = filtersets.InterfaceFilterSet diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 8ad8a0eb7..cc388b750 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -807,12 +807,16 @@ class InterfaceTypeChoices(ChoiceSet): TYPE_100GE_CFP = '100gbase-x-cfp' TYPE_100GE_CFP2 = '100gbase-x-cfp2' TYPE_100GE_CFP4 = '100gbase-x-cfp4' + TYPE_100GE_CXP = '100gbase-x-cxp' TYPE_100GE_CPAK = '100gbase-x-cpak' TYPE_100GE_QSFP28 = '100gbase-x-qsfp28' TYPE_200GE_CFP2 = '200gbase-x-cfp2' TYPE_200GE_QSFP56 = '200gbase-x-qsfp56' + TYPE_200GE_QSFP_DD = '200gbase-x-qsfpdd' TYPE_400GE_QSFP_DD = '400gbase-x-qsfpdd' TYPE_400GE_OSFP = '400gbase-x-osfp' + TYPE_400GE_CDFP = '400gbase-x-cdfp' + TYPE_400GE_CFP8 = '400gbase-x-cfp8' TYPE_800GE_QSFP_DD = '800gbase-x-qsfpdd' TYPE_800GE_OSFP = '800gbase-x-osfp' @@ -952,11 +956,15 @@ class InterfaceTypeChoices(ChoiceSet): (TYPE_100GE_CFP2, 'CFP2 (100GE)'), (TYPE_200GE_CFP2, 'CFP2 (200GE)'), (TYPE_100GE_CFP4, 'CFP4 (100GE)'), + (TYPE_100GE_CXP, 'CXP (100GE)'), (TYPE_100GE_CPAK, 'Cisco CPAK (100GE)'), (TYPE_100GE_QSFP28, 'QSFP28 (100GE)'), (TYPE_200GE_QSFP56, 'QSFP56 (200GE)'), + (TYPE_200GE_QSFP_DD, 'QSFP-DD (200GE)'), (TYPE_400GE_QSFP_DD, 'QSFP-DD (400GE)'), (TYPE_400GE_OSFP, 'OSFP (400GE)'), + (TYPE_400GE_CDFP, 'CDFP (400GE)'), + (TYPE_400GE_CFP8, 'CPF8 (400GE)'), (TYPE_800GE_QSFP_DD, 'QSFP-DD (800GE)'), (TYPE_800GE_OSFP, 'OSFP (800GE)'), ) @@ -1221,6 +1229,10 @@ class PortTypeChoices(ChoiceSet): TYPE_LSH_PC = 'lsh-pc' TYPE_LSH_UPC = 'lsh-upc' TYPE_LSH_APC = 'lsh-apc' + TYPE_LX5 = 'lx5' + TYPE_LX5_PC = 'lx5-pc' + TYPE_LX5_UPC = 'lx5-upc' + TYPE_LX5_APC = 'lx5-apc' TYPE_SPLICE = 'splice' TYPE_CS = 'cs' TYPE_SN = 'sn' @@ -1267,6 +1279,10 @@ class PortTypeChoices(ChoiceSet): (TYPE_LSH_PC, 'LSH/PC'), (TYPE_LSH_UPC, 'LSH/UPC'), (TYPE_LSH_APC, 'LSH/APC'), + (TYPE_LX5, 'LX.5'), + (TYPE_LX5_PC, 'LX.5/PC'), + (TYPE_LX5_UPC, 'LX.5/UPC'), + (TYPE_LX5_APC, 'LX.5/APC'), (TYPE_MPO, 'MPO'), (TYPE_MTRJ, 'MTRJ'), (TYPE_SC, 'SC'), diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index fccaa72f0..e784be8e8 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -1219,6 +1219,28 @@ class DeviceComponentFilterSet(django_filters.FilterSet): to_field_name='name', label=_('Device (name)'), ) + device_type_id = django_filters.ModelMultipleChoiceFilter( + field_name='device__device_type', + queryset=DeviceType.objects.all(), + label=_('Device type (ID)'), + ) + device_type = django_filters.ModelMultipleChoiceFilter( + field_name='device__device_type__model', + queryset=DeviceType.objects.all(), + to_field_name='model', + label=_('Device type (model)'), + ) + device_role_id = django_filters.ModelMultipleChoiceFilter( + field_name='device__device_role', + queryset=DeviceRole.objects.all(), + label=_('Device role (ID)'), + ) + device_role = django_filters.ModelMultipleChoiceFilter( + field_name='device__device_role__slug', + queryset=DeviceRole.objects.all(), + to_field_name='slug', + label=_('Device role (slug)'), + ) virtual_chassis_id = django_filters.ModelMultipleChoiceFilter( field_name='device__virtual_chassis', queryset=VirtualChassis.objects.all(), diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index d5abce647..bc9693afb 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -1,4 +1,5 @@ from django import forms +from django.conf import settings from django.contrib.auth.models import User from django.utils.translation import gettext as _ from timezone_field import TimeZoneFormField @@ -13,6 +14,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 +1141,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 +1231,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 +1253,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): @@ -1276,8 +1293,13 @@ class InterfaceBulkEditForm( break if site is not None: - self.fields['untagged_vlan'].widget.add_query_param('site_id', site.pk) - self.fields['tagged_vlans'].widget.add_query_param('site_id', site.pk) + # Query for VLANs assigned to the same site and VLANs with no site assigned (null). + self.fields['untagged_vlan'].widget.add_query_param( + 'site_id', [site.pk, settings.FILTERS_NULL_CHOICE_VALUE] + ) + self.fields['tagged_vlans'].widget.add_query_param( + 'site_id', [site.pk, settings.FILTERS_NULL_CHOICE_VALUE] + ) self.fields['parent'].choices = () self.fields['parent'].widget.attrs['disabled'] = True diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 8b7bd47ea..de7575acb 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -292,12 +292,21 @@ class DeviceTypeImportForm(NetBoxModelImportForm): required=False, help_text=_('The default platform for devices of this type (optional)') ) + weight = forms.DecimalField( + required=False, + help_text=_('Device weight'), + ) + weight_unit = CSVChoiceField( + choices=WeightUnitChoices, + required=False, + help_text=_('Unit for device weight') + ) class Meta: model = DeviceType fields = [ 'manufacturer', 'default_platform', 'model', 'slug', 'part_number', 'u_height', 'is_full_depth', - 'subdevice_role', 'airflow', 'description', 'comments', + 'subdevice_role', 'airflow', 'description', 'weight', 'weight_unit', 'comments', ] @@ -306,10 +315,19 @@ class ModuleTypeImportForm(NetBoxModelImportForm): queryset=Manufacturer.objects.all(), to_field_name='name' ) + weight = forms.DecimalField( + required=False, + help_text=_('Module weight'), + ) + weight_unit = CSVChoiceField( + choices=WeightUnitChoices, + required=False, + help_text=_('Unit for module weight') + ) class Meta: model = ModuleType - fields = ['manufacturer', 'model', 'part_number', 'description', 'comments'] + fields = ['manufacturer', 'model', 'part_number', 'description', 'weight', 'weight_unit', 'comments'] class DeviceRoleImportForm(NetBoxModelImportForm): @@ -1060,7 +1078,11 @@ class CableImportForm(NetBoxModelImportForm): model = content_type.model_class() try: - termination_object = model.objects.get(device=device, name=name) + if device.virtual_chassis and device.virtual_chassis.master == device and \ + model.objects.filter(device=device, name=name).count() == 0: + termination_object = model.objects.get(device__in=device.virtual_chassis.members.all(), name=name) + else: + termination_object = model.objects.get(device=device, name=name) if termination_object.cable is not None: raise forms.ValidationError(f"Side {side.upper()}: {device} {termination_object} is already connected") except ObjectDoesNotExist: diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index d31bba030..4edee6014 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -102,13 +102,25 @@ class DeviceComponentFilterForm(NetBoxModelFilterSetForm): required=False, label=_('Virtual Chassis') ) + device_type_id = DynamicModelMultipleChoiceField( + queryset=DeviceType.objects.all(), + required=False, + label=_('Device type') + ) + device_role_id = DynamicModelMultipleChoiceField( + queryset=DeviceRole.objects.all(), + required=False, + label=_('Device role') + ) device_id = DynamicModelMultipleChoiceField( queryset=Device.objects.all(), required=False, query_params={ 'site_id': '$site_id', 'location_id': '$location_id', - 'virtual_chassis_id': '$virtual_chassis_id' + 'virtual_chassis_id': '$virtual_chassis_id', + 'device_type_id': '$device_type_id', + 'role_id': '$device_role_id' }, label=_('Device') ) @@ -1070,7 +1082,8 @@ class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): fieldsets = ( (None, ('q', 'filter_id', 'tag')), ('Attributes', ('name', 'label', 'type', 'speed')), - ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), + ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), + ('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), ('Connection', ('cabled', 'connected', 'occupied')), ) type = forms.MultipleChoiceField( @@ -1089,7 +1102,8 @@ class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterF fieldsets = ( (None, ('q', 'filter_id', 'tag')), ('Attributes', ('name', 'label', 'type', 'speed')), - ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), + ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), + ('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), ('Connection', ('cabled', 'connected', 'occupied')), ) type = forms.MultipleChoiceField( @@ -1108,7 +1122,8 @@ class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): fieldsets = ( (None, ('q', 'filter_id', 'tag')), ('Attributes', ('name', 'label', 'type')), - ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), + ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), + ('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), ('Connection', ('cabled', 'connected', 'occupied')), ) type = forms.MultipleChoiceField( @@ -1123,7 +1138,8 @@ class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): fieldsets = ( (None, ('q', 'filter_id', 'tag')), ('Attributes', ('name', 'label', 'type')), - ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), + ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), + ('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), ('Connection', ('cabled', 'connected', 'occupied')), ) type = forms.MultipleChoiceField( @@ -1141,8 +1157,8 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): ('Addressing', ('vrf_id', 'l2vpn_id', 'mac_address', 'wwn')), ('PoE', ('poe_mode', 'poe_type')), ('Wireless', ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')), - ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', - 'device_id', 'vdc_id')), + ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), + ('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id', 'vdc_id')), ('Connection', ('cabled', 'connected', 'occupied')), ) vdc_id = DynamicModelMultipleChoiceField( @@ -1242,7 +1258,8 @@ class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm): fieldsets = ( (None, ('q', 'filter_id', 'tag')), ('Attributes', ('name', 'label', 'type', 'color')), - ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), + ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), + ('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), ('Cable', ('cabled', 'occupied')), ) model = FrontPort @@ -1261,7 +1278,8 @@ class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm): fieldsets = ( (None, ('q', 'filter_id', 'tag')), ('Attributes', ('name', 'label', 'type', 'color')), - ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), + ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), + ('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), ('Cable', ('cabled', 'occupied')), ) type = forms.MultipleChoiceField( @@ -1279,7 +1297,8 @@ class ModuleBayFilterForm(DeviceComponentFilterForm): fieldsets = ( (None, ('q', 'filter_id', 'tag')), ('Attributes', ('name', 'label', 'position')), - ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), + ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), + ('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), ) tag = TagFilterField(model) position = forms.CharField( @@ -1292,7 +1311,8 @@ class DeviceBayFilterForm(DeviceComponentFilterForm): fieldsets = ( (None, ('q', 'filter_id', 'tag')), ('Attributes', ('name', 'label')), - ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), + ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), + ('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), ) tag = TagFilterField(model) @@ -1302,7 +1322,8 @@ class InventoryItemFilterForm(DeviceComponentFilterForm): fieldsets = ( (None, ('q', 'filter_id', 'tag')), ('Attributes', ('name', 'label', 'role_id', 'manufacturer_id', 'serial', 'asset_tag', 'discovered')), - ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), + ('Location', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id')), + ('Device', ('device_type_id', 'device_role_id', 'device_id', 'virtual_chassis_id')), ) role_id = DynamicModelMultipleChoiceField( queryset=InventoryItemRole.objects.all(), diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index d756036f4..219216045 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -1214,7 +1214,7 @@ class PopulateDeviceBayForm(BootstrapMixin, forms.Form): installed_device = forms.ModelChoiceField( queryset=Device.objects.all(), label=_('Child Device'), - help_text=_("Child devices must first be created and assigned to the site/rack of the parent device.") + help_text=_("Child devices must first be created and assigned to the site and rack of the parent device.") ) def __init__(self, device_bay, *args, **kwargs): diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index 236077421..9589ab533 100644 --- a/netbox/dcim/forms/object_create.py +++ b/netbox/dcim/forms/object_create.py @@ -101,6 +101,7 @@ class FrontPortTemplateCreateForm(ComponentCreateForm, model_forms.FrontPortTemp choices=[], label=_('Rear ports'), help_text=_('Select one rear port assignment for each front port being created.'), + widget=forms.SelectMultiple(attrs={'size': 6}) ) # Override fieldsets from FrontPortTemplateForm to omit rear_port_position @@ -242,6 +243,7 @@ class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm): choices=[], label=_('Rear ports'), help_text=_('Select one rear port assignment for each front port being created.'), + widget=forms.SelectMultiple(attrs={'size': 6}) ) # Override fieldsets from FrontPortForm to omit rear_port_position diff --git a/netbox/dcim/migrations/0172_larger_power_draw_values.py b/netbox/dcim/migrations/0172_larger_power_draw_values.py new file mode 100644 index 000000000..729daf836 --- /dev/null +++ b/netbox/dcim/migrations/0172_larger_power_draw_values.py @@ -0,0 +1,42 @@ +# Generated by Django 4.1.9 on 2023-05-12 18:46 + +import django.core.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0171_cabletermination_change_logging'), + ] + + operations = [ + migrations.AlterField( + model_name='powerport', + name='allocated_draw', + field=models.PositiveIntegerField( + blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)] + ), + ), + migrations.AlterField( + model_name='powerport', + name='maximum_draw', + field=models.PositiveIntegerField( + blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)] + ), + ), + migrations.AlterField( + model_name='powerporttemplate', + name='allocated_draw', + field=models.PositiveIntegerField( + blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)] + ), + ), + migrations.AlterField( + model_name='powerporttemplate', + name='maximum_draw', + field=models.PositiveIntegerField( + blank=True, null=True, validators=[django.core.validators.MinValueValidator(1)] + ), + ), + ] diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index b0dc1677f..6a89655b2 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -232,13 +232,13 @@ class PowerPortTemplate(ModularComponentTemplateModel): choices=PowerPortTypeChoices, blank=True ) - maximum_draw = models.PositiveSmallIntegerField( + maximum_draw = models.PositiveIntegerField( blank=True, null=True, validators=[MinValueValidator(1)], help_text=_("Maximum power draw (watts)") ) - allocated_draw = models.PositiveSmallIntegerField( + allocated_draw = models.PositiveIntegerField( blank=True, null=True, validators=[MinValueValidator(1)], diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 645fc5c5c..9f6837b92 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -329,13 +329,13 @@ class PowerPort(ModularComponentModel, CabledObjectModel, PathEndpoint): blank=True, help_text=_('Physical port type') ) - maximum_draw = models.PositiveSmallIntegerField( + maximum_draw = models.PositiveIntegerField( blank=True, null=True, validators=[MinValueValidator(1)], help_text=_("Maximum power draw (watts)") ) - allocated_draw = models.PositiveSmallIntegerField( + allocated_draw = models.PositiveIntegerField( blank=True, null=True, validators=[MinValueValidator(1)], diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 02c68c10a..85a5d6870 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -184,6 +184,8 @@ class DeviceType(PrimaryModel, WeightMixin): 'subdevice_role': self.subdevice_role, 'airflow': self.airflow, 'comments': self.comments, + 'weight': float(self.weight) if self.weight is not None else None, + 'weight_unit': self.weight_unit, } # Component templates @@ -361,6 +363,8 @@ class ModuleType(PrimaryModel, WeightMixin): 'model': self.model, 'part_number': self.part_number, 'comments': self.comments, + 'weight': float(self.weight) if self.weight is not None else None, + 'weight_unit': self.weight_unit, } # Component templates diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index e5412a3ab..54de5c434 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -466,7 +466,7 @@ class Rack(PrimaryModel, WeightMixin): powerport.get_power_draw()['allocated'] for powerport in powerports ]) - return int(allocated_draw / available_power_total * 100) + return round(allocated_draw / available_power_total * 100, 1) @cached_property def total_weight(self): diff --git a/netbox/dcim/svg/racks.py b/netbox/dcim/svg/racks.py index 6c57e6023..9c317ea16 100644 --- a/netbox/dcim/svg/racks.py +++ b/netbox/dcim/svg/racks.py @@ -22,6 +22,11 @@ __all__ = ( 'RackElevationSVG', ) +GRADIENT_RESERVED = '#b0b0ff' +GRADIENT_OCCUPIED = '#d7d7d7' +GRADIENT_BLOCKED = '#ffc0c0' +STROKE_RESERVED = '#4d4dff' + def get_device_name(device): if device.virtual_chassis: @@ -37,15 +42,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: + Role: + Device Type: () + Asset tag: (if defined) + Serial: (if defined) + 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: @@ -119,9 +137,9 @@ class RackElevationSVG: drawing.defs.add(drawing.style(css_file.read())) # Add gradients - RackElevationSVG._add_gradient(drawing, 'reserved', '#b0b0ff') - RackElevationSVG._add_gradient(drawing, 'occupied', '#d7d7d7') - RackElevationSVG._add_gradient(drawing, 'blocked', '#ffc0c0') + RackElevationSVG._add_gradient(drawing, 'reserved', GRADIENT_RESERVED) + RackElevationSVG._add_gradient(drawing, 'occupied', GRADIENT_OCCUPIED) + RackElevationSVG._add_gradient(drawing, 'blocked', GRADIENT_BLOCKED) return drawing @@ -233,13 +251,13 @@ class RackElevationSVG: coords = self._get_device_coords(segment[0], u_height) coords = (coords[0] + self.unit_width + RACK_ELEVATION_BORDER_WIDTH * 2, coords[1]) size = ( - self.margin_width, + self.margin_width - 3, u_height * self.unit_height ) link = Hyperlink(href=f'{self.base_url}{reservation.get_absolute_url()}', target='_parent') link.set_desc(f'Reservation #{reservation.pk}: {reservation.description}') link.add( - Rect(coords, size, class_='reservation') + Rect(coords, size, class_='reservation', stroke=STROKE_RESERVED, stroke_width=2) ) self.drawing.add(link) diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 056d05c9a..db2655d27 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -216,6 +216,16 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): config_template = tables.Column( linkify=True ) + parent_device = tables.Column( + verbose_name='Parent Device', + linkify=True, + accessor='parent_bay__device' + ) + device_bay_position = tables.Column( + verbose_name='Position (Device Bay)', + accessor='parent_bay', + linkify=True + ) comments = columns.MarkdownColumn() tags = columns.TagColumn( url_name='dcim:device_list' @@ -225,9 +235,10 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): model = models.Device fields = ( 'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'device_role', 'manufacturer', 'device_type', - 'platform', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'location', 'rack', 'position', 'face', - 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', - 'vc_priority', 'description', 'config_template', 'comments', 'contacts', 'tags', 'created', 'last_updated', + 'platform', 'serial', 'asset_tag', 'region', 'site_group', 'site', 'location', 'rack', 'parent_device', + 'device_bay_position', 'position', 'face', 'airflow', 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', + 'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'config_template', 'comments', 'contacts', + 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type', diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 3445b7e75..af15e1343 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -1115,7 +1115,7 @@ class DeviceTest(APIViewTestCases.APIViewTestCase): device_types = ( DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'), - DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'), + DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2', u_height=2), ) DeviceType.objects.bulk_create(device_types) @@ -1229,6 +1229,39 @@ class DeviceTest(APIViewTestCases.APIViewTestCase): self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + def test_rack_fit(self): + """ + Check that creating multiple devices with overlapping position fails. + """ + device = Device.objects.first() + device_type = DeviceType.objects.all()[1] + data = [ + { + 'device_type': device_type.pk, + 'device_role': device.device_role.pk, + 'site': device.site.pk, + 'name': 'Test Device 7', + 'rack': device.rack.pk, + 'face': 'front', + 'position': 1 + }, + { + 'device_type': device_type.pk, + 'device_role': device.device_role.pk, + 'site': device.site.pk, + 'name': 'Test Device 8', + 'rack': device.rack.pk, + 'face': 'front', + 'position': 2 + } + ] + + self.add_permissions('dcim.add_device') + url = reverse('dcim-api:device-list') + response = self.client.post(url, data, format='json', **self.header) + + self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST) + class ModuleTest(APIViewTestCases.APIViewTestCase): model = Module diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 346b35005..3f9712f2a 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -12,6 +12,23 @@ from virtualization.models import Cluster, ClusterType from wireless.choices import WirelessChannelChoices, WirelessRoleChoices +class DeviceComponentFilterSetTests: + + def test_device_type(self): + device_types = DeviceType.objects.all()[:2] + params = {'device_type_id': [device_types[0].pk, device_types[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'device_type': [device_types[0].model, device_types[1].model]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_device_role(self): + device_role = DeviceRole.objects.all()[:2] + params = {'device_role_id': [device_role[0].pk, device_role[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'device_role': [device_role[0].slug, device_role[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + class RegionTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = Region.objects.all() filterset = RegionFilterSet @@ -1998,7 +2015,7 @@ class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests): +class ConsolePortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests): queryset = ConsolePort.objects.all() filterset = ConsolePortFilterSet @@ -2027,10 +2044,23 @@ class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests): Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), Site(name='Site X', slug='site-x'), )) + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') - device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') + device_types = ( + DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'), + DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'), + DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'), + ) + DeviceType.objects.bulk_create(device_types) + module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1') - device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') + + device_roles = ( + DeviceRole(name='Device Role 1', slug='device-role-1'), + DeviceRole(name='Device Role 2', slug='device-role-2'), + DeviceRole(name='Device Role 3', slug='device-role-3'), + ) + DeviceRole.objects.bulk_create(device_roles) locations = ( Location(name='Location 1', slug='location-1', site=sites[0]), @@ -2048,10 +2078,10 @@ class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests): Rack.objects.bulk_create(racks) devices = ( - Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]), - Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]), - Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]), - Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections + Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]), + Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]), + Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]), + Device(name=None, device_type=device_types[0], device_role=device_roles[0], site=sites[3]), # For cable connections ) Device.objects.bulk_create(devices) @@ -2165,7 +2195,7 @@ class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) -class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests): +class ConsoleServerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests): queryset = ConsoleServerPort.objects.all() filterset = ConsoleServerPortFilterSet @@ -2194,10 +2224,23 @@ class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests): Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), Site(name='Site X', slug='site-x'), )) + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') - device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') + device_types = ( + DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'), + DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'), + DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'), + ) + DeviceType.objects.bulk_create(device_types) + module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1') - device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') + + device_roles = ( + DeviceRole(name='Device Role 1', slug='device-role-1'), + DeviceRole(name='Device Role 2', slug='device-role-2'), + DeviceRole(name='Device Role 3', slug='device-role-3'), + ) + DeviceRole.objects.bulk_create(device_roles) locations = ( Location(name='Location 1', slug='location-1', site=sites[0]), @@ -2215,10 +2258,10 @@ class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests): Rack.objects.bulk_create(racks) devices = ( - Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]), - Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]), - Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]), - Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections + Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]), + Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]), + Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]), + Device(name=None, device_type=device_types[2], device_role=device_roles[2], site=sites[3]), # For cable connections ) Device.objects.bulk_create(devices) @@ -2332,7 +2375,7 @@ class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) -class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests): +class PowerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests): queryset = PowerPort.objects.all() filterset = PowerPortFilterSet @@ -2361,10 +2404,23 @@ class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests): Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), Site(name='Site X', slug='site-x'), )) + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') - device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') + device_types = ( + DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'), + DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'), + DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'), + ) + DeviceType.objects.bulk_create(device_types) + module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1') - device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') + + device_roles = ( + DeviceRole(name='Device Role 1', slug='device-role-1'), + DeviceRole(name='Device Role 2', slug='device-role-2'), + DeviceRole(name='Device Role 3', slug='device-role-3'), + ) + DeviceRole.objects.bulk_create(device_roles) locations = ( Location(name='Location 1', slug='location-1', site=sites[0]), @@ -2382,10 +2438,10 @@ class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests): Rack.objects.bulk_create(racks) devices = ( - Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]), - Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]), - Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]), - Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections + Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]), + Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]), + Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]), + Device(name=None, device_type=device_types[2], device_role=device_roles[2], site=sites[3]), # For cable connections ) Device.objects.bulk_create(devices) @@ -2507,7 +2563,7 @@ class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) -class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests): +class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests): queryset = PowerOutlet.objects.all() filterset = PowerOutletFilterSet @@ -2536,10 +2592,23 @@ class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests): Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), Site(name='Site X', slug='site-x'), )) + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') - device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') + device_types = ( + DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'), + DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'), + DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'), + ) + DeviceType.objects.bulk_create(device_types) + module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1') - device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') + + device_roles = ( + DeviceRole(name='Device Role 1', slug='device-role-1'), + DeviceRole(name='Device Role 2', slug='device-role-2'), + DeviceRole(name='Device Role 3', slug='device-role-3'), + ) + DeviceRole.objects.bulk_create(device_roles) locations = ( Location(name='Location 1', slug='location-1', site=sites[0]), @@ -2557,10 +2626,10 @@ class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests): Rack.objects.bulk_create(racks) devices = ( - Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]), - Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]), - Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]), - Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections + Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]), + Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]), + Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]), + Device(name=None, device_type=device_types[2], device_role=device_roles[2], site=sites[3]), # For cable connections ) Device.objects.bulk_create(devices) @@ -2678,7 +2747,7 @@ class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) -class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): +class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests): queryset = Interface.objects.all() filterset = InterfaceFilterSet @@ -2707,10 +2776,23 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), Site(name='Site X', slug='site-x'), )) + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') - device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') + device_types = ( + DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'), + DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'), + DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'), + ) + DeviceType.objects.bulk_create(device_types) + module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1') - device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') + + device_roles = ( + DeviceRole(name='Device Role 1', slug='device-role-1'), + DeviceRole(name='Device Role 2', slug='device-role-2'), + DeviceRole(name='Device Role 3', slug='device-role-3'), + ) + DeviceRole.objects.bulk_create(device_roles) locations = ( Location(name='Location 1', slug='location-1', site=sites[0]), @@ -2728,10 +2810,10 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): Rack.objects.bulk_create(racks) devices = ( - Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]), - Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]), - Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]), - Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections + Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]), + Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]), + Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]), + Device(name=None, device_type=device_types[2], device_role=device_roles[2], site=sites[3]), # For cable connections ) Device.objects.bulk_create(devices) @@ -3101,7 +3183,7 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) -class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests): +class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests): queryset = FrontPort.objects.all() filterset = FrontPortFilterSet @@ -3130,10 +3212,23 @@ class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests): Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), Site(name='Site X', slug='site-x'), )) + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') - device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') + device_types = ( + DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'), + DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'), + DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'), + ) + DeviceType.objects.bulk_create(device_types) + module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1') - device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') + + device_roles = ( + DeviceRole(name='Device Role 1', slug='device-role-1'), + DeviceRole(name='Device Role 2', slug='device-role-2'), + DeviceRole(name='Device Role 3', slug='device-role-3'), + ) + DeviceRole.objects.bulk_create(device_roles) locations = ( Location(name='Location 1', slug='location-1', site=sites[0]), @@ -3151,10 +3246,10 @@ class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests): Rack.objects.bulk_create(racks) devices = ( - Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]), - Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]), - Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]), - Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections + Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]), + Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]), + Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]), + Device(name=None, device_type=device_types[2], device_role=device_roles[2], site=sites[3]), # For cable connections ) Device.objects.bulk_create(devices) @@ -3277,7 +3372,7 @@ class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests): +class RearPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests): queryset = RearPort.objects.all() filterset = RearPortFilterSet @@ -3306,10 +3401,23 @@ class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests): Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), Site(name='Site X', slug='site-x'), )) + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') - device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') + device_types = ( + DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'), + DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'), + DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'), + ) + DeviceType.objects.bulk_create(device_types) + module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1') - device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') + + device_roles = ( + DeviceRole(name='Device Role 1', slug='device-role-1'), + DeviceRole(name='Device Role 2', slug='device-role-2'), + DeviceRole(name='Device Role 3', slug='device-role-3'), + ) + DeviceRole.objects.bulk_create(device_roles) locations = ( Location(name='Location 1', slug='location-1', site=sites[0]), @@ -3327,10 +3435,10 @@ class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests): Rack.objects.bulk_create(racks) devices = ( - Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]), - Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]), - Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]), - Device(name=None, device_type=device_type, device_role=device_role, site=sites[3]), # For cable connections + Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]), + Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]), + Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]), + Device(name=None, device_type=device_types[2], device_role=device_roles[2], site=sites[3]), # For cable connections ) Device.objects.bulk_create(devices) @@ -3447,7 +3555,7 @@ class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class ModuleBayTestCase(TestCase, ChangeLoggedFilterSetTests): +class ModuleBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests): queryset = ModuleBay.objects.all() filterset = ModuleBayFilterSet @@ -3476,9 +3584,21 @@ class ModuleBayTestCase(TestCase, ChangeLoggedFilterSetTests): Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), Site(name='Site X', slug='site-x'), )) + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') - device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1') - device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') + device_types = ( + DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'), + DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'), + DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'), + ) + DeviceType.objects.bulk_create(device_types) + + device_roles = ( + DeviceRole(name='Device Role 1', slug='device-role-1'), + DeviceRole(name='Device Role 2', slug='device-role-2'), + DeviceRole(name='Device Role 3', slug='device-role-3'), + ) + DeviceRole.objects.bulk_create(device_roles) locations = ( Location(name='Location 1', slug='location-1', site=sites[0]), @@ -3496,9 +3616,9 @@ class ModuleBayTestCase(TestCase, ChangeLoggedFilterSetTests): Rack.objects.bulk_create(racks) devices = ( - Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]), - Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]), - Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]), + Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]), + Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]), + Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]), ) Device.objects.bulk_create(devices) @@ -3564,7 +3684,7 @@ class ModuleBayTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) -class DeviceBayTestCase(TestCase, ChangeLoggedFilterSetTests): +class DeviceBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests): queryset = DeviceBay.objects.all() filterset = DeviceBayFilterSet @@ -3593,9 +3713,21 @@ class DeviceBayTestCase(TestCase, ChangeLoggedFilterSetTests): Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]), Site(name='Site X', slug='site-x'), )) + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') - device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1') - device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') + device_types = ( + DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'), + DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'), + DeviceType(manufacturer=manufacturer, model='Device Type 3', slug='device-type-3'), + ) + DeviceType.objects.bulk_create(device_types) + + device_roles = ( + DeviceRole(name='Device Role 1', slug='device-role-1'), + DeviceRole(name='Device Role 2', slug='device-role-2'), + DeviceRole(name='Device Role 3', slug='device-role-3'), + ) + DeviceRole.objects.bulk_create(device_roles) locations = ( Location(name='Location 1', slug='location-1', site=sites[0]), @@ -3613,9 +3745,9 @@ class DeviceBayTestCase(TestCase, ChangeLoggedFilterSetTests): Rack.objects.bulk_create(racks) devices = ( - Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]), - Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]), - Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]), + Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]), + Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]), + Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]), ) Device.objects.bulk_create(devices) @@ -3694,8 +3826,19 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests): ) Manufacturer.objects.bulk_create(manufacturers) - device_type = DeviceType.objects.create(manufacturer=manufacturers[0], model='Model 1', slug='model-1') - device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') + device_types = ( + DeviceType(manufacturer=manufacturers[0], model='Device Type 1', slug='device-type-1'), + DeviceType(manufacturer=manufacturers[0], model='Device Type 2', slug='device-type-2'), + DeviceType(manufacturer=manufacturers[0], model='Device Type 3', slug='device-type-3'), + ) + DeviceType.objects.bulk_create(device_types) + + device_roles = ( + DeviceRole(name='Device Role 1', slug='device-role-1'), + DeviceRole(name='Device Role 2', slug='device-role-2'), + DeviceRole(name='Device Role 3', slug='device-role-3'), + ) + DeviceRole.objects.bulk_create(device_roles) regions = ( Region(name='Region 1', slug='region-1'), @@ -3736,9 +3879,9 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests): Rack.objects.bulk_create(racks) devices = ( - Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0], rack=racks[0]), - Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1], rack=racks[1]), - Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2], rack=racks[2]), + Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], site=sites[0], location=locations[0], rack=racks[0]), + Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], site=sites[1], location=locations[1], rack=racks[1]), + Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], site=sites[2], location=locations[2], rack=racks[2]), ) Device.objects.bulk_create(devices) @@ -3829,6 +3972,20 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'rack': [racks[0].name, racks[1].name]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_device_type(self): + device_types = DeviceType.objects.all()[:2] + params = {'device_type_id': [device_types[0].pk, device_types[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'device_type': [device_types[0].model, device_types[1].model]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + def test_device_role(self): + device_role = DeviceRole.objects.all()[:2] + params = {'device_role_id': [device_role[0].pk, device_role[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'device_role': [device_role[0].slug, device_role[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_device(self): devices = Device.objects.all()[:2] params = {'device_id': [devices[0].pk, devices[1].pk]} diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index bae5a8e0b..c0cfca2e7 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -681,11 +681,15 @@ class DeviceTypeTestCase( """ IMPORT_DATA = """ manufacturer: Generic -default_platform: Platform model: TEST-1000 slug: test-1000 +default_platform: Platform u_height: 2 +is_full_depth: false +airflow: front-to-rear subdevice_role: parent +weight: 10 +weight_unit: kg comments: Test comment console-ports: - name: Console Port 1 @@ -794,8 +798,16 @@ inventory-items: self.assertHttpStatus(response, 200) device_type = DeviceType.objects.get(model='TEST-1000') - self.assertEqual(device_type.comments, 'Test comment') + self.assertEqual(device_type.manufacturer.pk, manufacturer.pk) self.assertEqual(device_type.default_platform.pk, platform.pk) + self.assertEqual(device_type.slug, 'test-1000') + self.assertEqual(device_type.u_height, 2) + self.assertFalse(device_type.is_full_depth) + self.assertEqual(device_type.airflow, DeviceAirflowChoices.AIRFLOW_FRONT_TO_REAR) + self.assertEqual(device_type.subdevice_role, SubdeviceRoleChoices.ROLE_PARENT) + self.assertEqual(device_type.weight, 10) + self.assertEqual(device_type.weight_unit, WeightUnitChoices.UNIT_KILOGRAM) + self.assertEqual(device_type.comments, 'Test comment') # Verify all of the components were created self.assertEqual(device_type.consoleporttemplates.count(), 3) @@ -1019,6 +1031,8 @@ class ModuleTypeTestCase( IMPORT_DATA = """ manufacturer: Generic model: TEST-1000 +weight: 10 +weight_unit: lb comments: Test comment console-ports: - name: Console Port 1 @@ -1082,7 +1096,8 @@ front-ports: """ # Create the manufacturer - Manufacturer(name='Generic', slug='generic').save() + manufacturer = Manufacturer(name='Generic', slug='generic') + manufacturer.save() # Add all required permissions to the test user self.add_permissions( @@ -1105,6 +1120,9 @@ front-ports: self.assertHttpStatus(response, 200) module_type = ModuleType.objects.get(model='TEST-1000') + self.assertEqual(module_type.manufacturer.pk, manufacturer.pk) + self.assertEqual(module_type.weight, 10) + self.assertEqual(module_type.weight_unit, WeightUnitChoices.UNIT_POUND) self.assertEqual(module_type.comments, 'Test comment') # Verify all the components were created @@ -2889,6 +2907,7 @@ class CableTestCase( manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') devicetype = DeviceType.objects.create(model='Device Type 1', manufacturer=manufacturer) devicerole = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1') + vc = VirtualChassis.objects.create(name='Virtual Chassis') devices = ( Device(name='Device 1', site=site, device_type=devicetype, device_role=devicerole), @@ -2898,6 +2917,10 @@ class CableTestCase( ) Device.objects.bulk_create(devices) + vc.members.set((devices[0], devices[1], devices[2])) + vc.master = devices[0] + vc.save() + interfaces = ( Interface(device=devices[0], name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED), Interface(device=devices[0], name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED), @@ -2911,6 +2934,10 @@ class CableTestCase( Interface(device=devices[3], name='Interface 1', type=InterfaceTypeChoices.TYPE_1GE_FIXED), Interface(device=devices[3], name='Interface 2', type=InterfaceTypeChoices.TYPE_1GE_FIXED), Interface(device=devices[3], name='Interface 3', type=InterfaceTypeChoices.TYPE_1GE_FIXED), + Interface(device=devices[1], name='Device 2 Interface', type=InterfaceTypeChoices.TYPE_1GE_FIXED), + Interface(device=devices[2], name='Device 3 Interface', type=InterfaceTypeChoices.TYPE_1GE_FIXED), + Interface(device=devices[3], name='Interface 4', type=InterfaceTypeChoices.TYPE_1GE_FIXED), + Interface(device=devices[3], name='Interface 5', type=InterfaceTypeChoices.TYPE_1GE_FIXED), ) Interface.objects.bulk_create(interfaces) @@ -2943,6 +2970,8 @@ class CableTestCase( "Device 3,dcim.interface,Interface 1,Device 4,dcim.interface,Interface 1", "Device 3,dcim.interface,Interface 2,Device 4,dcim.interface,Interface 2", "Device 3,dcim.interface,Interface 3,Device 4,dcim.interface,Interface 3", + "Device 1,dcim.interface,Device 2 Interface,Device 4,dcim.interface,Interface 4", + "Device 1,dcim.interface,Device 3 Interface,Device 4,dcim.interface,Interface 5", ) cls.csv_update_data = ( diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index bcbbf1739..0def4f4a8 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -20,6 +20,7 @@ from extras.views import ObjectConfigContextView from ipam.models import ASN, IPAddress, Prefix, VLAN, VLANGroup from ipam.tables import InterfaceVLANTable from netbox.views import generic +from tenancy.views import ObjectContactsView from utilities.forms import ConfirmationForm from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.permissions import get_permission_for_model @@ -267,6 +268,11 @@ class RegionBulkDeleteView(generic.BulkDeleteView): table = tables.RegionTable +@register_model_view(Region, 'contacts') +class RegionContactsView(ObjectContactsView): + queryset = Region.objects.all() + + # # Site groups # @@ -342,6 +348,11 @@ class SiteGroupBulkDeleteView(generic.BulkDeleteView): table = tables.SiteGroupTable +@register_model_view(SiteGroup, 'contacts') +class SiteGroupContactsView(ObjectContactsView): + queryset = SiteGroup.objects.all() + + # # Sites # @@ -435,6 +446,11 @@ class SiteBulkDeleteView(generic.BulkDeleteView): table = tables.SiteTable +@register_model_view(Site, 'contacts') +class SiteContactsView(ObjectContactsView): + queryset = Site.objects.all() + + # # Locations # @@ -523,6 +539,11 @@ class LocationBulkDeleteView(generic.BulkDeleteView): table = tables.LocationTable +@register_model_view(Location, 'contacts') +class LocationContactsView(ObjectContactsView): + queryset = Location.objects.all() + + # # Rack roles # @@ -740,6 +761,11 @@ class RackBulkDeleteView(generic.BulkDeleteView): table = tables.RackTable +@register_model_view(Rack, 'contacts') +class RackContactsView(ObjectContactsView): + queryset = Rack.objects.all() + + # # Rack reservations # @@ -874,6 +900,11 @@ class ManufacturerBulkDeleteView(generic.BulkDeleteView): table = tables.ManufacturerTable +@register_model_view(Manufacturer, 'contacts') +class ManufacturerContactsView(ObjectContactsView): + queryset = Manufacturer.objects.all() + + # # Device types # @@ -2088,6 +2119,11 @@ class DeviceBulkRenameView(generic.BulkRenameView): table = tables.DeviceTable +@register_model_view(Device, 'contacts') +class DeviceContactsView(ObjectContactsView): + queryset = Device.objects.all() + + # # Modules # @@ -3469,6 +3505,11 @@ class PowerPanelBulkDeleteView(generic.BulkDeleteView): table = tables.PowerPanelTable +@register_model_view(PowerPanel, 'contacts') +class PowerPanelContactsView(ObjectContactsView): + queryset = PowerPanel.objects.all() + + # # Power feeds # diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index 04a67b521..6d1b14370 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -25,7 +25,7 @@ class ConfigRevisionAdmin(admin.ModelAdmin): 'fields': ('ALLOWED_URL_SCHEMES',), }), ('Banners', { - 'fields': ('BANNER_LOGIN', 'BANNER_TOP', 'BANNER_BOTTOM'), + 'fields': ('BANNER_LOGIN', 'BANNER_MAINTENANCE', 'BANNER_TOP', 'BANNER_BOTTOM'), 'classes': ('monospace',), }), ('Pagination', { diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index f302024b0..3f796d7f8 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -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}) diff --git a/netbox/extras/choices.py b/netbox/extras/choices.py index e10516c4c..6fc14b965 100644 --- a/netbox/extras/choices.py +++ b/netbox/extras/choices.py @@ -56,11 +56,13 @@ class CustomFieldVisibilityChoices(ChoiceSet): VISIBILITY_READ_WRITE = 'read-write' VISIBILITY_READ_ONLY = 'read-only' VISIBILITY_HIDDEN = 'hidden' + VISIBILITY_HIDDEN_IFUNSET = 'hidden-ifunset' CHOICES = ( (VISIBILITY_READ_WRITE, 'Read/Write'), (VISIBILITY_READ_ONLY, 'Read-only'), (VISIBILITY_HIDDEN, 'Hidden'), + (VISIBILITY_HIDDEN_IFUNSET, 'Hidden (if unset)'), ) diff --git a/netbox/extras/conditions.py b/netbox/extras/conditions.py index c6744e524..db054149e 100644 --- a/netbox/extras/conditions.py +++ b/netbox/extras/conditions.py @@ -65,8 +65,14 @@ class Condition: """ Evaluate the provided data to determine whether it matches the condition. """ + def _get(obj, key): + if isinstance(obj, list): + return [dict.get(i, key) for i in obj] + + return dict.get(obj, key) + try: - value = functools.reduce(dict.get, self.attr.split('.'), data) + value = functools.reduce(_get, self.attr.split('.'), data) except TypeError: # Invalid key path value = None diff --git a/netbox/extras/dashboard/widgets.py b/netbox/extras/dashboard/widgets.py index 69d1cc36d..b3a4d090c 100644 --- a/netbox/extras/dashboard/widgets.py +++ b/netbox/extras/dashboard/widgets.py @@ -10,8 +10,9 @@ 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.http import QueryDict from django.template.loader import render_to_string -from django.urls import NoReverseMatch, reverse +from django.urls import NoReverseMatch, resolve, reverse from django.utils.translation import gettext as _ from extras.utils import FeatureQuery @@ -35,7 +36,8 @@ 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() | Q(app_label='extras', model='objectchange') + FeatureQuery('export_templates').get_query() | Q(app_label='extras', model='objectchange') | + Q(app_label='extras', model='configcontext') ).order_by('app_label', 'model') ] @@ -148,7 +150,7 @@ class ObjectCountsWidget(DashboardWidget): filters = forms.JSONField( required=False, label='Object filters', - help_text=_("Only objects matching the specified filters will be counted") + help_text=_("Filters to apply when counting the number of objects") ) def clean_filters(self): @@ -157,13 +159,6 @@ class ObjectCountsWidget(DashboardWidget): dict(data) except TypeError: raise forms.ValidationError("Invalid format. Object filters must be passed as a dictionary.") - for model in get_models_from_content_types(self.cleaned_data.get('models')): - try: - # Validate the filters by creating a QuerySet - model.objects.filter(**data).none() - except Exception: - model_name = model._meta.verbose_name_plural - raise forms.ValidationError(f"Invalid filter specification for {model_name}.") return data def render(self, request): @@ -171,13 +166,19 @@ class ObjectCountsWidget(DashboardWidget): for model in get_models_from_content_types(self.config['models']): permission = get_permission_for_model(model, 'view') if request.user.has_perm(permission): + url = reverse(get_viewname(model, 'list')) qs = model.objects.restrict(request.user, 'view') + # Apply any specified filters if filters := self.config.get('filters'): - qs = qs.filter(**filters) + params = QueryDict(mutable=True) + params.update(filters) + filterset = getattr(resolve(url).func.view_class, 'filterset', None) + qs = filterset(params, qs).qs + url = f'{url}?{params.urlencode()}' object_count = qs.count - counts.append((model, object_count)) + counts.append((model, object_count, url)) else: - counts.append((model, None)) + counts.append((model, None, None)) return render_to_string(self.template_name, { 'counts': counts, diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py index c344a3214..818b8a52f 100644 --- a/netbox/extras/forms/bulk_import.py +++ b/netbox/extras/forms/bulk_import.py @@ -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. 00ff00)')), } + + +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' + ) diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index 056302343..fae15d041 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -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')), diff --git a/netbox/extras/lookups.py b/netbox/extras/lookups.py index 77fe2301e..a8d89c943 100644 --- a/netbox/extras/lookups.py +++ b/netbox/extras/lookups.py @@ -7,12 +7,14 @@ class Empty(Lookup): Filter on whether a string is empty. """ lookup_name = 'empty' + prepare_rhs = False - def as_sql(self, qn, connection): - lhs, lhs_params = self.process_lhs(qn, connection) - rhs, rhs_params = self.process_rhs(qn, connection) - params = lhs_params + rhs_params - return 'CAST(LENGTH(%s) AS BOOLEAN) != %s' % (lhs, rhs), params + def as_sql(self, compiler, connection): + sql, params = compiler.compile(self.lhs) + if self.rhs: + return f"CAST(LENGTH({sql}) AS BOOLEAN) IS NOT TRUE", params + else: + return f"CAST(LENGTH({sql}) AS BOOLEAN) IS TRUE", params class NetContainsOrEquals(Lookup): diff --git a/netbox/extras/management/commands/runscript.py b/netbox/extras/management/commands/runscript.py index 76ceeb239..b42e9b47d 100644 --- a/netbox/extras/management/commands/runscript.py +++ b/netbox/extras/management/commands/runscript.py @@ -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() diff --git a/netbox/extras/migrations/0066_customfield_name_validation.py b/netbox/extras/migrations/0066_customfield_name_validation.py index 7a768c10c..3d2c51399 100644 --- a/netbox/extras/migrations/0066_customfield_name_validation.py +++ b/netbox/extras/migrations/0066_customfield_name_validation.py @@ -13,6 +13,22 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='customfield', name='name', - field=models.CharField(max_length=50, unique=True, validators=[django.core.validators.RegexValidator(flags=re.RegexFlag['IGNORECASE'], message='Only alphanumeric characters and underscores are allowed.', regex='^[a-z0-9_]+$')]), + field=models.CharField( + max_length=50, + unique=True, + validators=[ + django.core.validators.RegexValidator( + flags=re.RegexFlag['IGNORECASE'], + message='Only alphanumeric characters and underscores are allowed.', + regex='^[a-z0-9_]+$', + ), + django.core.validators.RegexValidator( + flags=re.RegexFlag['IGNORECASE'], + inverse_match=True, + message='Double underscores are not permitted in custom field names.', + regex=r'__', + ), + ], + ), ), ] diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 439d15edc..be3540f08 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -85,6 +85,12 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): message="Only alphanumeric characters and underscores are allowed.", flags=re.IGNORECASE ), + RegexValidator( + regex=r'__', + message="Double underscores are not permitted in custom field names.", + flags=re.IGNORECASE, + inverse_match=True + ), ) ) label = models.CharField( diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 16e4fb577..9433ab6b0 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -274,10 +274,10 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): :param context: The context passed to Jinja2 """ - text = render_jinja2(self.link_text, context) + text = render_jinja2(self.link_text, context).strip() if not text: return {} - link = render_jinja2(self.link_url, context) + link = render_jinja2(self.link_url, context).strip() link_target = ' target="_blank"' if self.new_window else '' # Sanitize link text diff --git a/netbox/extras/models/scripts.py b/netbox/extras/models/scripts.py index 1a7559e53..de48aae8e 100644 --- a/netbox/extras/models/scripts.py +++ b/netbox/extras/models/scripts.py @@ -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', []) diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index 59b45c059..9e4924532 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -13,6 +13,7 @@ __all__ = ( 'CustomFieldTable', 'CustomLinkTable', 'ExportTemplateTable', + 'ImageAttachmentTable', 'JournalEntryTable', 'ObjectChangeTable', 'SavedFilterTable', @@ -21,6 +22,14 @@ __all__ = ( 'WebhookTable', ) +IMAGEATTACHMENT_IMAGE = ''' +{% if record.image %} + {{ record }} +{% else %} + — +{% endif %} +''' + class CustomFieldTable(NetBoxTable): name = tables.Column( @@ -72,6 +81,7 @@ class ExportTemplateTable(NetBoxTable): linkify=True ) is_synced = columns.BooleanColumn( + orderable=False, verbose_name='Synced' ) @@ -86,6 +96,31 @@ class ExportTemplateTable(NetBoxTable): ) +class ImageAttachmentTable(NetBoxTable): + id = tables.Column( + linkify=False + ) + content_type = columns.ContentTypeColumn() + parent = tables.Column( + linkify=True + ) + image = tables.TemplateColumn( + template_code=IMAGEATTACHMENT_IMAGE, + ) + 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 @@ -195,6 +230,7 @@ class ConfigContextTable(NetBoxTable): verbose_name='Active' ) is_synced = columns.BooleanColumn( + orderable=False, verbose_name='Synced' ) @@ -219,6 +255,7 @@ class ConfigTemplateTable(NetBoxTable): linkify=True ) is_synced = columns.BooleanColumn( + orderable=False, verbose_name='Synced' ) tags = columns.TagColumn( diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index 6a3a3d074..3fd0dc83e 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -29,6 +29,17 @@ class CustomFieldTest(TestCase): cls.object_type = ContentType.objects.get_for_model(Site) + def test_invalid_name(self): + """ + Try creating a CustomField with an invalid name. + """ + with self.assertRaises(ValidationError): + # Invalid character + CustomField(name='?', type=CustomFieldTypeChoices.TYPE_TEXT).full_clean() + with self.assertRaises(ValidationError): + # Double underscores not permitted + CustomField(name='foo__bar', type=CustomFieldTypeChoices.TYPE_TEXT).full_clean() + def test_text_field(self): value = 'Foobar!' diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index f04c53add..c4fc3d938 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -73,6 +73,7 @@ urlpatterns = [ path('config-templates//', 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//', 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//', include(get_model_urls('extras', 'journalentry'))), # Change logging diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 286ec76cd..6cbadf09d 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -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)) diff --git a/netbox/extras/webhooks.py b/netbox/extras/webhooks.py index 23702949a..1fc869ee8 100644 --- a/netbox/extras/webhooks.py +++ b/netbox/extras/webhooks.py @@ -9,6 +9,7 @@ from netbox.config import get_config from netbox.constants import RQ_QUEUE_DEFAULT from netbox.registry import registry from utilities.api import get_serializer_for_model +from utilities.rqworker import get_rq_retry from utilities.utils import serialize_object from .choices import * from .models import Webhook @@ -116,5 +117,6 @@ def flush_webhooks(queue): snapshots=data['snapshots'], timestamp=str(timezone.now()), username=data['username'], - request_id=data['request_id'] + request_id=data['request_id'], + retry=get_rq_retry() ) diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py index 9951b72e4..d80dabe5f 100644 --- a/netbox/ipam/forms/model_forms.py +++ b/netbox/ipam/forms/model_forms.py @@ -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) @@ -378,6 +328,12 @@ class IPAddressForm(TenancyForm, NetBoxModelForm): ): self.initial['primary_for_parent'] = True + # Disable object assignment fields if the IP address is designated as primary + if self.initial.get('primary_for_parent'): + self.fields['interface'].disabled = True + self.fields['vminterface'].disabled = True + self.fields['fhrpgroup'].disabled = True + def clean(self): super().clean() @@ -390,7 +346,12 @@ class IPAddressForm(TenancyForm, NetBoxModelForm): selected_objects[1]: "An IP address can only be assigned to a single object." }) elif selected_objects: - self.instance.assigned_object = self.cleaned_data[selected_objects[0]] + assigned_object = self.cleaned_data[selected_objects[0]] + if self.cleaned_data['primary_for_parent'] and assigned_object != self.instance.assigned_object: + raise ValidationError( + "Cannot reassign IP address while it is designated as the primary IP for the parent object" + ) + self.instance.assigned_object = assigned_object else: self.instance.assigned_object = None @@ -401,6 +362,18 @@ class IPAddressForm(TenancyForm, NetBoxModelForm): 'primary_for_parent', "Only IP addresses assigned to an interface can be designated as primary IPs." ) + # Do not allow assigning a network ID or broadcast address to an interface. + if interface and (address := self.cleaned_data.get('address')): + if address.ip == address.network: + msg = f"{address} is a network ID, which may not be assigned to an interface." + if address.version == 4 and address.prefixlen not in (31, 32): + raise ValidationError(msg) + if address.version == 6 and address.prefixlen not in (127, 128): + raise ValidationError(msg) + if address.version == 4 and address.ip == address.broadcast and address.prefixlen not in (31, 32): + msg = f"{address} is a broadcast address, which may not be assigned to an interface." + raise ValidationError(msg) + def save(self, *args, **kwargs): ipaddress = super().save(*args, **kwargs) diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index 28901ab8e..015f9220c 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -783,6 +783,14 @@ class IPAddress(PrimaryModel): if available_ips: return next(iter(available_ips)) + def get_related_ips(self): + """ + Return all IPAddresses belonging to the same VRF. + """ + return IPAddress.objects.exclude(address=str(self.address)).filter( + vrf=self.vrf, address__net_contained_or_equal=str(self.address) + ) + def clean(self): super().clean() diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 804d1603c..e03c0ec3e 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -10,11 +10,13 @@ from circuits.models import Provider from dcim.filtersets import InterfaceFilterSet from dcim.models import Interface, Site from netbox.views import generic +from tenancy.views import ObjectContactsView from utilities.utils import count_related 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 @@ -496,7 +498,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( @@ -755,19 +757,9 @@ class IPAddressView(generic.ObjectView): # Limit to a maximum of 10 duplicates displayed here duplicate_ips_table = tables.IPAddressTable(duplicate_ips[:10], orderable=False) - # Related IP table - related_ips = IPAddress.objects.restrict(request.user, 'view').exclude( - address=str(instance.address) - ).filter( - vrf=instance.vrf, address__net_contained_or_equal=str(instance.address) - ) - related_ips_table = tables.IPAddressTable(related_ips, orderable=False) - related_ips_table.configure(request) - return { 'parent_prefixes_table': parent_prefixes_table, 'duplicate_ips_table': duplicate_ips_table, - 'related_ips_table': related_ips_table, } @@ -872,6 +864,24 @@ class IPAddressBulkDeleteView(generic.BulkDeleteView): table = tables.IPAddressTable +@register_model_view(IPAddress, 'related_ips', path='related-ip-addresses') +class IPAddressRelatedIPsView(generic.ObjectChildrenView): + queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant') + child_model = IPAddress + table = tables.IPAddressTable + filterset = filtersets.IPAddressFilterSet + template_name = 'ipam/ipaddress/ip_addresses.html' + tab = ViewTab( + label=_('Related IPs'), + badge=lambda x: x.get_related_ips().count(), + weight=500, + hide_if_empty=True, + ) + + def get_children(self, request, parent): + return parent.get_related_ips().restrict(request.user, 'view') + + # # VLAN groups # @@ -1298,6 +1308,11 @@ class L2VPNBulkDeleteView(generic.BulkDeleteView): table = tables.L2VPNTable +@register_model_view(L2VPN, 'contacts') +class L2VPNContactsView(ObjectContactsView): + queryset = L2VPN.objects.all() + + # # L2VPN terminations # diff --git a/netbox/netbox/api/viewsets/mixins.py b/netbox/netbox/api/viewsets/mixins.py index 8b629bbc6..fde486fe9 100644 --- a/netbox/netbox/api/viewsets/mixins.py +++ b/netbox/netbox/api/viewsets/mixins.py @@ -15,11 +15,12 @@ from utilities.api import get_serializer_for_model __all__ = ( 'BriefModeMixin', + 'BulkDestroyModelMixin', 'BulkUpdateModelMixin', 'CustomFieldsMixin', 'ExportTemplatesMixin', - 'BulkDestroyModelMixin', 'ObjectValidationMixin', + 'SequentialBulkCreatesMixin', ) @@ -94,6 +95,30 @@ class ExportTemplatesMixin: return super().list(request, *args, **kwargs) +class SequentialBulkCreatesMixin: + """ + Perform bulk creation of new objects sequentially, rather than all at once. This ensures that any validation + which depends on the evaluation of existing objects (such as checking for free space within a rack) functions + appropriately. + """ + @transaction.atomic + def create(self, request, *args, **kwargs): + if not isinstance(request.data, list): + # Creating a single object + return super().create(request, *args, **kwargs) + + return_data = [] + for data in request.data: + serializer = self.get_serializer(data=data) + serializer.is_valid(raise_exception=True) + self.perform_create(serializer) + return_data.append(serializer.data) + + headers = self.get_success_headers(serializer.data) + + return Response(return_data, status=status.HTTP_201_CREATED, headers=headers) + + class BulkUpdateModelMixin: """ Support bulk modification of objects using the list endpoint for a model. Accepts a PATCH action with a list of one diff --git a/netbox/netbox/authentication.py b/netbox/netbox/authentication.py index 798cb80e2..61dfe2fdb 100644 --- a/netbox/netbox/authentication.py +++ b/netbox/netbox/authentication.py @@ -156,8 +156,11 @@ class RemoteUserBackend(_RemoteUserBackend): try: group_list.append(Group.objects.get(name=name)) except Group.DoesNotExist: - logging.error( - f"Could not assign group {name} to remotely-authenticated user {user}: Group not found") + if settings.REMOTE_AUTH_AUTO_CREATE_GROUPS: + group_list.append(Group.objects.create(name=name)) + else: + logging.error( + f"Could not assign group {name} to remotely-authenticated user {user}: Group not found") if group_list: user.groups.set(group_list) logger.debug( diff --git a/netbox/netbox/config/parameters.py b/netbox/netbox/config/parameters.py index 2bfa234f0..9c613217c 100644 --- a/netbox/netbox/config/parameters.py +++ b/netbox/netbox/config/parameters.py @@ -28,6 +28,17 @@ PARAMS = ( ), }, ), + ConfigParam( + name='BANNER_MAINTENANCE', + label=_('Maintenance banner'), + default='NetBox is currently in maintenance mode. Functionality may be limited.', + description=_('Additional content to display when in maintenance mode'), + field_kwargs={ + 'widget': forms.Textarea( + attrs={'class': 'vLargeTextField'} + ), + }, + ), ConfigParam( name='BANNER_TOP', label=_('Top banner'), diff --git a/netbox/netbox/filtersets.py b/netbox/netbox/filtersets.py index a0c1edee8..9a2385c45 100644 --- a/netbox/netbox/filtersets.py +++ b/netbox/netbox/filtersets.py @@ -177,7 +177,8 @@ class BaseFilterSet(django_filters.FilterSet): # create the new filter with the same type because there is no guarantee the defined type # is the same as the default type for the field resolve_field(field, lookup_expr) # Will raise FieldLookupError if the lookup is invalid - new_filter = type(existing_filter)( + filter_cls = django_filters.BooleanFilter if lookup_expr == 'empty' else type(existing_filter) + new_filter = filter_cls( field_name=field_name, lookup_expr=lookup_expr, label=existing_filter.label, @@ -224,6 +225,14 @@ class BaseFilterSet(django_filters.FilterSet): return filters + @classmethod + def filter_for_lookup(cls, field, lookup_type): + + if lookup_type == 'empty': + return django_filters.BooleanFilter, {} + + return super().filter_for_lookup(field, lookup_type) + class ChangeLoggedModelFilterSet(BaseFilterSet): """ diff --git a/netbox/netbox/middleware.py b/netbox/netbox/middleware.py index f9faa9c5d..76b3e42a8 100644 --- a/netbox/netbox/middleware.py +++ b/netbox/netbox/middleware.py @@ -3,19 +3,21 @@ import uuid from urllib import parse from django.conf import settings -from django.contrib import auth +from django.contrib import auth, messages from django.contrib.auth.middleware import RemoteUserMiddleware as RemoteUserMiddleware_ from django.core.exceptions import ImproperlyConfigured -from django.db import ProgrammingError +from django.db import connection, ProgrammingError +from django.db.utils import InternalError from django.http import Http404, HttpResponseRedirect from extras.context_managers import change_logging -from netbox.config import clear_config +from netbox.config import clear_config, get_config from netbox.views import handler_500 from utilities.api import is_api_request, rest_api_server_error __all__ = ( 'CoreMiddleware', + 'MaintenanceModeMiddleware', 'RemoteUserMiddleware', ) @@ -166,3 +168,47 @@ class RemoteUserMiddleware(RemoteUserMiddleware_): groups = [] logger.debug(f"Groups are {groups}") return groups + + +class MaintenanceModeMiddleware: + """ + Middleware that checks if the application is in maintenance mode + and restricts write-related operations to the database. + """ + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + if get_config().MAINTENANCE_MODE: + self._set_session_type( + allow_write=request.path_info.startswith(settings.MAINTENANCE_EXEMPT_PATHS) + ) + + return self.get_response(request) + + @staticmethod + def _set_session_type(allow_write): + """ + Prevent any write-related database operations. + + Args: + allow_write (bool): If True, write operations will be permitted. + """ + with connection.cursor() as cursor: + mode = 'READ WRITE' if allow_write else 'READ ONLY' + cursor.execute(f'SET SESSION CHARACTERISTICS AS TRANSACTION {mode};') + + def process_exception(self, request, exception): + """ + Prevent any write-related database operations if an exception is raised. + """ + if isinstance(exception, InternalError): + error_message = 'NetBox is currently operating in maintenance mode and is unable to perform write ' \ + 'operations. Please try again later.' + + if is_api_request(request): + return rest_api_server_error(request, error=error_message) + + messages.error(request, error_message) + return HttpResponseRedirect(request.path_info) diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index 6d82e2a2b..8bacba534 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -197,11 +197,15 @@ class CustomFieldsMixin(models.Model): data = {} for field in CustomField.objects.get_for_model(self): - # Skip fields that are hidden if 'omit_hidden' is set - if omit_hidden and field.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN: - continue - value = self.custom_field_data.get(field.name) + + # Skip fields that are hidden if 'omit_hidden' is set + if omit_hidden: + if field.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN: + continue + if field.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN_IFUNSET and not value: + continue + data[field] = field.deserialize(value) return data @@ -227,6 +231,8 @@ class CustomFieldsMixin(models.Model): for cf in visible_custom_fields: value = self.custom_field_data.get(cf.name) + if value in (None, []) and cf.ui_visibility == CustomFieldVisibilityChoices.VISIBILITY_HIDDEN_IFUNSET: + continue value = cf.deserialize(value) groups[cf.group_name][cf] = value diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index 2b1428d27..d139546d9 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -102,7 +102,7 @@ CONNECTIONS_MENU = Menu( label=_('Connections'), items=( get_model_item('dcim', 'cable', _('Cables'), actions=['import']), - get_model_item('wireless', 'wirelesslink', _('Wireless Links'), actions=['import']), + get_model_item('wireless', 'wirelesslink', _('Wireless Links')), MenuItem( link='dcim:interface_connections_list', link_text=_('Interface Connections'), @@ -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=[]), ), ), diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 206b00fe7..e77ac43c0 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -25,7 +25,7 @@ from netbox.constants import RQ_QUEUE_DEFAULT, RQ_QUEUE_HIGH, RQ_QUEUE_LOW # Environment setup # -VERSION = '3.5.1-dev' +VERSION = '3.5.4-dev' # Hostname HOSTNAME = platform.node() @@ -122,6 +122,7 @@ PLUGINS_CONFIG = getattr(configuration, 'PLUGINS_CONFIG', {}) QUEUE_MAPPINGS = getattr(configuration, 'QUEUE_MAPPINGS', {}) RELEASE_CHECK_URL = getattr(configuration, 'RELEASE_CHECK_URL', None) REMOTE_AUTH_AUTO_CREATE_USER = getattr(configuration, 'REMOTE_AUTH_AUTO_CREATE_USER', False) +REMOTE_AUTH_AUTO_CREATE_GROUPS = getattr(configuration, 'REMOTE_AUTH_AUTO_CREATE_GROUPS', False) REMOTE_AUTH_BACKEND = getattr(configuration, 'REMOTE_AUTH_BACKEND', 'netbox.authentication.RemoteUserBackend') REMOTE_AUTH_DEFAULT_GROUPS = getattr(configuration, 'REMOTE_AUTH_DEFAULT_GROUPS', []) REMOTE_AUTH_DEFAULT_PERMISSIONS = getattr(configuration, 'REMOTE_AUTH_DEFAULT_PERMISSIONS', {}) @@ -139,6 +140,8 @@ REMOTE_AUTH_STAFF_USERS = getattr(configuration, 'REMOTE_AUTH_STAFF_USERS', []) REMOTE_AUTH_GROUP_SEPARATOR = getattr(configuration, 'REMOTE_AUTH_GROUP_SEPARATOR', '|') REPORTS_ROOT = getattr(configuration, 'REPORTS_ROOT', os.path.join(BASE_DIR, 'reports')).rstrip('/') RQ_DEFAULT_TIMEOUT = getattr(configuration, 'RQ_DEFAULT_TIMEOUT', 300) +RQ_RETRY_INTERVAL = getattr(configuration, 'RQ_RETRY_INTERVAL', 60) +RQ_RETRY_MAX = getattr(configuration, 'RQ_RETRY_MAX', 0) SCRIPTS_ROOT = getattr(configuration, 'SCRIPTS_ROOT', os.path.join(BASE_DIR, 'scripts')).rstrip('/') SEARCH_BACKEND = getattr(configuration, 'SEARCH_BACKEND', 'netbox.search.backends.CachedValueSearchBackend') SECURE_SSL_REDIRECT = getattr(configuration, 'SECURE_SSL_REDIRECT', False) @@ -382,6 +385,7 @@ MIDDLEWARE = [ 'django.middleware.security.SecurityMiddleware', 'netbox.middleware.RemoteUserMiddleware', 'netbox.middleware.CoreMiddleware', + 'netbox.middleware.MaintenanceModeMiddleware', 'django_prometheus.middleware.PrometheusAfterMiddleware', ] @@ -476,6 +480,11 @@ AUTH_EXEMPT_PATHS = ( f'/{BASE_PATH}metrics', ) +# All URLs starting with a string listed here are exempt from maintenance mode enforcement +MAINTENANCE_EXEMPT_PATHS = ( + f'/{BASE_PATH}admin/', +) + SERIALIZATION_MODULES = { 'json': 'utilities.serializers.json', } diff --git a/netbox/netbox/tables/columns.py b/netbox/netbox/tables/columns.py index 66ee787a8..9ef327026 100644 --- a/netbox/netbox/tables/columns.py +++ b/netbox/netbox/tables/columns.py @@ -234,8 +234,12 @@ class ActionsColumn(tables.Column): return '' model = table.Meta.model - request = getattr(table, 'context', {}).get('request') - url_appendix = f'?return_url={quote(request.get_full_path())}' if request else '' + if request := getattr(table, 'context', {}).get('request'): + return_url = request.GET.get('return_url', request.get_full_path()) + url_appendix = f'?return_url={quote(return_url)}' + else: + url_appendix = '' + html = '' # Compile actions menu diff --git a/netbox/netbox/tests/test_authentication.py b/netbox/netbox/tests/test_authentication.py index 790cb4bd8..4e46996b5 100644 --- a/netbox/netbox/tests/test_authentication.py +++ b/netbox/netbox/tests/test_authentication.py @@ -310,6 +310,50 @@ class ExternalAuthenticationTestCase(TestCase): list(new_user.groups.all()) ) + @override_settings( + REMOTE_AUTH_ENABLED=True, + REMOTE_AUTH_AUTO_CREATE_USER=True, + REMOTE_AUTH_GROUP_SYNC_ENABLED=True, + REMOTE_AUTH_AUTO_CREATE_GROUPS=True, + LOGIN_REQUIRED=True, + ) + def test_remote_auth_remote_groups_autocreate(self): + """ + Test enabling remote authentication with group sync and autocreate + enabled with the default configuration. + """ + headers = { + "HTTP_REMOTE_USER": "remoteuser2", + "HTTP_REMOTE_USER_GROUP": "Group 1|Group 2", + } + + self.assertTrue(settings.REMOTE_AUTH_ENABLED) + self.assertTrue(settings.REMOTE_AUTH_AUTO_CREATE_USER) + self.assertTrue(settings.REMOTE_AUTH_AUTO_CREATE_GROUPS) + self.assertTrue(settings.REMOTE_AUTH_GROUP_SYNC_ENABLED) + self.assertEqual(settings.REMOTE_AUTH_HEADER, "HTTP_REMOTE_USER") + self.assertEqual(settings.REMOTE_AUTH_GROUP_HEADER, "HTTP_REMOTE_USER_GROUP") + self.assertEqual(settings.REMOTE_AUTH_GROUP_SEPARATOR, "|") + + groups = ( + Group(name="Group 1"), + Group(name="Group 2"), + ) + + response = self.client.get(reverse("home"), follow=True, **headers) + self.assertEqual(response.status_code, 200) + + new_user = User.objects.get(username="remoteuser2") + self.assertEqual( + int(self.client.session.get("_auth_user_id")), + new_user.pk, + msg="Authentication failed", + ) + self.assertListEqual( + [group.name for group in groups], + [group.name for group in list(new_user.groups.all())], + ) + @override_settings( REMOTE_AUTH_ENABLED=True, REMOTE_AUTH_AUTO_CREATE_USER=True, diff --git a/netbox/project-static/dist/netbox-dark.css b/netbox/project-static/dist/netbox-dark.css index a690cfcac..11110069e 100644 Binary files a/netbox/project-static/dist/netbox-dark.css and b/netbox/project-static/dist/netbox-dark.css differ diff --git a/netbox/project-static/dist/netbox-light.css b/netbox/project-static/dist/netbox-light.css index fad60f89b..8a3c83af9 100644 Binary files a/netbox/project-static/dist/netbox-light.css and b/netbox/project-static/dist/netbox-light.css differ diff --git a/netbox/project-static/dist/netbox-print.css b/netbox/project-static/dist/netbox-print.css index 37814cd20..ef2682e0a 100644 Binary files a/netbox/project-static/dist/netbox-print.css and b/netbox/project-static/dist/netbox-print.css differ diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index 8caaaa9a0..9642d1585 100644 Binary files a/netbox/project-static/dist/netbox.js and b/netbox/project-static/dist/netbox.js differ diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map index 0201e7bf8..f86d50148 100644 Binary files a/netbox/project-static/dist/netbox.js.map and b/netbox/project-static/dist/netbox.js.map differ diff --git a/netbox/project-static/package.json b/netbox/project-static/package.json index f10b5b7ac..98e1a5c60 100644 --- a/netbox/project-static/package.json +++ b/netbox/project-static/package.json @@ -30,6 +30,7 @@ "dayjs": "^1.11.5", "flatpickr": "4.6.13", "gridstack": "^7.2.3", + "html-entities": "^2.3.3", "htmx.org": "^1.8.0", "just-debounce-it": "^3.1.1", "query-string": "^7.1.1", diff --git a/netbox/project-static/src/htmx.ts b/netbox/project-static/src/htmx.ts index 09d423cbd..2410a5fd9 100644 --- a/netbox/project-static/src/htmx.ts +++ b/netbox/project-static/src/htmx.ts @@ -2,9 +2,10 @@ import { getElements, isTruthy } from './util'; import { initButtons } from './buttons'; import { initSelect } from './select'; import { initObjectSelector } from './objectSelector'; +import { initBootstrap } from './bs'; function initDepedencies(): void { - for (const init of [initButtons, initSelect, initObjectSelector]) { + for (const init of [initButtons, initSelect, initObjectSelector, initBootstrap]) { init(); } } @@ -22,4 +23,8 @@ export function initHtmx(): void { } } } + + for (const element of getElements('[hx-trigger=load]')) { + element.addEventListener('htmx:afterSettle', initDepedencies); + } } diff --git a/netbox/project-static/src/select/api/apiSelect.ts b/netbox/project-static/src/select/api/apiSelect.ts index f5b605d58..53996910e 100644 --- a/netbox/project-static/src/select/api/apiSelect.ts +++ b/netbox/project-static/src/select/api/apiSelect.ts @@ -1,5 +1,6 @@ import { readableColor } from 'color2k'; import debounce from 'just-debounce-it'; +import { encode } from 'html-entities'; import queryString from 'query-string'; import SlimSelect from 'slim-select'; import { createToast } from '../../bs'; @@ -446,7 +447,7 @@ export class APISelect { // Build SlimSelect options from all already-selected options. const preSelectedOptions = preSelected.map(option => ({ value: option.value, - text: option.innerText, + text: encode(option.innerText), selected: true, disabled: false, })) as Option[]; @@ -454,7 +455,7 @@ export class APISelect { let options = [] as Option[]; for (const result of data.results) { - let text = result.display; + let text = encode(result.display); if (typeof result._depth === 'number' && result._depth > 0) { // If the object has a `_depth` property, indent its display text. diff --git a/netbox/project-static/styles/netbox.scss b/netbox/project-static/styles/netbox.scss index 37f6c21c4..b294d67bd 100644 --- a/netbox/project-static/styles/netbox.scss +++ b/netbox/project-static/styles/netbox.scss @@ -231,6 +231,10 @@ table { p { // Remove spacing from paragraph elements within tables. + margin-bottom: 0.5em; + } + + p:last-child { margin-bottom: 0; } } diff --git a/netbox/project-static/yarn.lock b/netbox/project-static/yarn.lock index c4bee7557..2adc50001 100644 --- a/netbox/project-static/yarn.lock +++ b/netbox/project-static/yarn.lock @@ -1818,6 +1818,11 @@ has@^1.0.3: dependencies: function-bind "^1.1.1" +html-entities@^2.3.3: + version "2.3.3" + resolved "https://registry.yarnpkg.com/html-entities/-/html-entities-2.3.3.tgz#117d7626bece327fc8baace8868fa6f5ef856e46" + integrity sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA== + htmx.org@^1.8.0: version "1.8.0" resolved "https://registry.yarnpkg.com/htmx.org/-/htmx.org-1.8.0.tgz#f3a2f681f3e2b6357b5a29bba24a2572a8e48fd3" diff --git a/netbox/templates/base/layout.html b/netbox/templates/base/layout.html index 6b247d81a..9e8b7d7bf 100644 --- a/netbox/templates/base/layout.html +++ b/netbox/templates/base/layout.html @@ -77,10 +77,10 @@ Blocks: {% endif %} - {% if config.MAINTENANCE_MODE %} + {% if config.MAINTENANCE_MODE and config.BANNER_MAINTENANCE %} {% endif %} diff --git a/netbox/templates/circuits/circuit.html b/netbox/templates/circuits/circuit.html index ee994e959..a5913e2ad 100644 --- a/netbox/templates/circuits/circuit.html +++ b/netbox/templates/circuits/circuit.html @@ -70,7 +70,6 @@
{% include 'circuits/inc/circuit_termination.html' with termination=object.termination_a side='A' %} {% include 'circuits/inc/circuit_termination.html' with termination=object.termination_z side='Z' %} - {% include 'inc/panels/contacts.html' %} {% include 'inc/panels/image_attachments.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/circuits/inc/circuit_termination.html b/netbox/templates/circuits/inc/circuit_termination.html index 22c204afc..b26a09205 100644 --- a/netbox/templates/circuits/inc/circuit_termination.html +++ b/netbox/templates/circuits/inc/circuit_termination.html @@ -132,9 +132,16 @@ {% for field, value in fields.items %} - - {{ field }} - + {{ field }} + {% if field.description %} + + {% endif %} + {% customfield_value field value %} diff --git a/netbox/templates/circuits/provider.html b/netbox/templates/circuits/provider.html index 695202176..c721d5a58 100644 --- a/netbox/templates/circuits/provider.html +++ b/netbox/templates/circuits/provider.html @@ -29,17 +29,6 @@ {% endfor %} - - - Account - - {{ object.account|placeholder }} - Description {{ object.description|placeholder }} @@ -54,7 +43,6 @@
{% include 'inc/panels/related_objects.html' %} {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/contacts.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/circuits/provideraccount.html b/netbox/templates/circuits/provideraccount.html index 63344ada1..c55663b4a 100644 --- a/netbox/templates/circuits/provideraccount.html +++ b/netbox/templates/circuits/provideraccount.html @@ -38,7 +38,6 @@ {% include 'inc/panels/related_objects.html' %} {% include 'inc/panels/comments.html' %} {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/contacts.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 2c79ab006..b0e67269c 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -298,8 +298,30 @@
{% endif %} - {% include 'inc/panels/contacts.html' %} {% include 'inc/panels/image_attachments.html' %} +
+
Dimensions
+
+ + + + + + + + + +
Height + {{ object.device_type.u_height }}U +
Weight + {% if object.total_weight %} + {{ object.total_weight|floatformat }} Kilograms + {% else %} + {{ ''|placeholder }} + {% endif %} +
+
+
{% if object.rack and object.position %}
diff --git a/netbox/templates/dcim/device/render_config.html b/netbox/templates/dcim/device/render_config.html index b6e16701f..dfda7cdf6 100644 --- a/netbox/templates/dcim/device/render_config.html +++ b/netbox/templates/dcim/device/render_config.html @@ -28,8 +28,22 @@
-
Context Data
-
{{ context_data|pprint }}
+
+
+
+

+ +

+
+
+
{{ context_data|pprint }}
+
+
+
+
+
diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html index db0fd7dfd..11f262eeb 100644 --- a/netbox/templates/dcim/interface.html +++ b/netbox/templates/dcim/interface.html @@ -123,11 +123,11 @@ - + - + diff --git a/netbox/templates/dcim/location.html b/netbox/templates/dcim/location.html index 193d93f9a..795aeb35f 100644 --- a/netbox/templates/dcim/location.html +++ b/netbox/templates/dcim/location.html @@ -65,7 +65,6 @@
{% include 'inc/panels/related_objects.html' %} - {% include 'inc/panels/contacts.html' %} {% include 'dcim/inc/nonracked_devices.html' %} {% include 'inc/panels/image_attachments.html' %} {% plugin_right_page object %} diff --git a/netbox/templates/dcim/manufacturer.html b/netbox/templates/dcim/manufacturer.html index a60b3503c..8233b6fc8 100644 --- a/netbox/templates/dcim/manufacturer.html +++ b/netbox/templates/dcim/manufacturer.html @@ -51,7 +51,6 @@
{% include 'inc/panels/related_objects.html' %} {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/contacts.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/dcim/powerpanel.html b/netbox/templates/dcim/powerpanel.html index af08f3023..ea9210ba7 100644 --- a/netbox/templates/dcim/powerpanel.html +++ b/netbox/templates/dcim/powerpanel.html @@ -40,7 +40,6 @@
{% include 'inc/panels/related_objects.html' %} {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/contacts.html' %} {% include 'inc/panels/image_attachments.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 9cb046b4e..52b5d4bfe 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -191,7 +191,6 @@ {% include 'inc/panels/related_objects.html' %} {% include 'dcim/inc/nonracked_devices.html' %} - {% include 'inc/panels/contacts.html' %} {% plugin_right_page object %} diff --git a/netbox/templates/dcim/region.html b/netbox/templates/dcim/region.html index 85587e4b5..05cc424d7 100644 --- a/netbox/templates/dcim/region.html +++ b/netbox/templates/dcim/region.html @@ -46,7 +46,6 @@
{% include 'inc/panels/related_objects.html' %} - {% include 'inc/panels/contacts.html' %} {% plugin_right_page object %}
diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index d6de8f3cb..697737ceb 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -87,11 +87,13 @@
MAC Address{{ object.mac_address|placeholder }}{{ object.mac_address|placeholder }}
WWN{{ object.wwn|placeholder }}{{ object.wwn|placeholder }}
VRF Physical Address {% if object.physical_address %} - + {% if config.MAPS_URL %} + + {% endif %} {{ object.physical_address|linebreaksbr }} {% else %} {{ ''|placeholder }} @@ -106,11 +108,13 @@ GPS Coordinates {% if object.latitude and object.longitude %} - + {% if config.MAPS_URL %} + + {% endif %} {{ object.latitude }}, {{ object.longitude }} {% else %} {{ ''|placeholder }} @@ -127,7 +131,6 @@
{% include 'inc/panels/related_objects.html' with filter_name='site_id' %} - {% include 'inc/panels/contacts.html' %}
Locations
diff --git a/netbox/templates/dcim/sitegroup.html b/netbox/templates/dcim/sitegroup.html index 2cf8e7168..819022a34 100644 --- a/netbox/templates/dcim/sitegroup.html +++ b/netbox/templates/dcim/sitegroup.html @@ -42,7 +42,6 @@
{% include 'inc/panels/tags.html' %} {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/contacts.html' %} {% plugin_left_page object %}
diff --git a/netbox/templates/extras/dashboard/widgets/objectcounts.html b/netbox/templates/extras/dashboard/widgets/objectcounts.html index d0e604c9a..8b68dc166 100644 --- a/netbox/templates/extras/dashboard/widgets/objectcounts.html +++ b/netbox/templates/extras/dashboard/widgets/objectcounts.html @@ -1,10 +1,8 @@ -{% load helpers %} - {% if counts %}
- {% for model, count in counts %} + {% for model, count, url in counts %} {% if count != None %} - +
{{ model|meta:"verbose_name_plural"|bettertitle }}
{{ count }}
diff --git a/netbox/templates/extras/script_list.html b/netbox/templates/extras/script_list.html index bccbce589..9a67e2b10 100644 --- a/netbox/templates/extras/script_list.html +++ b/netbox/templates/extras/script_list.html @@ -37,43 +37,49 @@
{% include 'inc/sync_warning.html' with object=module %} - - - - - - - - - - - {% with jobs=module.get_latest_jobs %} - {% for script_name, script_class in module.scripts.items %} - - - - {% with last_result=jobs|get_key:script_class.name %} - {% if last_result %} - - - {% else %} - - - {% endif %} - {% endwith %} - - {% endfor %} - {% endwith %} - -
NameDescriptionLast RunStatus
- {{ script_class.name }} - - {{ script_class.Meta.description|markdown|placeholder }} - - {{ last_result.created|annotated_date }} - - {% badge last_result.get_status_display last_result.get_status_color %} - Never{{ ''|placeholder }}
+ {% if not module.scripts %} + + {% else %} + + + + + + + + + + + {% with jobs=module.get_latest_jobs %} + {% for script_name, script_class in module.scripts.items %} + + + + {% with last_result=jobs|get_key:script_class.name %} + {% if last_result %} + + + {% else %} + + + {% endif %} + {% endwith %} + + {% endfor %} + {% endwith %} + +
NameDescriptionLast RunStatus
+ {{ script_class.name }} + + {{ script_class.Meta.description|markdown|placeholder }} + + {{ last_result.created|annotated_date }} + + {% badge last_result.get_status_display last_result.get_status_color %} + Never{{ ''|placeholder }}
+ {% endif %}
{% empty %} diff --git a/netbox/templates/inc/panels/contacts.html b/netbox/templates/inc/panels/contacts.html deleted file mode 100644 index 359ad8d7e..000000000 --- a/netbox/templates/inc/panels/contacts.html +++ /dev/null @@ -1,63 +0,0 @@ -{% load helpers %} - -
diff --git a/netbox/templates/inc/panels/custom_fields.html b/netbox/templates/inc/panels/custom_fields.html index 45843eea5..7f5f4cc27 100644 --- a/netbox/templates/inc/panels/custom_fields.html +++ b/netbox/templates/inc/panels/custom_fields.html @@ -12,8 +12,15 @@ {% for field, value in fields.items %} - - + diff --git a/netbox/tenancy/views.py b/netbox/tenancy/views.py index b9ada8640..bbe901bde 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -7,17 +7,50 @@ from dcim.models import Cable, Device, Location, Rack, RackReservation, Site, Vi from ipam.models import Aggregate, ASN, IPAddress, IPRange, L2VPN, Prefix, VLAN, VRF from netbox.views import generic from utilities.utils import count_related -from utilities.views import register_model_view +from utilities.views import register_model_view, ViewTab from virtualization.models import VirtualMachine, Cluster from wireless.models import WirelessLAN, WirelessLink from . import filtersets, forms, tables from .models import * +class ObjectContactsView(generic.ObjectChildrenView): + child_model = ContactAssignment + table = tables.ContactAssignmentTable + filterset = filtersets.ContactAssignmentFilterSet + template_name = 'tenancy/object_contacts.html' + tab = ViewTab( + label=_('Contacts'), + badge=lambda obj: obj.contacts.count(), + permission='tenancy.view_contactassignment', + weight=5000 + ) + + def get_children(self, request, parent): + return ContactAssignment.objects.restrict(request.user, 'view').filter( + content_type=ContentType.objects.get_for_model(parent), + object_id=parent.pk + ) + + def get_table(self, *args, **kwargs): + table = super().get_table(*args, **kwargs) + + # Hide object columns + table.columns.hide('content_type') + table.columns.hide('object') + + return table + + def get_extra_context(self, request, instance): + return { + 'base_template': f'{instance._meta.app_label}/{instance._meta.model_name}.html', + } + # # Tenant groups # + class TenantGroupListView(generic.ObjectListView): queryset = TenantGroup.objects.add_related_count( TenantGroup.objects.all(), @@ -165,6 +198,11 @@ class TenantBulkDeleteView(generic.BulkDeleteView): table = tables.TenantTable +@register_model_view(Tenant, 'contacts') +class TenantContactsView(ObjectContactsView): + queryset = Tenant.objects.all() + + # # Contact groups # @@ -342,11 +380,11 @@ class ContactBulkDeleteView(generic.BulkDeleteView): filterset = filtersets.ContactFilterSet table = tables.ContactTable - # # Contact assignments # + class ContactAssignmentListView(generic.ObjectListView): queryset = ContactAssignment.objects.all() filterset = filtersets.ContactAssignmentFilterSet diff --git a/netbox/users/signals.py b/netbox/users/signals.py index 8915af1dc..98036d5d1 100644 --- a/netbox/users/signals.py +++ b/netbox/users/signals.py @@ -1,10 +1,18 @@ import logging from django.dispatch import receiver from django.contrib.auth.signals import user_login_failed +from utilities.request import get_client_ip @receiver(user_login_failed) def log_user_login_failed(sender, credentials, request, **kwargs): logger = logging.getLogger('netbox.auth.login') username = credentials.get("username") - logger.info(f"Failed login attempt for username: {username}") + if client_ip := get_client_ip(request): + logger.info(f"Failed login attempt for username: {username} from {client_ip}") + else: + logger.warning( + "Client IP address could not be determined for validation. Check that the HTTP server is properly " + "configured to pass the required header(s)." + ) + logger.info(f"Failed login attempt for username: {username}") diff --git a/netbox/utilities/forms/mixins.py b/netbox/utilities/forms/mixins.py index dc9c3eb80..7cdb9731e 100644 --- a/netbox/utilities/forms/mixins.py +++ b/netbox/utilities/forms/mixins.py @@ -32,11 +32,11 @@ class BootstrapMixin: elif isinstance(field.widget, forms.CheckboxInput): field.widget.attrs['class'] = f'{css} form-check-input' - elif isinstance(field.widget, forms.SelectMultiple): - if 'size' not in field.widget.attrs: - field.widget.attrs['class'] = f'{css} netbox-static-select' + elif isinstance(field.widget, forms.SelectMultiple) and 'size' in field.widget.attrs: + # Use native Bootstrap class for multi-line
- {{ field }} + {{ field }} + {% if field.description %} + + {% endif %} {% customfield_value field value %} diff --git a/netbox/templates/inc/panels/image_attachments.html b/netbox/templates/inc/panels/image_attachments.html index 9706a7ffe..a09fe78d5 100644 --- a/netbox/templates/inc/panels/image_attachments.html +++ b/netbox/templates/inc/panels/image_attachments.html @@ -1,47 +1,8 @@ {% load helpers %}
-
- Images -
-
- {% with images=object.images.all %} - {% if images.exists %} - - - - - - - - {% for attachment in images %} - - - - - - - {% endfor %} -
NameSizeCreated
- - {{ attachment }} - {{ attachment.size|filesizeformat }}{{ attachment.created|annotated_date }} - {% if perms.extras.change_imageattachment %} - - - - {% endif %} - {% if perms.extras.delete_imageattachment %} - - - - {% endif %} -
- {% else %} -
None
- {% endif %} - {% endwith %} -
+
Images
+ {% htmx_table 'extras:imageattachment_list' content_type_id=object|content_type_id object_id=object.pk %} {% if perms.extras.add_imageattachment %} diff --git a/netbox/templates/virtualization/virtualmachine.html b/netbox/templates/virtualization/virtualmachine.html index 5098a2f8f..51fd8aa80 100644 --- a/netbox/templates/virtualization/virtualmachine.html +++ b/netbox/templates/virtualization/virtualmachine.html @@ -158,7 +158,6 @@
{% endif %} - {% include 'inc/panels/contacts.html' %} {% plugin_right_page object %} diff --git a/netbox/templates/virtualization/vminterface.html b/netbox/templates/virtualization/vminterface.html index a7d4d92ba..82071bdaa 100644 --- a/netbox/templates/virtualization/vminterface.html +++ b/netbox/templates/virtualization/vminterface.html @@ -59,7 +59,7 @@
MAC Address{{ object.mac_address|placeholder }}{{ object.mac_address|placeholder }}
802.1Q Mode