diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index be2aacff5..b3dd583ca 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.1 + placeholder: v3.5.4 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 fcb3516b4..bd93001e7 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.1 + placeholder: v3.5.4 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..2d8055049 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -8,7 +8,7 @@ boto3 # The Python web framework on which NetBox is built # https://docs.djangoproject.com/en/stable/releases/ -Django<4.2 +Django<5.0 # Django middleware which permits cross-domain API requests # https://github.com/adamchainz/django-cors-headers/blob/main/CHANGELOG.rst @@ -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 @@ -120,8 +121,8 @@ netaddr Pillow # PostgreSQL database adapter for Python -# https://www.psycopg.org/docs/news.html -psycopg2-binary +# https://github.com/psycopg/psycopg/blob/master/docs/news.rst +psycopg[binary,pool] # YAML rendering library # https://github.com/yaml/pyyaml/blob/master/CHANGES diff --git a/docs/configuration/miscellaneous.md b/docs/configuration/miscellaneous.md index e3728acab..fd410a9d4 100644 --- a/docs/configuration/miscellaneous.md +++ b/docs/configuration/miscellaneous.md @@ -204,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/required-parameters.md b/docs/configuration/required-parameters.md index 1eba265bf..012d85762 100644 --- a/docs/configuration/required-parameters.md +++ b/docs/configuration/required-parameters.md @@ -25,7 +25,7 @@ ALLOWED_HOSTS = ['*'] ## DATABASE -NetBox requires access to a PostgreSQL 11 or later database service to store data. This service can run locally on the NetBox server or on a remote system. The following parameters must be defined within the `DATABASE` dictionary: +NetBox requires access to a PostgreSQL 12 or later database service to store data. This service can run locally on the NetBox server or on a remote system. The following parameters must be defined within the `DATABASE` dictionary: * `NAME` - Database name * `USER` - PostgreSQL username diff --git a/docs/installation/1-postgresql.md b/docs/installation/1-postgresql.md index 1fccd0270..bc1bbf22c 100644 --- a/docs/installation/1-postgresql.md +++ b/docs/installation/1-postgresql.md @@ -2,8 +2,8 @@ This section entails the installation and configuration of a local PostgreSQL database. If you already have a PostgreSQL database service in place, skip to [the next section](2-redis.md). -!!! warning "PostgreSQL 11 or later required" - NetBox requires PostgreSQL 11 or later. Please note that MySQL and other relational databases are **not** supported. +!!! warning "PostgreSQL 12 or later required" + NetBox requires PostgreSQL 12 or later. Please note that MySQL and other relational databases are **not** supported. ## Installation @@ -35,7 +35,7 @@ This section entails the installation and configuration of a local PostgreSQL da sudo systemctl enable postgresql ``` -Before continuing, verify that you have installed PostgreSQL 11 or later: +Before continuing, verify that you have installed PostgreSQL 12 or later: ```no-highlight psql -V diff --git a/docs/installation/index.md b/docs/installation/index.md index 375c0a2b5..da50fa5fa 100644 --- a/docs/installation/index.md +++ b/docs/installation/index.md @@ -18,7 +18,7 @@ The following sections detail how to set up a new instance of NetBox: | Dependency | Minimum Version | |------------|-----------------| | Python | 3.8 | -| PostgreSQL | 11 | +| PostgreSQL | 12 | | Redis | 4.0 | Below is a simplified overview of the NetBox application stack for reference: diff --git a/docs/installation/upgrading.md b/docs/installation/upgrading.md index 27401c3cf..a81d8c954 100644 --- a/docs/installation/upgrading.md +++ b/docs/installation/upgrading.md @@ -15,12 +15,12 @@ Prior to upgrading your NetBox instance, be sure to carefully review all [releas ## 2. Update Dependencies to Required Versions -NetBox v3.0 and later require the following: +NetBox requires the following dependencies: | Dependency | Minimum Version | |------------|-----------------| | Python | 3.8 | -| PostgreSQL | 11 | +| PostgreSQL | 12 | | Redis | 4.0 | ## 3. Install the Latest Release 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/introduction.md b/docs/introduction.md index 640147395..8f62d842a 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -75,5 +75,5 @@ NetBox is built on the [Django](https://djangoproject.com/) Python framework and | HTTP service | nginx or Apache | | WSGI service | gunicorn or uWSGI | | Application | Django/Python | -| Database | PostgreSQL 11+ | +| Database | PostgreSQL 12+ | | Task queuing | Redis/django-rq | diff --git a/docs/models/dcim/device.md b/docs/models/dcim/device.md index 8f97b920b..2216e351c 100644 --- a/docs/models/dcim/device.md +++ b/docs/models/dcim/device.md @@ -61,6 +61,10 @@ If installed in a rack, this field indicates the base rack unit in which the dev !!! tip Devices with a height of more than one rack unit should be set to the lowest-numbered rack unit that they occupy. +### Latitude & Longitude + +GPS coordinates of the device for geolocation. + ### Status The device's operational status. 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/models/extras/tag.md b/docs/models/extras/tag.md index 97ebd9d72..684be582e 100644 --- a/docs/models/extras/tag.md +++ b/docs/models/extras/tag.md @@ -15,3 +15,11 @@ A unique URL-friendly identifier. (This value will be used for filtering.) This ### Color The color to use when displaying the tag in the NetBox UI. + +### Object Types + +!!! info "This feature was introduced in NetBox v3.6." + +The assignment of a tag may be limited to a prescribed set of objects. For example, it may be desirable to limit the application of a specific tag to only devices and virtual machines. + +If no object types are specified, the tag will be assignable to any type of object. diff --git a/docs/release-notes/version-3.5.md b/docs/release-notes/version-3.5.md index 39e159268..7ad333e47 100644 --- a/docs/release-notes/version-3.5.md +++ b/docs/release-notes/version-3.5.md @@ -1,10 +1,70 @@ # NetBox v3.5 -## v3.5.2 (FUTURE) +## v3.5.5 (FUTURE) + +--- + +## v3.5.4 (2023-06-20) + +### Enhancements + +* [#12828](https://github.com/netbox-community/netbox/issues/12828) - Define colors for staged change action choices +* [#12847](https://github.com/netbox-community/netbox/issues/12847) - Include "add" button on all device & virtual machine component list views +* [#12862](https://github.com/netbox-community/netbox/issues/12862) - Add menu navigation button to add wireless links directly +* [#12865](https://github.com/netbox-community/netbox/issues/12865) - Add "add" buttons for reports & scripts to navigation menu + +### Bug Fixes + +* [#12474](https://github.com/netbox-community/netbox/issues/12474) - Update cable terminations when assigning a location to a new site +* [#12622](https://github.com/netbox-community/netbox/issues/12622) - Permit the assignment of non-site VLANs to prefixes assigned to a site +* [#12682](https://github.com/netbox-community/netbox/issues/12682) - Correct OpenAPI schema for connected device API endpoint +* [#12687](https://github.com/netbox-community/netbox/issues/12687) - Allow the assignment of all /31 IP addresses to interfaces +* [#12818](https://github.com/netbox-community/netbox/issues/12818) - Fix permissions evaluation when queuing a data sync job +* [#12822](https://github.com/netbox-community/netbox/issues/12822) - Fix encoding of whitespace in custom link URLs +* [#12838](https://github.com/netbox-community/netbox/issues/12838) - Correct rounding of rack power utilization values +* [#12845](https://github.com/netbox-community/netbox/issues/12845) - Fix pagination of objects for related IP addresses table +* [#12850](https://github.com/netbox-community/netbox/issues/12850) - Fix table configuration modal for the contact assignments list +* [#12885](https://github.com/netbox-community/netbox/issues/12885) - Permit mounting of devices in rack unit 100 +* [#12914](https://github.com/netbox-community/netbox/issues/12914) - Clear stored ordering from user config when cleared by request + +--- + +## 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 @@ -14,14 +74,23 @@ * [#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 --- diff --git a/docs/release-notes/version-3.6.md b/docs/release-notes/version-3.6.md index c4e6847d3..dc5280670 100644 --- a/docs/release-notes/version-3.6.md +++ b/docs/release-notes/version-3.6.md @@ -4,9 +4,19 @@ ### Breaking Changes +* PostgreSQL 11 is no longer supported (due to adopting Django 4.2). NetBox v3.6 requires PostgreSQL 12 or later. * The `napalm_driver` and `napalm_args` fields (which were deprecated in v3.5) have been removed from the platform model. +### Enhancements + +* [#11305](https://github.com/netbox-community/netbox/issues/11305) - Add GPS coordinate fields to the device model +* [#12175](https://github.com/netbox-community/netbox/issues/12175) - Permit racks to start numbering at values greater than one + ### Other Changes +* [#9077](https://github.com/netbox-community/netbox/issues/9077) - Prevent the errant execution of dangerous instance methods in Django templates * [#11766](https://github.com/netbox-community/netbox/issues/11766) - Remove obsolete custom `ChoiceField` and `MultipleChoiceField` classes +* [#12180](https://github.com/netbox-community/netbox/issues/12180) - All API endpoints for available objects (e.g. IP addresses) now inherit from a common parent view +* [#12794](https://github.com/netbox-community/netbox/issues/12794) - Avoid direct imports of Django's stock user model * [#12320](https://github.com/netbox-community/netbox/issues/12320) - Remove obsolete fields `napalm_driver` and `napalm_args` from Platform +* [#12964](https://github.com/netbox-community/netbox/issues/12964) - Drop support for PostgreSQL diff --git a/netbox/core/api/views.py b/netbox/core/api/views.py index fc4ef2927..7bf2f87a6 100644 --- a/netbox/core/api/views.py +++ b/netbox/core/api/views.py @@ -33,7 +33,7 @@ class DataSourceViewSet(NetBoxModelViewSet): """ Enqueue a job to synchronize the DataSource. """ - if not request.user.has_perm('extras.sync_datasource'): + if not request.user.has_perm('core.sync_datasource'): raise PermissionDenied("Syncing data sources requires the core.sync_datasource permission.") datasource = get_object_or_404(DataSource, pk=pk) diff --git a/netbox/core/forms/filtersets.py b/netbox/core/forms/filtersets.py index 7c3f2ab09..d8624f6b6 100644 --- a/netbox/core/forms/filtersets.py +++ b/netbox/core/forms/filtersets.py @@ -1,5 +1,5 @@ from django import forms -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.utils.translation import gettext as _ @@ -105,7 +105,7 @@ class JobFilterForm(SavedFiltersMixin, FilterForm): widget=DateTimePicker() ) user = DynamicModelMultipleChoiceField( - queryset=User.objects.all(), + queryset=get_user_model().objects.all(), required=False, label=_('User'), widget=APISelectMultiple( diff --git a/netbox/core/management/commands/nbshell.py b/netbox/core/management/commands/nbshell.py index 04a67eb49..674a878c7 100644 --- a/netbox/core/management/commands/nbshell.py +++ b/netbox/core/management/commands/nbshell.py @@ -5,7 +5,7 @@ import sys from django import get_version from django.apps import apps from django.conf import settings -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.core.management.base import BaseCommand @@ -60,7 +60,7 @@ class Command(BaseCommand): # Additional objects to include namespace['ContentType'] = ContentType - namespace['User'] = User + namespace['User'] = get_user_model() # Load convenience commands namespace.update({ diff --git a/netbox/core/models/data.py b/netbox/core/models/data.py index a8ac4e8f1..a2a20f858 100644 --- a/netbox/core/models/data.py +++ b/netbox/core/models/data.py @@ -200,6 +200,7 @@ class DataSource(JobsMixin, PrimaryModel): # Emit the post_sync signal post_sync.send(sender=self.__class__, instance=self) + sync.alters_data = True def _walk(self, root): """ @@ -289,8 +290,10 @@ class DataFile(models.Model): @property def data_as_string(self): + if not self.data: + return None try: - return self.data.tobytes().decode('utf-8') + return bytes(self.data, 'utf-8') except UnicodeDecodeError: return None diff --git a/netbox/core/models/jobs.py b/netbox/core/models/jobs.py index 1d0eecd21..9be06bd6d 100644 --- a/netbox/core/models/jobs.py +++ b/netbox/core/models/jobs.py @@ -1,7 +1,7 @@ import uuid import django_rq -from django.contrib.auth.models import User +from django.conf import settings from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.core.validators import MinValueValidator @@ -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', @@ -69,7 +69,7 @@ class Job(models.Model): blank=True ) user = models.ForeignKey( - to=User, + to=settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, related_name='+', blank=True, @@ -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/serializers.py b/netbox/dcim/api/serializers.py index 894a3f4f9..3a3065acc 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -673,9 +673,10 @@ class DeviceSerializer(NetBoxModelSerializer): model = Device fields = [ 'id', 'url', 'display', 'name', 'device_type', 'device_role', 'tenant', 'platform', 'serial', 'asset_tag', - 'site', 'location', 'rack', 'position', 'face', 'parent_device', 'status', 'airflow', 'primary_ip', - 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'description', - 'comments', 'config_template', 'local_context_data', 'tags', 'custom_fields', 'created', 'last_updated', + 'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent_device', 'status', 'airflow', + 'primary_ip', 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', + 'description', 'comments', 'config_template', 'local_context_data', 'tags', 'custom_fields', 'created', + 'last_updated', ] @extend_schema_field(NestedDeviceSerializer) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 0d9fcdc5f..e8a2eabbf 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 @@ -640,7 +646,10 @@ class ConnectedDeviceViewSet(ViewSet): def get_view_name(self): return "Connected Device Locator" - @extend_schema(responses={200: OpenApiTypes.OBJECT}) + @extend_schema( + parameters=[_device_param, _interface_param], + responses={200: serializers.DeviceSerializer} + ) def list(self, request): peer_device_name = request.query_params.get(self._device_param.name) diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index d32f5aaee..cc388b750 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -812,8 +812,11 @@ class InterfaceTypeChoices(ChoiceSet): 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' @@ -957,8 +960,11 @@ class InterfaceTypeChoices(ChoiceSet): (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)'), ) @@ -1223,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' @@ -1269,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/constants.py b/netbox/dcim/constants.py index 80d7558c9..303fc2344 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -11,11 +11,14 @@ DEVICETYPE_IMAGE_FORMATS = 'image/bmp,image/gif,image/jpeg,image/png,image/tiff, # RACK_U_HEIGHT_DEFAULT = 42 +RACK_U_HEIGHT_MAX = 100 RACK_ELEVATION_BORDER_WIDTH = 2 RACK_ELEVATION_DEFAULT_LEGEND_WIDTH = 30 RACK_ELEVATION_DEFAULT_MARGIN_WIDTH = 15 +RACK_STARTING_UNIT_DEFAULT = 1 + # # RearPorts diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 1f142d97f..e53ea8079 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -1,5 +1,5 @@ import django_filters -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.utils.translation import gettext as _ from extras.filtersets import LocalConfigContextFilterSet @@ -395,12 +395,12 @@ class RackReservationFilterSet(NetBoxModelFilterSet, TenancyFilterSet): label=_('Location (slug)'), ) user_id = django_filters.ModelMultipleChoiceFilter( - queryset=User.objects.all(), + queryset=get_user_model().objects.all(), label=_('User (ID)'), ) user = django_filters.ModelMultipleChoiceFilter( field_name='user__username', - queryset=User.objects.all(), + queryset=get_user_model().objects.all(), to_field_name='username', label=_('User (name)'), ) @@ -999,7 +999,7 @@ class DeviceFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilter class Meta: model = Device - fields = ['id', 'asset_tag', 'face', 'position', 'airflow', 'vc_position', 'vc_priority'] + fields = ['id', 'asset_tag', 'face', 'position', 'latitude', 'longitude', 'airflow', 'vc_position', 'vc_priority'] def search(self, queryset, name, value): if not value.strip(): @@ -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 f64a9768a..309370bfd 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -1,5 +1,6 @@ from django import forms -from django.contrib.auth.models import User +from django.conf import settings +from django.contrib.auth import get_user_model from django.utils.translation import gettext as _ from timezone_field import TimeZoneFormField @@ -321,7 +322,7 @@ class RackBulkEditForm(NetBoxModelBulkEditForm): class RackReservationBulkEditForm(NetBoxModelBulkEditForm): user = forms.ModelChoiceField( - queryset=User.objects.order_by( + queryset=get_user_model().objects.order_by( 'username' ), required=False @@ -1288,8 +1289,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 c8f13e213..e3e97ab73 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -478,8 +478,9 @@ class DeviceImportForm(BaseDeviceImportForm): class Meta(BaseDeviceImportForm.Meta): fields = [ 'name', 'device_role', 'tenant', 'manufacturer', 'device_type', 'platform', 'serial', 'asset_tag', 'status', - 'site', 'location', 'rack', 'position', 'face', 'parent', 'device_bay', 'airflow', 'virtual_chassis', - 'vc_position', 'vc_priority', 'cluster', 'description', 'config_template', 'comments', 'tags', + 'site', 'location', 'rack', 'position', 'face', 'latitude', 'longitude', 'parent', 'device_bay', 'airflow', + 'virtual_chassis', 'vc_position', 'vc_priority', 'cluster', 'description', 'config_template', 'comments', + 'tags', ] def __init__(self, data=None, *args, **kwargs): diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index d31bba030..0a4a22a70 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -1,5 +1,5 @@ from django import forms -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.utils.translation import gettext as _ from dcim.choices import * @@ -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') ) @@ -364,7 +376,7 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): label=_('Rack') ) user_id = DynamicModelMultipleChoiceField( - queryset=User.objects.all(), + queryset=get_user_model().objects.all(), required=False, label=_('User'), widget=APISelectMultiple( @@ -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 8379fd085..04f976d94 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -1,5 +1,5 @@ from django import forms -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.utils.translation import gettext as _ from timezone_field import TimeZoneFormField @@ -221,8 +221,8 @@ class RackForm(TenancyForm, NetBoxModelForm): model = Rack fields = [ 'site', 'location', 'name', 'facility_id', 'tenant_group', 'tenant', 'status', 'role', 'serial', - 'asset_tag', 'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', - 'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'description', 'comments', 'tags', + 'asset_tag', 'type', 'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_depth', + 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'description', 'comments', 'tags', ] @@ -236,7 +236,7 @@ class RackReservationForm(TenancyForm, NetBoxModelForm): help_text=_("Comma-separated list of numeric unit IDs. A range may be specified using a hyphen.") ) user = forms.ModelChoiceField( - queryset=User.objects.order_by( + queryset=get_user_model().objects.order_by( 'username' ) ) @@ -449,9 +449,9 @@ class DeviceForm(TenancyForm, NetBoxModelForm): model = Device fields = [ 'name', 'device_role', 'device_type', 'serial', 'asset_tag', 'site', 'rack', 'location', 'position', 'face', - 'status', 'airflow', 'platform', 'primary_ip4', 'primary_ip6', 'cluster', 'tenant_group', 'tenant', - 'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'config_template', 'comments', 'tags', - 'local_context_data' + 'latitude', 'longitude', 'status', 'airflow', 'platform', 'primary_ip4', 'primary_ip6', 'cluster', + 'tenant_group', 'tenant', 'virtual_chassis', 'vc_position', 'vc_priority', 'description', 'config_template', + 'comments', 'tags', 'local_context_data' ] def __init__(self, *args, **kwargs): diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index d4c9e6ec3..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 diff --git a/netbox/dcim/migrations/0154_half_height_rack_units.py b/netbox/dcim/migrations/0154_half_height_rack_units.py index dd21fddcf..f212aa21a 100644 --- a/netbox/dcim/migrations/0154_half_height_rack_units.py +++ b/netbox/dcim/migrations/0154_half_height_rack_units.py @@ -18,6 +18,6 @@ class Migration(migrations.Migration): migrations.AlterField( model_name='device', name='position', - field=models.DecimalField(blank=True, decimal_places=1, max_digits=4, null=True, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(99.5)]), + field=models.DecimalField(blank=True, decimal_places=1, max_digits=4, null=True, validators=[django.core.validators.MinValueValidator(1), django.core.validators.MaxValueValidator(100.5)]), ), ] diff --git a/netbox/dcim/migrations/0174_device_latitude_device_longitude.py b/netbox/dcim/migrations/0174_device_latitude_device_longitude.py new file mode 100644 index 000000000..f9f72f9f8 --- /dev/null +++ b/netbox/dcim/migrations/0174_device_latitude_device_longitude.py @@ -0,0 +1,22 @@ +# Generated by Django 4.1.9 on 2023-05-31 22:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('dcim', '0173_remove_napalm_fields'), + ] + + operations = [ + migrations.AddField( + model_name='device', + name='latitude', + field=models.DecimalField(blank=True, decimal_places=6, max_digits=8, null=True), + ), + migrations.AddField( + model_name='device', + name='longitude', + field=models.DecimalField(blank=True, decimal_places=6, max_digits=9, null=True), + ), + ] diff --git a/netbox/dcim/migrations/0174_rack_starting_unit.py b/netbox/dcim/migrations/0174_rack_starting_unit.py new file mode 100644 index 000000000..e32738660 --- /dev/null +++ b/netbox/dcim/migrations/0174_rack_starting_unit.py @@ -0,0 +1,17 @@ +# Generated by Django 4.1.9 on 2023-05-31 15:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('dcim', '0174_device_latitude_device_longitude'), + ] + + operations = [ + migrations.AddField( + model_name='rack', + name='starting_unit', + field=models.PositiveSmallIntegerField(default=1), + ), + ] diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index af69c440e..b2786719c 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -359,6 +359,7 @@ class CableTermination(ChangeLoggedModel): # Circuit terminations elif getattr(self.termination, 'site', None): self._site = self.termination.site + cache_related_objects.alters_data = True def to_objectchange(self, action): objectchange = super().to_objectchange(action) @@ -637,6 +638,7 @@ class CablePath(models.Model): self.save() else: self.delete() + retrace.alters_data = True def _get_path(self): """ diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index 6a89655b2..0355d7028 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -213,6 +213,7 @@ class ConsoleServerPortTemplate(ModularComponentTemplateModel): type=self.type, **kwargs ) + instantiate.do_not_call_in_templates = True def to_yaml(self): return { @@ -256,6 +257,7 @@ class PowerPortTemplate(ModularComponentTemplateModel): allocated_draw=self.allocated_draw, **kwargs ) + instantiate.do_not_call_in_templates = True def clean(self): super().clean() @@ -330,6 +332,7 @@ class PowerOutletTemplate(ModularComponentTemplateModel): feed_leg=self.feed_leg, **kwargs ) + instantiate.do_not_call_in_templates = True def to_yaml(self): return { @@ -413,6 +416,7 @@ class InterfaceTemplate(ModularComponentTemplateModel): poe_type=self.poe_type, **kwargs ) + instantiate.do_not_call_in_templates = True def to_yaml(self): return { @@ -507,6 +511,7 @@ class FrontPortTemplate(ModularComponentTemplateModel): rear_port_position=self.rear_port_position, **kwargs ) + instantiate.do_not_call_in_templates = True def to_yaml(self): return { @@ -550,6 +555,7 @@ class RearPortTemplate(ModularComponentTemplateModel): positions=self.positions, **kwargs ) + instantiate.do_not_call_in_templates = True def to_yaml(self): return { @@ -581,6 +587,7 @@ class ModuleBayTemplate(ComponentTemplateModel): label=self.label, position=self.position ) + instantiate.do_not_call_in_templates = True def to_yaml(self): return { @@ -603,6 +610,7 @@ class DeviceBayTemplate(ComponentTemplateModel): name=self.name, label=self.label ) + instantiate.do_not_call_in_templates = True def clean(self): if self.device_type and self.device_type.subdevice_role != SubdeviceRoleChoices.ROLE_PARENT: @@ -696,3 +704,4 @@ class InventoryItemTemplate(MPTTModel, ComponentTemplateModel): part_id=self.part_id, **kwargs ) + instantiate.do_not_call_in_templates = True diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index b47989344..4e76c139e 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -555,7 +555,7 @@ class Device(PrimaryModel, ConfigContextModel): decimal_places=1, blank=True, null=True, - validators=[MinValueValidator(1), MaxValueValidator(99.5)], + validators=[MinValueValidator(1), MaxValueValidator(RACK_U_HEIGHT_MAX + 0.5)], verbose_name='Position (U)', help_text=_('The lowest-numbered unit occupied by the device') ) @@ -624,6 +624,20 @@ class Device(PrimaryModel, ConfigContextModel): blank=True, null=True ) + latitude = models.DecimalField( + max_digits=8, + decimal_places=6, + blank=True, + null=True, + help_text=_("GPS coordinate in decimal format (xx.yyyyyy)") + ) + longitude = models.DecimalField( + max_digits=9, + decimal_places=6, + blank=True, + null=True, + help_text=_("GPS coordinate in decimal format (xx.yyyyyy)") + ) _console_port_count = CounterCacheField() _console_server_port_count = CounterCacheField() diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index e5412a3ab..6d3c15eee 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -1,7 +1,7 @@ import decimal from functools import cached_property -from django.contrib.auth.models import User +from django.conf import settings from django.contrib.contenttypes.fields import GenericRelation from django.contrib.postgres.fields import ArrayField from django.core.exceptions import ValidationError @@ -126,9 +126,14 @@ class Rack(PrimaryModel, WeightMixin): u_height = models.PositiveSmallIntegerField( default=RACK_U_HEIGHT_DEFAULT, verbose_name='Height (U)', - validators=[MinValueValidator(1), MaxValueValidator(100)], + validators=[MinValueValidator(1), MaxValueValidator(RACK_U_HEIGHT_MAX)], help_text=_('Height in rack units') ) + starting_unit = models.PositiveSmallIntegerField( + default=RACK_STARTING_UNIT_DEFAULT, + verbose_name='Starting unit', + help_text=_('Starting unit for rack') + ) desc_units = models.BooleanField( default=False, verbose_name='Descending units', @@ -228,20 +233,24 @@ class Rack(PrimaryModel, WeightMixin): raise ValidationError("Must specify a unit when setting a maximum weight") if self.pk: - # Validate that Rack is tall enough to house the installed Devices - top_device = Device.objects.filter( - rack=self - ).exclude( - position__isnull=True - ).order_by('-position').first() - if top_device: - min_height = top_device.position + top_device.device_type.u_height - 1 + mounted_devices = Device.objects.filter(rack=self).exclude(position__isnull=True).order_by('position') + + # Validate that Rack is tall enough to house the highest mounted Device + if top_device := mounted_devices.last(): + min_height = top_device.position + top_device.device_type.u_height - self.starting_unit if self.u_height < min_height: raise ValidationError({ - 'u_height': "Rack must be at least {}U tall to house currently installed devices.".format( - min_height - ) + 'u_height': f"Rack must be at least {min_height}U tall to house currently installed devices." }) + + # Validate that the Rack's starting unit is less than or equal to the position of the lowest mounted Device + if last_device := mounted_devices.first(): + if self.starting_unit > last_device.position: + raise ValidationError({ + 'starting_unit': f"Rack unit numbering must begin at {last_device.position} or less to house " + f"currently installed devices." + }) + # Validate that Rack was assigned a Location of its same site, if applicable if self.location: if self.location.site != self.site: @@ -269,8 +278,8 @@ class Rack(PrimaryModel, WeightMixin): Return a list of unit numbers, top to bottom. """ if self.desc_units: - return drange(decimal.Decimal(1.0), self.u_height + 1, 0.5) - return drange(self.u_height + decimal.Decimal(0.5), 0.5, -0.5) + return drange(decimal.Decimal(self.starting_unit), self.u_height + self.starting_unit, 0.5) + return drange(self.u_height + decimal.Decimal(0.5) + self.starting_unit - 1, 0.5 + self.starting_unit - 1, -0.5) def get_status_color(self): return RackStatusChoices.colors.get(self.status) @@ -466,7 +475,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): @@ -505,7 +514,7 @@ class RackReservation(PrimaryModel): null=True ) user = models.ForeignKey( - to=User, + to=settings.AUTH_USER_MODEL, on_delete=models.PROTECT ) description = models.CharField( diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py index 7ef08d2cc..a51872719 100644 --- a/netbox/dcim/signals.py +++ b/netbox/dcim/signals.py @@ -27,6 +27,7 @@ def handle_location_site_change(instance, created, **kwargs): Rack.objects.filter(location__in=locations).update(site=instance.site) Device.objects.filter(location__in=locations).update(site=instance.site) PowerPanel.objects.filter(location__in=locations).update(site=instance.site) + CableTermination.objects.filter(_location__in=locations).update(_site=instance.site) @receiver(post_save, sender=Rack) diff --git a/netbox/dcim/svg/racks.py b/netbox/dcim/svg/racks.py index 9c317ea16..6333abcf1 100644 --- a/netbox/dcim/svg/racks.py +++ b/netbox/dcim/svg/racks.py @@ -150,9 +150,9 @@ class RackElevationSVG: x = self.legend_width + RACK_ELEVATION_BORDER_WIDTH y = RACK_ELEVATION_BORDER_WIDTH if self.rack.desc_units: - y += int((position - 1) * self.unit_height) + y += int((position - self.rack.starting_unit) * self.unit_height) else: - y += int((self.rack.u_height - position + 1) * self.unit_height) - int(height * self.unit_height) + y += int((self.rack.u_height - position + self.rack.starting_unit) * self.unit_height) - int(height * self.unit_height) return x, y @@ -237,6 +237,7 @@ class RackElevationSVG: start_y = ru * self.unit_height + RACK_ELEVATION_BORDER_WIDTH position_coordinates = (self.legend_width / 2, start_y + self.unit_height / 2 + RACK_ELEVATION_BORDER_WIDTH) unit = ru + 1 if self.rack.desc_units else self.rack.u_height - ru + unit = unit + self.rack.starting_unit - 1 self.drawing.add( Text(str(unit), position_coordinates, class_='unit') ) @@ -278,6 +279,7 @@ class RackElevationSVG: for ru in range(0, self.rack.u_height): unit = ru + 1 if self.rack.desc_units else self.rack.u_height - ru + unit = unit + self.rack.starting_unit - 1 y_offset = RACK_ELEVATION_BORDER_WIDTH + ru * self.unit_height text_coords = ( x_offset + self.unit_width / 2, diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index a0238a1fb..a5862da68 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -236,9 +236,9 @@ class DeviceTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): fields = ( 'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'device_role', 'manufacturer', 'device_type', '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', + 'device_bay_position', 'position', 'face', 'latitude', 'longitude', '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..ecaf32a06 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -1,4 +1,4 @@ -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.test import override_settings from django.urls import reverse from rest_framework import status @@ -14,6 +14,9 @@ from wireless.choices import WirelessChannelChoices from wireless.models import WirelessLAN +User = get_user_model() + + class AppTest(APITestCase): def test_root(self): @@ -1115,7 +1118,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 +1232,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 4b82e87bd..a1e684cb9 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -1,4 +1,4 @@ -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.test import TestCase from dcim.choices import * @@ -12,6 +12,26 @@ from virtualization.models import Cluster, ClusterType from wireless.choices import WirelessChannelChoices, WirelessRoleChoices +User = get_user_model() + + +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 @@ -1621,9 +1641,9 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests): Tenant.objects.bulk_create(tenants) devices = ( - Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], platform=platforms[0], tenant=tenants[0], serial='ABC', asset_tag='1001', site=sites[0], location=locations[0], rack=racks[0], position=1, face=DeviceFaceChoices.FACE_FRONT, status=DeviceStatusChoices.STATUS_ACTIVE, cluster=clusters[0], local_context_data={"foo": 123}), - Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], platform=platforms[1], tenant=tenants[1], serial='DEF', asset_tag='1002', site=sites[1], location=locations[1], rack=racks[1], position=2, face=DeviceFaceChoices.FACE_FRONT, status=DeviceStatusChoices.STATUS_STAGED, airflow=DeviceAirflowChoices.AIRFLOW_FRONT_TO_REAR, cluster=clusters[1]), - Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], platform=platforms[2], tenant=tenants[2], serial='GHI', asset_tag='1003', site=sites[2], location=locations[2], rack=racks[2], position=3, face=DeviceFaceChoices.FACE_REAR, status=DeviceStatusChoices.STATUS_FAILED, airflow=DeviceAirflowChoices.AIRFLOW_REAR_TO_FRONT, cluster=clusters[2]), + Device(name='Device 1', device_type=device_types[0], device_role=device_roles[0], platform=platforms[0], tenant=tenants[0], serial='ABC', asset_tag='1001', site=sites[0], location=locations[0], rack=racks[0], position=1, face=DeviceFaceChoices.FACE_FRONT, latitude=10, longitude=10, status=DeviceStatusChoices.STATUS_ACTIVE, cluster=clusters[0], local_context_data={"foo": 123}), + Device(name='Device 2', device_type=device_types[1], device_role=device_roles[1], platform=platforms[1], tenant=tenants[1], serial='DEF', asset_tag='1002', site=sites[1], location=locations[1], rack=racks[1], position=2, face=DeviceFaceChoices.FACE_FRONT, latitude=20, longitude=20, status=DeviceStatusChoices.STATUS_STAGED, airflow=DeviceAirflowChoices.AIRFLOW_FRONT_TO_REAR, cluster=clusters[1]), + Device(name='Device 3', device_type=device_types[2], device_role=device_roles[2], platform=platforms[2], tenant=tenants[2], serial='GHI', asset_tag='1003', site=sites[2], location=locations[2], rack=racks[2], position=3, face=DeviceFaceChoices.FACE_REAR, latitude=30, longitude=30, status=DeviceStatusChoices.STATUS_FAILED, airflow=DeviceAirflowChoices.AIRFLOW_REAR_TO_FRONT, cluster=clusters[2]), ) Device.objects.bulk_create(devices) @@ -1704,6 +1724,14 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'position': [1, 2]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_latitude(self): + params = {'latitude': [10, 20]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_longitude(self): + params = {'longitude': [10, 20]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_vc_position(self): params = {'vc_position': [1, 2]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -1994,7 +2022,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 @@ -2023,10 +2051,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]), @@ -2044,10 +2085,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) @@ -2161,7 +2202,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 @@ -2190,10 +2231,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]), @@ -2211,10 +2265,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) @@ -2328,7 +2382,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 @@ -2357,10 +2411,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]), @@ -2378,10 +2445,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) @@ -2503,7 +2570,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 @@ -2532,10 +2599,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]), @@ -2553,10 +2633,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) @@ -2674,7 +2754,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 @@ -2703,10 +2783,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]), @@ -2724,10 +2817,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) @@ -3097,7 +3190,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 @@ -3126,10 +3219,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]), @@ -3147,10 +3253,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) @@ -3273,7 +3379,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 @@ -3302,10 +3408,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]), @@ -3323,10 +3442,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) @@ -3443,7 +3562,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 @@ -3472,9 +3591,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]), @@ -3492,9 +3623,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) @@ -3560,7 +3691,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 @@ -3589,9 +3720,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]), @@ -3609,9 +3752,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) @@ -3690,8 +3833,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'), @@ -3732,9 +3886,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) @@ -3825,6 +3979,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 4bcb8df53..cca6b3f02 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -6,7 +6,7 @@ except ImportError: from backports.zoneinfo import ZoneInfo import yaml -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.test import override_settings from django.urls import reverse @@ -22,6 +22,9 @@ from utilities.testing import ViewTestCases, create_tags, create_test_device, po from wireless.models import WirelessLAN +User = get_user_model() + + class RegionTestCase(ViewTestCases.OrganizationalObjectViewTestCase): model = Region @@ -389,6 +392,7 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'outer_width': 500, 'outer_depth': 500, 'outer_unit': RackDimensionUnitChoices.UNIT_MILLIMETER, + 'starting_unit': 1, 'weight': 100, 'max_weight': 2000, 'weight_unit': WeightUnitChoices.UNIT_POUND, @@ -1696,6 +1700,8 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase): 'rack': racks[1].pk, 'position': 1, 'face': DeviceFaceChoices.FACE_FRONT, + 'latitude': Decimal('35.780000'), + 'longitude': Decimal('-78.642000'), 'status': DeviceStatusChoices.STATUS_PLANNED, 'primary_ip4': None, 'primary_ip6': None, diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 0def4f4a8..b52e0afa5 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -2193,7 +2193,6 @@ class ConsolePortListView(generic.ObjectListView): filterset = filtersets.ConsolePortFilterSet filterset_form = forms.ConsolePortFilterForm table = tables.ConsolePortTable - actions = ('import', 'export', 'bulk_edit', 'bulk_delete') @register_model_view(ConsolePort) @@ -2257,7 +2256,6 @@ class ConsoleServerPortListView(generic.ObjectListView): filterset = filtersets.ConsoleServerPortFilterSet filterset_form = forms.ConsoleServerPortFilterForm table = tables.ConsoleServerPortTable - actions = ('import', 'export', 'bulk_edit', 'bulk_delete') @register_model_view(ConsoleServerPort) @@ -2321,7 +2319,6 @@ class PowerPortListView(generic.ObjectListView): filterset = filtersets.PowerPortFilterSet filterset_form = forms.PowerPortFilterForm table = tables.PowerPortTable - actions = ('import', 'export', 'bulk_edit', 'bulk_delete') @register_model_view(PowerPort) @@ -2385,7 +2382,6 @@ class PowerOutletListView(generic.ObjectListView): filterset = filtersets.PowerOutletFilterSet filterset_form = forms.PowerOutletFilterForm table = tables.PowerOutletTable - actions = ('import', 'export', 'bulk_edit', 'bulk_delete') @register_model_view(PowerOutlet) @@ -2449,7 +2445,6 @@ class InterfaceListView(generic.ObjectListView): filterset = filtersets.InterfaceFilterSet filterset_form = forms.InterfaceFilterForm table = tables.InterfaceTable - actions = ('import', 'export', 'bulk_edit', 'bulk_delete') @register_model_view(Interface) @@ -2559,7 +2554,6 @@ class FrontPortListView(generic.ObjectListView): filterset = filtersets.FrontPortFilterSet filterset_form = forms.FrontPortFilterForm table = tables.FrontPortTable - actions = ('import', 'export', 'bulk_edit', 'bulk_delete') @register_model_view(FrontPort) @@ -2623,7 +2617,6 @@ class RearPortListView(generic.ObjectListView): filterset = filtersets.RearPortFilterSet filterset_form = forms.RearPortFilterForm table = tables.RearPortTable - actions = ('import', 'export', 'bulk_edit', 'bulk_delete') @register_model_view(RearPort) @@ -2687,7 +2680,6 @@ class ModuleBayListView(generic.ObjectListView): filterset = filtersets.ModuleBayFilterSet filterset_form = forms.ModuleBayFilterForm table = tables.ModuleBayTable - actions = ('import', 'export', 'bulk_edit', 'bulk_delete') @register_model_view(ModuleBay) @@ -2743,7 +2735,6 @@ class DeviceBayListView(generic.ObjectListView): filterset = filtersets.DeviceBayFilterSet filterset_form = forms.DeviceBayFilterForm table = tables.DeviceBayTable - actions = ('import', 'export', 'bulk_edit', 'bulk_delete') @register_model_view(DeviceBay) @@ -2868,7 +2859,6 @@ class InventoryItemListView(generic.ObjectListView): filterset = filtersets.InventoryItemFilterSet filterset_form = forms.InventoryItemFilterForm table = tables.InventoryItemTable - actions = ('import', 'export', 'bulk_edit', 'bulk_delete') @register_model_view(InventoryItem) diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index 6d1b14370..6e82ffc75 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -1,129 +1,2 @@ -from django.contrib import admin -from django.shortcuts import get_object_or_404, redirect -from django.template.response import TemplateResponse -from django.urls import path, reverse -from django.utils.html import format_html - -from netbox.config import get_config, PARAMS +# TODO: Removing this import triggers an import loop due to how form mixins are currently organized from .forms import ConfigRevisionForm -from .models import ConfigRevision - - -@admin.register(ConfigRevision) -class ConfigRevisionAdmin(admin.ModelAdmin): - fieldsets = [ - ('Rack Elevations', { - 'fields': ('RACK_ELEVATION_DEFAULT_UNIT_HEIGHT', 'RACK_ELEVATION_DEFAULT_UNIT_WIDTH'), - }), - ('Power', { - 'fields': ('POWERFEED_DEFAULT_VOLTAGE', 'POWERFEED_DEFAULT_AMPERAGE', 'POWERFEED_DEFAULT_MAX_UTILIZATION') - }), - ('IPAM', { - 'fields': ('ENFORCE_GLOBAL_UNIQUE', 'PREFER_IPV4'), - }), - ('Security', { - 'fields': ('ALLOWED_URL_SCHEMES',), - }), - ('Banners', { - 'fields': ('BANNER_LOGIN', 'BANNER_MAINTENANCE', 'BANNER_TOP', 'BANNER_BOTTOM'), - 'classes': ('monospace',), - }), - ('Pagination', { - 'fields': ('PAGINATE_COUNT', 'MAX_PAGE_SIZE'), - }), - ('Validation', { - 'fields': ('CUSTOM_VALIDATORS',), - 'classes': ('monospace',), - }), - ('User Preferences', { - 'fields': ('DEFAULT_USER_PREFERENCES',), - }), - ('Miscellaneous', { - 'fields': ('MAINTENANCE_MODE', 'GRAPHQL_ENABLED', 'CHANGELOG_RETENTION', 'JOB_RETENTION', 'MAPS_URL'), - }), - ('Config Revision', { - 'fields': ('comment',), - }) - ] - form = ConfigRevisionForm - list_display = ('id', 'is_active', 'created', 'comment', 'restore_link') - ordering = ('-id',) - readonly_fields = ('data',) - - def get_changeform_initial_data(self, request): - """ - Populate initial form data from the most recent ConfigRevision. - """ - latest_revision = ConfigRevision.objects.last() - initial = latest_revision.data if latest_revision else {} - initial.update(super().get_changeform_initial_data(request)) - - return initial - - # Permissions - - def has_add_permission(self, request): - # Only superusers may modify the configuration. - return request.user.is_superuser - - def has_change_permission(self, request, obj=None): - # ConfigRevisions cannot be modified once created. - return False - - def has_delete_permission(self, request, obj=None): - # Only inactive ConfigRevisions may be deleted (must be superuser). - return request.user.is_superuser and ( - obj is None or not obj.is_active() - ) - - # List display methods - - def restore_link(self, obj): - if obj.is_active(): - return '' - return format_html( - 'Restore', - url=reverse('admin:extras_configrevision_restore', args=(obj.pk,)) - ) - restore_link.short_description = "Actions" - - # URLs - - def get_urls(self): - urls = [ - path('/restore/', self.admin_site.admin_view(self.restore), name='extras_configrevision_restore'), - ] - - return urls + super().get_urls() - - # Views - - def restore(self, request, pk): - # Get the ConfigRevision being restored - candidate_config = get_object_or_404(ConfigRevision, pk=pk) - - if request.method == 'POST': - candidate_config.activate() - self.message_user(request, f"Restored configuration revision #{pk}") - - return redirect(reverse('admin:extras_configrevision_changelist')) - - # Get the current ConfigRevision - config_version = get_config().version - current_config = ConfigRevision.objects.filter(pk=config_version).first() - - params = [] - for param in PARAMS: - params.append(( - param.name, - current_config.data.get(param.name, None), - candidate_config.data.get(param.name, None) - )) - - context = self.admin_site.each_context(request) - context.update({ - 'object': candidate_config, - 'params': params, - }) - - return TemplateResponse(request, 'admin/extras/configrevision/restore.html', context) diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index cbe4ed56d..c71e840d5 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -1,4 +1,4 @@ -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist from rest_framework import serializers @@ -196,12 +196,18 @@ class SavedFilterSerializer(ValidatedModelSerializer): class TagSerializer(ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='extras-api:tag-detail') + object_types = ContentTypeField( + queryset=ContentType.objects.filter(FeatureQuery('custom_fields').get_query()), + many=True, + required=False + ) tagged_items = serializers.IntegerField(read_only=True) class Meta: model = Tag fields = [ - 'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tagged_items', 'created', 'last_updated', + 'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'object_types', 'tagged_items', 'created', + 'last_updated', ] @@ -256,7 +262,7 @@ class JournalEntrySerializer(NetBoxModelSerializer): assigned_object = serializers.SerializerMethodField(read_only=True) created_by = serializers.PrimaryKeyRelatedField( allow_null=True, - queryset=User.objects.all(), + queryset=get_user_model().objects.all(), required=False, default=serializers.CurrentUserDefault() ) diff --git a/netbox/extras/choices.py b/netbox/extras/choices.py index e10516c4c..63bdbf7db 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)'), ) @@ -208,7 +210,7 @@ class ChangeActionChoices(ChoiceSet): ACTION_DELETE = 'delete' CHOICES = ( - (ACTION_CREATE, 'Create'), - (ACTION_UPDATE, 'Update'), - (ACTION_DELETE, 'Delete'), + (ACTION_CREATE, 'Create', 'green'), + (ACTION_UPDATE, 'Update', 'blue'), + (ACTION_DELETE, 'Delete', 'red'), ) 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/filtersets.py b/netbox/extras/filtersets.py index 5253ae7b0..acb0aa359 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -1,5 +1,5 @@ import django_filters -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.db.models import Q from django.utils.translation import gettext as _ @@ -16,6 +16,7 @@ from .models import * __all__ = ( 'ConfigContextFilterSet', + 'ConfigRevisionFilterSet', 'ConfigTemplateFilterSet', 'ContentTypeFilterSet', 'CustomFieldFilterSet', @@ -159,12 +160,12 @@ class SavedFilterFilterSet(BaseFilterSet): ) content_types = ContentTypeFilter() user_id = django_filters.ModelMultipleChoiceFilter( - queryset=User.objects.all(), + queryset=get_user_model().objects.all(), label=_('User (ID)'), ) user = django_filters.ModelMultipleChoiceFilter( field_name='user__username', - queryset=User.objects.all(), + queryset=get_user_model().objects.all(), to_field_name='username', label=_('User (name)'), ) @@ -223,12 +224,12 @@ class JournalEntryFilterSet(NetBoxModelFilterSet): queryset=ContentType.objects.all() ) created_by_id = django_filters.ModelMultipleChoiceFilter( - queryset=User.objects.all(), + queryset=get_user_model().objects.all(), label=_('User (ID)'), ) created_by = django_filters.ModelMultipleChoiceFilter( field_name='created_by__username', - queryset=User.objects.all(), + queryset=get_user_model().objects.all(), to_field_name='username', label=_('User (name)'), ) @@ -257,10 +258,13 @@ class TagFilterSet(ChangeLoggedModelFilterSet): content_type_id = MultiValueNumberFilter( method='_content_type_id' ) + for_object_type_id = MultiValueNumberFilter( + method='_for_object_type' + ) class Meta: model = Tag - fields = ['id', 'name', 'slug', 'color', 'description'] + fields = ['id', 'name', 'slug', 'color', 'description', 'object_types'] def search(self, queryset, name, value): if not value.strip(): @@ -297,6 +301,11 @@ class TagFilterSet(ChangeLoggedModelFilterSet): return queryset.filter(extras_taggeditem_items__content_type__in=content_types).distinct() + def _for_object_type(self, queryset, name, values): + return queryset.filter( + Q(object_types__id__in=values) | Q(object_types__isnull=True) + ) + class ConfigContextFilterSet(ChangeLoggedModelFilterSet): q = django_filters.CharFilter( @@ -510,12 +519,12 @@ class ObjectChangeFilterSet(BaseFilterSet): queryset=ContentType.objects.all() ) user_id = django_filters.ModelMultipleChoiceFilter( - queryset=User.objects.all(), + queryset=get_user_model().objects.all(), label=_('User (ID)'), ) user = django_filters.ModelMultipleChoiceFilter( field_name='user__username', - queryset=User.objects.all(), + queryset=get_user_model().objects.all(), to_field_name='username', label=_('User name'), ) @@ -557,3 +566,27 @@ class ContentTypeFilterSet(django_filters.FilterSet): Q(app_label__icontains=value) | Q(model__icontains=value) ) + + +# +# ConfigRevisions +# + +class ConfigRevisionFilterSet(BaseFilterSet): + q = django_filters.CharFilter( + method='search', + label=_('Search'), + ) + + class Meta: + model = ConfigRevision + fields = [ + 'id', + ] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(comment__icontains=value) + ) diff --git a/netbox/extras/forms/__init__.py b/netbox/extras/forms/__init__.py index 0825c9ca7..e203bee46 100644 --- a/netbox/extras/forms/__init__.py +++ b/netbox/extras/forms/__init__.py @@ -4,5 +4,4 @@ from .bulk_edit import * from .bulk_import import * from .misc import * from .mixins import * -from .config import * from .scripts import * diff --git a/netbox/extras/forms/config.py b/netbox/extras/forms/config.py deleted file mode 100644 index 4a7dba614..000000000 --- a/netbox/extras/forms/config.py +++ /dev/null @@ -1,82 +0,0 @@ -from django import forms -from django.conf import settings - -from netbox.config import get_config, PARAMS - -__all__ = ( - 'ConfigRevisionForm', -) - - -EMPTY_VALUES = ('', None, [], ()) - - -class FormMetaclass(forms.models.ModelFormMetaclass): - - def __new__(mcs, name, bases, attrs): - - # Emulate a declared field for each supported configuration parameter - param_fields = {} - for param in PARAMS: - field_kwargs = { - 'required': False, - 'label': param.label, - 'help_text': param.description, - } - field_kwargs.update(**param.field_kwargs) - param_fields[param.name] = param.field(**field_kwargs) - attrs.update(param_fields) - - return super().__new__(mcs, name, bases, attrs) - - -class ConfigRevisionForm(forms.BaseModelForm, metaclass=FormMetaclass): - """ - Form for creating a new ConfigRevision. - """ - class Meta: - widgets = { - 'comment': forms.Textarea(), - } - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # Append current parameter values to form field help texts and check for static configurations - config = get_config() - for param in PARAMS: - value = getattr(config, param.name) - is_static = hasattr(settings, param.name) - if value: - help_text = self.fields[param.name].help_text - if help_text: - help_text += '
' # Line break - help_text += f'Current value: {value}' - if is_static: - help_text += ' (defined statically)' - elif value == param.default: - help_text += ' (default)' - self.fields[param.name].help_text = help_text - if is_static: - self.fields[param.name].disabled = True - - def save(self, commit=True): - instance = super().save(commit=False) - - # Populate JSON data on the instance - instance.data = self.render_json() - - if commit: - instance.save() - - return instance - - def render_json(self): - json = {} - - # Iterate through each field and populate non-empty values - for field_name in self.declared_fields: - if field_name in self.cleaned_data and self.cleaned_data[field_name] not in EMPTY_VALUES: - json[field_name] = self.cleaned_data[field_name] - - return json diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index fae15d041..56e9c8dfb 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -1,5 +1,5 @@ from django import forms -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.utils.translation import gettext as _ @@ -18,6 +18,7 @@ from .mixins import SavedFiltersMixin __all__ = ( 'ConfigContextFilterForm', + 'ConfigRevisionFilterForm', 'ConfigTemplateFilterForm', 'CustomFieldFilterForm', 'CustomLinkFilterForm', @@ -244,6 +245,11 @@ class TagFilterForm(SavedFiltersMixin, FilterForm): required=False, label=_('Tagged object type') ) + for_object_type_id = ContentTypeChoiceField( + queryset=ContentType.objects.filter(FeatureQuery('tags').get_query()), + required=False, + label=_('Allowed object type') + ) class ConfigContextFilterForm(SavedFiltersMixin, FilterForm): @@ -385,7 +391,7 @@ class JournalEntryFilterForm(NetBoxModelFilterSetForm): widget=DateTimePicker() ) created_by_id = DynamicModelMultipleChoiceField( - queryset=User.objects.all(), + queryset=get_user_model().objects.all(), required=False, label=_('User'), widget=APISelectMultiple( @@ -429,7 +435,7 @@ class ObjectChangeFilterForm(SavedFiltersMixin, FilterForm): required=False ) user_id = DynamicModelMultipleChoiceField( - queryset=User.objects.all(), + queryset=get_user_model().objects.all(), required=False, label=_('User'), widget=APISelectMultiple( @@ -444,3 +450,9 @@ class ObjectChangeFilterForm(SavedFiltersMixin, FilterForm): api_url='/api/extras/content-types/', ) ) + + +class ConfigRevisionFilterForm(SavedFiltersMixin, FilterForm): + fieldsets = ( + (None, ('q', 'filter_id')), + ) diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py index 2f617b682..f8aa982bc 100644 --- a/netbox/extras/forms/model_forms.py +++ b/netbox/extras/forms/model_forms.py @@ -1,6 +1,7 @@ import json from django import forms +from django.conf import settings from django.db.models import Q from django.contrib.contenttypes.models import ContentType from django.utils.translation import gettext as _ @@ -10,17 +11,20 @@ from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site from extras.choices import * from extras.models import * from extras.utils import FeatureQuery +from netbox.config import get_config, PARAMS from netbox.forms import NetBoxModelForm from tenancy.models import Tenant, TenantGroup -from utilities.forms import BootstrapMixin, add_blank_choice +from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, BootstrapMixin, add_blank_choice from utilities.forms.fields import ( CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, JSONField, SlugField, ) from virtualization.models import Cluster, ClusterGroup, ClusterType + __all__ = ( 'ConfigContextForm', + 'ConfigRevisionForm', 'ConfigTemplateForm', 'CustomFieldForm', 'CustomLinkForm', @@ -200,15 +204,20 @@ class WebhookForm(BootstrapMixin, forms.ModelForm): class TagForm(BootstrapMixin, forms.ModelForm): slug = SlugField() + object_types = ContentTypeMultipleChoiceField( + queryset=ContentType.objects.all(), + limit_choices_to=FeatureQuery('tags'), + required=False + ) fieldsets = ( - ('Tag', ('name', 'slug', 'color', 'description')), + ('Tag', ('name', 'slug', 'color', 'description', 'object_types')), ) class Meta: model = Tag fields = [ - 'name', 'slug', 'color', 'description' + 'name', 'slug', 'color', 'description', 'object_types', ] @@ -374,3 +383,99 @@ class JournalEntryForm(NetBoxModelForm): 'assigned_object_type': forms.HiddenInput, 'assigned_object_id': forms.HiddenInput, } + + +EMPTY_VALUES = ('', None, [], ()) + + +class ConfigFormMetaclass(forms.models.ModelFormMetaclass): + + def __new__(mcs, name, bases, attrs): + + # Emulate a declared field for each supported configuration parameter + param_fields = {} + for param in PARAMS: + field_kwargs = { + 'required': False, + 'label': param.label, + 'help_text': param.description, + } + field_kwargs.update(**param.field_kwargs) + param_fields[param.name] = param.field(**field_kwargs) + attrs.update(param_fields) + + return super().__new__(mcs, name, bases, attrs) + + +class ConfigRevisionForm(BootstrapMixin, forms.ModelForm, metaclass=ConfigFormMetaclass): + """ + Form for creating a new ConfigRevision. + """ + + fieldsets = ( + ('Rack Elevations', ('RACK_ELEVATION_DEFAULT_UNIT_HEIGHT', 'RACK_ELEVATION_DEFAULT_UNIT_WIDTH')), + ('Power', ('POWERFEED_DEFAULT_VOLTAGE', 'POWERFEED_DEFAULT_AMPERAGE', 'POWERFEED_DEFAULT_MAX_UTILIZATION')), + ('IPAM', ('ENFORCE_GLOBAL_UNIQUE', 'PREFER_IPV4')), + ('Security', ('ALLOWED_URL_SCHEMES',)), + ('Banners', ('BANNER_LOGIN', 'BANNER_MAINTENANCE', 'BANNER_TOP', 'BANNER_BOTTOM')), + ('Pagination', ('PAGINATE_COUNT', 'MAX_PAGE_SIZE')), + ('Validation', ('CUSTOM_VALIDATORS',)), + ('User Preferences', ('DEFAULT_USER_PREFERENCES',)), + ('Miscellaneous', ('MAINTENANCE_MODE', 'GRAPHQL_ENABLED', 'CHANGELOG_RETENTION', 'JOB_RETENTION', 'MAPS_URL')), + ('Config Revision', ('comment',)) + ) + + class Meta: + model = ConfigRevision + fields = '__all__' + widgets = { + 'BANNER_LOGIN': forms.Textarea(attrs={'class': 'font-monospace'}), + 'BANNER_MAINTENANCE': forms.Textarea(attrs={'class': 'font-monospace'}), + 'BANNER_TOP': forms.Textarea(attrs={'class': 'font-monospace'}), + 'BANNER_BOTTOM': forms.Textarea(attrs={'class': 'font-monospace'}), + 'CUSTOM_VALIDATORS': forms.Textarea(attrs={'class': 'font-monospace'}), + 'comment': forms.Textarea(), + } + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Append current parameter values to form field help texts and check for static configurations + config = get_config() + for param in PARAMS: + value = getattr(config, param.name) + is_static = hasattr(settings, param.name) + if value: + help_text = self.fields[param.name].help_text + if help_text: + help_text += '
' # Line break + help_text += f'Current value: {value}' + if is_static: + help_text += ' (defined statically)' + elif value == param.default: + help_text += ' (default)' + self.fields[param.name].help_text = help_text + self.fields[param.name].initial = value + if is_static: + self.fields[param.name].disabled = True + + def save(self, commit=True): + instance = super().save(commit=False) + + # Populate JSON data on the instance + instance.data = self.render_json() + + if commit: + instance.save() + + return instance + + def render_json(self): + json = {} + + # Iterate through each field and populate non-empty values + for field_name in self.declared_fields: + if field_name in self.cleaned_data and self.cleaned_data[field_name] not in EMPTY_VALUES: + json[field_name] = self.cleaned_data[field_name] + + return json 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 b42e9b47d..d9a9f41ae 100644 --- a/netbox/extras/management/commands/runscript.py +++ b/netbox/extras/management/commands/runscript.py @@ -4,7 +4,7 @@ import sys import traceback import uuid -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.core.management.base import BaseCommand, CommandError from django.db import transaction @@ -63,6 +63,8 @@ class Command(BaseCommand): logger.info(f"Script completed in {job.duration}") + User = get_user_model() + # Params script = options['script'] loglevel = options['loglevel'] diff --git a/netbox/extras/migrations/0093_configrevision_ordering.py b/netbox/extras/migrations/0093_configrevision_ordering.py new file mode 100644 index 000000000..a4e875e6d --- /dev/null +++ b/netbox/extras/migrations/0093_configrevision_ordering.py @@ -0,0 +1,17 @@ +# Generated by Django 4.1.9 on 2023-06-22 14:14 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0092_delete_jobresult'), + ] + + operations = [ + migrations.AlterModelOptions( + name='configrevision', + options={'ordering': ['-created']}, + ), + ] diff --git a/netbox/extras/migrations/0094_tag_object_types.py b/netbox/extras/migrations/0094_tag_object_types.py new file mode 100644 index 000000000..944ef64b2 --- /dev/null +++ b/netbox/extras/migrations/0094_tag_object_types.py @@ -0,0 +1,23 @@ +from django.db import migrations, models +import extras.utils + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('extras', '0093_configrevision_ordering'), + ] + + operations = [ + migrations.AddField( + model_name='tag', + name='object_types', + field=models.ManyToManyField(blank=True, limit_choices_to=extras.utils.FeatureQuery('tags'), related_name='+', to='contenttypes.contenttype'), + ), + migrations.RenameIndex( + model_name='taggeditem', + new_name='extras_tagg_content_717743_idx', + old_fields=('content_type', 'object_id'), + ), + ] diff --git a/netbox/extras/models/change_logging.py b/netbox/extras/models/change_logging.py index e2b118b84..54d72cdd8 100644 --- a/netbox/extras/models/change_logging.py +++ b/netbox/extras/models/change_logging.py @@ -1,4 +1,4 @@ -from django.contrib.auth.models import User +from django.conf import settings from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.db import models @@ -24,7 +24,7 @@ class ObjectChange(models.Model): db_index=True ) user = models.ForeignKey( - to=User, + to=settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, related_name='changes', blank=True, diff --git a/netbox/extras/models/configs.py b/netbox/extras/models/configs.py index 632323af0..ee9f7cfda 100644 --- a/netbox/extras/models/configs.py +++ b/netbox/extras/models/configs.py @@ -146,6 +146,7 @@ class ConfigContext(SyncedDataMixin, CloningMixin, ChangeLoggedModel): Synchronize context data from the designated DataFile (if any). """ self.data = self.data_file.get_data() + sync_data.alters_data = True class ConfigContextModel(models.Model): @@ -236,6 +237,7 @@ class ConfigTemplate(SyncedDataMixin, ExportTemplatesMixin, TagsMixin, ChangeLog Synchronize template content from the designated DataFile (if any). """ self.template_code = self.data_file.data_as_string + sync_data.alters_data = True def render(self, context=None): """ diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 16e4fb577..0cbc7a1de 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -3,7 +3,7 @@ import urllib.parse from django.conf import settings from django.contrib import admin -from django.contrib.auth.models import User +from django.conf import settings from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType from django.core.cache import cache @@ -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 @@ -285,7 +285,7 @@ class CustomLink(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): text = clean_html(text, allowed_schemes) # Sanitize link - link = urllib.parse.quote_plus(link, safe='/:?&=%+[]@#') + link = urllib.parse.quote(link, safe='/:?&=%+[]@#') # Verify link scheme is allowed result = urllib.parse.urlparse(link) @@ -362,6 +362,7 @@ class ExportTemplate(SyncedDataMixin, CloningMixin, ExportTemplatesMixin, Change Synchronize template content from the designated DataFile (if any). """ self.template_code = self.data_file.data_as_string + sync_data.alters_data = True def render(self, queryset): """ @@ -418,7 +419,7 @@ class SavedFilter(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel): blank=True ) user = models.ForeignKey( - to=User, + to=settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, blank=True, null=True @@ -559,7 +560,7 @@ class JournalEntry(CustomFieldsMixin, CustomLinksMixin, TagsMixin, ExportTemplat fk_field='assigned_object_id' ) created_by = models.ForeignKey( - to=User, + to=settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, blank=True, null=True @@ -611,6 +612,11 @@ class ConfigRevision(models.Model): verbose_name='Configuration data' ) + objects = RestrictedQuerySet.as_manager() + + class Meta: + ordering = ['-created'] + def __str__(self): return f'Config revision #{self.pk} ({self.created})' @@ -619,12 +625,16 @@ class ConfigRevision(models.Model): return self.data[item] return super().__getattribute__(item) + def get_absolute_url(self): + return reverse('extras:configrevision', args=[self.pk]) + def activate(self): """ Cache the configuration data. """ cache.set('config', self.data, None) cache.set('config_version', self.pk, None) + activate.alters_data = True @admin.display(boolean=True) def is_active(self): diff --git a/netbox/extras/models/staging.py b/netbox/extras/models/staging.py index b46d6a7bc..850015be7 100644 --- a/netbox/extras/models/staging.py +++ b/netbox/extras/models/staging.py @@ -112,3 +112,7 @@ class StagedChange(ChangeLoggedModel): instance = self.model.objects.get(pk=self.object_id) logger.info(f'Deleting {self.model._meta.verbose_name} {instance}') instance.delete() + apply.alters_data = True + + def get_action_color(self): + return ChangeActionChoices.colors.get(self.action) diff --git a/netbox/extras/models/tags.py b/netbox/extras/models/tags.py index 066c0fd78..f54b3d0fe 100644 --- a/netbox/extras/models/tags.py +++ b/netbox/extras/models/tags.py @@ -1,9 +1,13 @@ from django.conf import settings +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError from django.db import models from django.urls import reverse from django.utils.text import slugify +from django.utils.translation import gettext as _ from taggit.models import TagBase, GenericTaggedItemBase +from extras.utils import FeatureQuery from netbox.models import ChangeLoggedModel from netbox.models.features import CloningMixin, ExportTemplatesMixin from utilities.choices import ColorChoices @@ -30,9 +34,16 @@ class Tag(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel, TagBase): max_length=200, blank=True, ) + object_types = models.ManyToManyField( + to=ContentType, + related_name='+', + limit_choices_to=FeatureQuery('tags'), + blank=True, + help_text=_("The object type(s) to which this this tag can be applied.") + ) clone_fields = ( - 'color', 'description', + 'color', 'description', 'object_types', ) class Meta: @@ -61,6 +72,4 @@ class TaggedItem(GenericTaggedItemBase): ) class Meta: - index_together = ( - ("content_type", "object_id") - ) + indexes = [models.Index(fields=["content_type", "object_id"])] diff --git a/netbox/extras/signals.py b/netbox/extras/signals.py index 4972d9e85..d6550309f 100644 --- a/netbox/extras/signals.py +++ b/netbox/extras/signals.py @@ -10,8 +10,9 @@ from extras.validators import CustomValidator from netbox.config import get_config from netbox.context import current_request, webhooks_queue from netbox.signals import post_clean +from utilities.exceptions import AbortRequest from .choices import ObjectChangeActionChoices -from .models import ConfigRevision, CustomField, ObjectChange +from .models import ConfigRevision, CustomField, ObjectChange, TaggedItem from .webhooks import enqueue_object, get_snapshots, serialize_for_webhook # @@ -207,3 +208,21 @@ def update_config(sender, instance, **kwargs): Update the cached NetBox configuration when a new ConfigRevision is created. """ instance.activate() + + +# +# Tags +# + +@receiver(m2m_changed, sender=TaggedItem) +def validate_assigned_tags(sender, instance, action, model, pk_set, **kwargs): + """ + Validate that any Tags being assigned to the instance are not restricted to non-applicable object types. + """ + if action != 'pre_add': + return + ct = ContentType.objects.get_for_model(instance) + # Retrieve any applied Tags that are restricted to certain object_types + for tag in model.objects.filter(pk__in=pk_set, object_types__isnull=False).prefetch_related('object_types'): + if ct not in tag.object_types.all(): + raise AbortRequest(f"Tag {tag} cannot be assigned to {ct.model} objects.") diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index 8d046b85d..35d53d1a6 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -9,6 +9,7 @@ from .template_code import * __all__ = ( 'ConfigContextTable', + 'ConfigRevisionTable', 'ConfigTemplateTable', 'CustomFieldTable', 'CustomLinkTable', @@ -22,6 +23,37 @@ __all__ = ( 'WebhookTable', ) +IMAGEATTACHMENT_IMAGE = ''' +{% if record.image %} + {{ record }} +{% else %} + — +{% endif %} +''' + +REVISION_BUTTONS = """ +{% if not record.is_active %} + + + +{% endif %} +""" + + +class ConfigRevisionTable(NetBoxTable): + is_active = columns.BooleanColumn() + actions = columns.ActionsColumn( + actions=('delete',), + extra_buttons=REVISION_BUTTONS + ) + + class Meta(NetBoxTable.Meta): + model = ConfigRevision + fields = ( + 'pk', 'id', 'is_active', 'created', 'comment', + ) + default_columns = ('pk', 'id', 'is_active', 'created', 'comment') + class CustomFieldTable(NetBoxTable): name = tables.Column( @@ -96,6 +128,9 @@ class ImageAttachmentTable(NetBoxTable): parent = tables.Column( linkify=True ) + image = tables.TemplateColumn( + template_code=IMAGEATTACHMENT_IMAGE, + ) size = tables.Column( orderable=False, verbose_name='Size (bytes)' @@ -175,10 +210,14 @@ class TagTable(NetBoxTable): linkify=True ) color = columns.ColorColumn() + object_types = columns.ContentTypesColumn() class Meta(NetBoxTable.Meta): model = Tag - fields = ('pk', 'id', 'name', 'items', 'slug', 'color', 'description', 'created', 'last_updated', 'actions') + fields = ( + 'pk', 'id', 'name', 'items', 'slug', 'color', 'description', 'object_types', 'created', 'last_updated', + 'actions', + ) default_columns = ('pk', 'name', 'items', 'slug', 'color', 'description') diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index b59481a36..4c48aa73e 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -1,6 +1,6 @@ import datetime -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.urls import reverse from django.utils.timezone import make_aware @@ -15,6 +15,9 @@ from extras.scripts import BooleanVar, IntegerVar, Script, StringVar from utilities.testing import APITestCase, APIViewTestCases +User = get_user_model() + + class AppTest(APITestCase): def test_root(self): diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py index e77afd20e..7dff14cc0 100644 --- a/netbox/extras/tests/test_filtersets.py +++ b/netbox/extras/tests/test_filtersets.py @@ -1,7 +1,7 @@ import uuid from datetime import datetime, timezone -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.test import TestCase @@ -18,6 +18,9 @@ from utilities.testing import BaseFilterSetTests, ChangeLoggedFilterSetTests, cr from virtualization.models import Cluster, ClusterGroup, ClusterType +User = get_user_model() + + class CustomFieldTestCase(TestCase, BaseFilterSetTests): queryset = CustomField.objects.all() filterset = CustomFieldFilterSet @@ -818,6 +821,10 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests): @classmethod def setUpTestData(cls): + content_types = { + 'site': ContentType.objects.get_by_natural_key('dcim', 'site'), + 'provider': ContentType.objects.get_by_natural_key('circuits', 'provider'), + } tags = ( Tag(name='Tag 1', slug='tag-1', color='ff0000', description='foobar1'), @@ -825,6 +832,8 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests): Tag(name='Tag 3', slug='tag-3', color='0000ff'), ) Tag.objects.bulk_create(tags) + tags[0].object_types.add(content_types['site']) + tags[1].object_types.add(content_types['provider']) # Apply some tags so we can filter by content type site = Site.objects.create(name='Site 1', slug='site-1') @@ -857,6 +866,18 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'content_type_id': [site_ct, provider_ct]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_object_types(self): + params = {'for_object_type_id': [ContentType.objects.get_by_natural_key('dcim', 'site').pk]} + self.assertEqual( + list(self.filterset(params, self.queryset).qs.values_list('name', flat=True)), + ['Tag 1', 'Tag 3'] + ) + params = {'for_object_type_id': [ContentType.objects.get_by_natural_key('circuits', 'provider').pk]} + self.assertEqual( + list(self.filterset(params, self.queryset).qs.values_list('name', flat=True)), + ['Tag 2', 'Tag 3'] + ) + class ObjectChangeTestCase(TestCase, BaseFilterSetTests): queryset = ObjectChange.objects.all() diff --git a/netbox/extras/tests/test_models.py b/netbox/extras/tests/test_models.py index 0ac63c086..0d1dc0e51 100644 --- a/netbox/extras/tests/test_models.py +++ b/netbox/extras/tests/test_models.py @@ -1,8 +1,10 @@ +from django.contrib.contenttypes.models import ContentType from django.test import TestCase from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, Platform, Region, Site, SiteGroup from extras.models import ConfigContext, Tag from tenancy.models import Tenant, TenantGroup +from utilities.exceptions import AbortRequest from virtualization.models import Cluster, ClusterGroup, ClusterType, VirtualMachine @@ -14,6 +16,22 @@ class TagTest(TestCase): self.assertEqual(tag.slug, 'testing-unicode-台灣') + def test_object_type_validation(self): + region = Region.objects.create(name='Region 1', slug='region-1') + sitegroup = SiteGroup.objects.create(name='Site Group 1', slug='site-group-1') + + # Create a Tag that can only be applied to Regions + tag = Tag.objects.create(name='Tag 1', slug='tag-1') + tag.object_types.add(ContentType.objects.get_by_natural_key('dcim', 'region')) + + # Apply the Tag to a Region + region.tags.add(tag) + self.assertIn(tag, region.tags.all()) + + # Apply the Tag to a SiteGroup + with self.assertRaises(AbortRequest): + sitegroup.tags.add(tag) + class ConfigContextTest(TestCase): """ diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index ef8e87489..3dcb90875 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -1,7 +1,7 @@ import urllib.parse import uuid -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.urls import reverse @@ -11,6 +11,9 @@ from extras.models import * from utilities.testing import ViewTestCases, TestCase +User = get_user_model() + + class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = CustomField diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index c4fc3d938..b3909391a 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -85,6 +85,13 @@ urlpatterns = [ path('journal-entries/import/', views.JournalEntryBulkImportView.as_view(), name='journalentry_import'), path('journal-entries//', include(get_model_urls('extras', 'journalentry'))), + # Config revisions + path('config-revisions/', views.ConfigRevisionListView.as_view(), name='configrevision_list'), + path('config-revisions/add/', views.ConfigRevisionEditView.as_view(), name='configrevision_add'), + path('config-revisions/delete/', views.ConfigRevisionBulkDeleteView.as_view(), name='configrevision_bulk_delete'), + path('config-revisions//restore/', views.ConfigRevisionRestoreView.as_view(), name='configrevision_restore'), + path('config-revisions//', include(get_model_urls('extras', 'configrevision'))), + # Change logging path('changelog/', views.ObjectChangeListView.as_view(), name='objectchange_list'), path('changelog//', include(get_model_urls('extras', 'objectchange'))), @@ -114,5 +121,5 @@ urlpatterns = [ path('scripts///jobs/', views.ScriptJobsView.as_view(), name='script_jobs'), # Markdown - path('render/markdown/', views.RenderMarkdownView.as_view(), name="render_markdown") + path('render/markdown/', views.RenderMarkdownView.as_view(), name="render_markdown"), ] diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 6cbadf09d..9e02b5019 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -14,6 +14,7 @@ from core.models import Job from core.tables import JobTable from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm from extras.dashboard.utils import get_widget_class +from netbox.config import get_config, PARAMS from netbox.views import generic from utilities.forms import ConfirmationForm, get_field_value from utilities.htmx import is_htmx @@ -1176,6 +1177,74 @@ class ScriptResultView(ContentTypePermissionRequiredMixin, View): }) +# +# Config Revisions +# + +class ConfigRevisionListView(generic.ObjectListView): + queryset = ConfigRevision.objects.all() + filterset = filtersets.ConfigRevisionFilterSet + filterset_form = forms.ConfigRevisionFilterForm + table = tables.ConfigRevisionTable + + +@register_model_view(ConfigRevision) +class ConfigRevisionView(generic.ObjectView): + queryset = ConfigRevision.objects.all() + + +class ConfigRevisionEditView(generic.ObjectEditView): + queryset = ConfigRevision.objects.all() + form = forms.ConfigRevisionForm + + +@register_model_view(ConfigRevision, 'delete') +class ConfigRevisionDeleteView(generic.ObjectDeleteView): + queryset = ConfigRevision.objects.all() + + +class ConfigRevisionBulkDeleteView(generic.BulkDeleteView): + queryset = ConfigRevision.objects.all() + filterset = filtersets.ConfigRevisionFilterSet + table = tables.ConfigRevisionTable + + +class ConfigRevisionRestoreView(ContentTypePermissionRequiredMixin, View): + + def get_required_permission(self): + return 'extras.configrevision_edit' + + def get(self, request, pk): + candidate_config = get_object_or_404(ConfigRevision, pk=pk) + + # Get the current ConfigRevision + config_version = get_config().version + current_config = ConfigRevision.objects.filter(pk=config_version).first() + + params = [] + for param in PARAMS: + params.append(( + param.name, + current_config.data.get(param.name, None), + candidate_config.data.get(param.name, None) + )) + + return render(request, 'extras/configrevision_restore.html', { + 'object': candidate_config, + 'params': params, + }) + + def post(self, request, pk): + if not request.user.has_perm('extras.configrevision_edit'): + return HttpResponseForbidden() + + candidate_config = get_object_or_404(ConfigRevision, pk=pk) + candidate_config.activate() + messages.success(request, f"Restored configuration revision #{pk}") + + return redirect(candidate_config.get_absolute_url()) + + # # Markdown # 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/api/serializers.py b/netbox/ipam/api/serializers.py index 064452667..f59850aa2 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -58,6 +58,7 @@ class AvailableASNSerializer(serializers.Serializer): Representation of an ASN which does not exist in the database. """ asn = serializers.IntegerField(read_only=True) + description = serializers.CharField(required=False) def to_representation(self, asn): rir = NestedRIRSerializer(self.context['range'].rir, context={ @@ -432,6 +433,7 @@ class AvailableIPSerializer(serializers.Serializer): family = serializers.IntegerField(read_only=True) address = serializers.CharField(read_only=True) vrf = NestedVRFSerializer(read_only=True) + description = serializers.CharField(required=False) def to_representation(self, instance): if self.context.get('vrf'): diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index f432e0e6b..c895a706b 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -3,7 +3,9 @@ from django.db import transaction from django.shortcuts import get_object_or_404 from django_pglocks import advisory_lock from drf_spectacular.utils import extend_schema +from netaddr import IPSet from rest_framework import status +from rest_framework.exceptions import ValidationError from rest_framework.response import Response from rest_framework.routers import APIRootView from rest_framework.views import APIView @@ -12,10 +14,12 @@ from circuits.models import Provider from dcim.models import Site from ipam import filtersets from ipam.models import * +from ipam.utils import get_next_available_prefix from netbox.api.viewsets import NetBoxModelViewSet from netbox.api.viewsets.mixins import ObjectValidationMixin from netbox.config import get_config from netbox.constants import ADVISORY_LOCK_KEYS +from utilities.api import get_serializer_for_model from utilities.utils import count_related from . import serializers from ipam.models import L2VPN, L2VPNTermination @@ -207,237 +211,233 @@ def get_results_limit(request): return limit -class AvailableASNsView(ObjectValidationMixin, APIView): - queryset = ASN.objects.all() +class AvailableObjectsView(ObjectValidationMixin, APIView): + """ + Return a list of dicts representing child objects that have not yet been created for a parent object. + """ + read_serializer_class = None + write_serializer_class = None + advisory_lock_key = None + + def get_parent(self, request, pk): + """ + Return the parent object. + """ + raise NotImplemented() + + def get_available_objects(self, parent, limit=None): + """ + Return all available objects for the parent. + """ + raise NotImplemented() + + def get_extra_context(self, parent): + """ + Return any extra context data for the serializer. + """ + return {} + + def check_sufficient_available(self, requested_objects, available_objects): + """ + Check if there exist a sufficient number of available objects to satisfy the request. + """ + return len(requested_objects) <= len(available_objects) + + def prep_object_data(self, requested_objects, available_objects, parent): + """ + Prepare data by setting any programmatically determined object attributes (e.g. next available VLAN ID) + on the request data. + """ + return requested_objects - @extend_schema(methods=["get"], responses={200: serializers.AvailableASNSerializer(many=True)}) def get(self, request, pk): - asnrange = get_object_or_404(ASNRange.objects.restrict(request.user), pk=pk) + parent = self.get_parent(request, pk) limit = get_results_limit(request) + available_objects = self.get_available_objects(parent, limit) - available_asns = asnrange.get_available_asns()[:limit] - - serializer = serializers.AvailableASNSerializer(available_asns, many=True, context={ + serializer = self.read_serializer_class(available_objects, many=True, context={ 'request': request, - 'range': asnrange, + **self.get_extra_context(parent), }) return Response(serializer.data) - @extend_schema(methods=["post"], responses={201: serializers.ASNSerializer(many=True)}) - @advisory_lock(ADVISORY_LOCK_KEYS['available-asns']) def post(self, request, pk): self.queryset = self.queryset.restrict(request.user, 'add') - asnrange = get_object_or_404(ASNRange.objects.restrict(request.user), pk=pk) + parent = self.get_parent(request, pk) - # Normalize to a list of objects - requested_asns = request.data if isinstance(request.data, list) else [request.data] + # Normalize request data to a list of objects + requested_objects = request.data if isinstance(request.data, list) else [request.data] - # Determine if the requested number of IPs is available - available_asns = asnrange.get_available_asns() - if len(available_asns) < len(requested_asns): - return Response( - { - "detail": f"An insufficient number of ASNs are available within {asnrange} " - f"({len(requested_asns)} requested, {len(available_asns)} available)" - }, - status=status.HTTP_409_CONFLICT - ) - - # Assign ASNs from the list of available IPs and copy VRF assignment from the parent - for i, requested_asn in enumerate(requested_asns): - requested_asn.update({ - 'rir': asnrange.rir.pk, - 'range': asnrange.pk, - 'asn': available_asns[i], - }) - - # Initialize the serializer with a list or a single object depending on what was requested - context = {'request': request} - if isinstance(request.data, list): - serializer = serializers.ASNSerializer(data=requested_asns, many=True, context=context) - else: - serializer = serializers.ASNSerializer(data=requested_asns[0], context=context) - - # Create the new IP address(es) - if serializer.is_valid(): - try: - with transaction.atomic(): - created = serializer.save() - self._validate_objects(created) - except ObjectDoesNotExist: - raise PermissionDenied() - return Response(serializer.data, status=status.HTTP_201_CREATED) - - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def get_serializer_class(self): - if self.request.method == "GET": - return serializers.AvailableASNSerializer - - return serializers.ASNSerializer - - -class AvailablePrefixesView(ObjectValidationMixin, APIView): - queryset = Prefix.objects.all() - - @extend_schema(methods=["get"], responses={200: serializers.AvailablePrefixSerializer(many=True)}) - def get(self, request, pk): - prefix = get_object_or_404(Prefix.objects.restrict(request.user), pk=pk) - available_prefixes = prefix.get_available_prefixes() - - serializer = serializers.AvailablePrefixSerializer(available_prefixes.iter_cidrs(), many=True, context={ + # Serialize and validate the request data + serializer = self.write_serializer_class(data=requested_objects, many=True, context={ 'request': request, - 'vrf': prefix.vrf, + **self.get_extra_context(parent), }) - - return Response(serializer.data) - - @extend_schema(methods=["post"], responses={201: serializers.PrefixSerializer(many=True)}) - @advisory_lock(ADVISORY_LOCK_KEYS['available-prefixes']) - def post(self, request, pk): - self.queryset = self.queryset.restrict(request.user, 'add') - prefix = get_object_or_404(Prefix.objects.restrict(request.user), pk=pk) - available_prefixes = prefix.get_available_prefixes() - - # Validate Requested Prefixes' length - serializer = serializers.PrefixLengthSerializer( - data=request.data if isinstance(request.data, list) else [request.data], - many=True, - context={ - 'request': request, - 'prefix': prefix, - } - ) if not serializer.is_valid(): return Response( serializer.errors, status=status.HTTP_400_BAD_REQUEST ) - requested_prefixes = serializer.validated_data - # Allocate prefixes to the requested objects based on availability within the parent - for i, requested_prefix in enumerate(requested_prefixes): + with advisory_lock(ADVISORY_LOCK_KEYS[self.advisory_lock_key]): + available_objects = self.get_available_objects(parent) - # Find the first available prefix equal to or larger than the requested size - for available_prefix in available_prefixes.iter_cidrs(): - if requested_prefix['prefix_length'] >= available_prefix.prefixlen: - allocated_prefix = '{}/{}'.format(available_prefix.network, requested_prefix['prefix_length']) - requested_prefix['prefix'] = allocated_prefix - requested_prefix['vrf'] = prefix.vrf.pk if prefix.vrf else None - break - else: + # Determine if the requested number of objects is available + if not self.check_sufficient_available(serializer.validated_data, available_objects): return Response( - { - "detail": "Insufficient space is available to accommodate the requested prefix size(s)" - }, + {"detail": f"Insufficient resources are available to satisfy the request"}, status=status.HTTP_409_CONFLICT ) - # Remove the allocated prefix from the list of available prefixes - available_prefixes.remove(allocated_prefix) + # Prepare object data for deserialization + requested_objects = self.prep_object_data(serializer.validated_data, available_objects, parent) - # Initialize the serializer with a list or a single object depending on what was requested - context = {'request': request} - if isinstance(request.data, list): - serializer = serializers.PrefixSerializer(data=requested_prefixes, many=True, context=context) - else: - serializer = serializers.PrefixSerializer(data=requested_prefixes[0], context=context) + # Initialize the serializer with a list or a single object depending on what was requested + serializer_class = get_serializer_for_model(self.queryset.model) + context = {'request': request} + if isinstance(request.data, list): + serializer = serializer_class(data=requested_objects, many=True, context=context) + else: + serializer = serializer_class(data=requested_objects[0], context=context) - # Create the new Prefix(es) - if serializer.is_valid(): + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + # Create the new IP address(es) try: with transaction.atomic(): created = serializer.save() self._validate_objects(created) except ObjectDoesNotExist: raise PermissionDenied() - return Response(serializer.data, status=status.HTTP_201_CREATED) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def get_serializer_class(self): - if self.request.method == "GET": - return serializers.AvailablePrefixSerializer - - return serializers.PrefixLengthSerializer + return Response(serializer.data, status=status.HTTP_201_CREATED) -class AvailableIPAddressesView(ObjectValidationMixin, APIView): - queryset = IPAddress.objects.all() +class AvailableASNsView(AvailableObjectsView): + queryset = ASN.objects.all() + read_serializer_class = serializers.AvailableASNSerializer + write_serializer_class = serializers.AvailableASNSerializer + advisory_lock_key = 'available-asns' def get_parent(self, request, pk): - raise NotImplemented() + return get_object_or_404(ASNRange.objects.restrict(request.user), pk=pk) - @extend_schema(methods=["get"], responses={200: serializers.AvailableIPSerializer(many=True)}) + def get_available_objects(self, parent, limit=None): + return parent.get_available_asns()[:limit] + + def get_extra_context(self, parent): + return { + 'range': parent, + } + + def prep_object_data(self, requested_objects, available_objects, parent): + for i, request_data in enumerate(requested_objects): + request_data.update({ + 'rir': parent.rir.pk, + 'range': parent.pk, + 'asn': available_objects[i], + }) + + return requested_objects + + @extend_schema(methods=["get"], responses={200: serializers.AvailableASNSerializer(many=True)}) def get(self, request, pk): - parent = self.get_parent(request, pk) - limit = get_results_limit(request) + return super().get(request, pk) + @extend_schema(methods=["post"], responses={201: serializers.AvailableASNSerializer(many=True)}) + def post(self, request, pk): + return super().post(request, pk) + + +class AvailablePrefixesView(AvailableObjectsView): + queryset = Prefix.objects.all() + read_serializer_class = serializers.AvailablePrefixSerializer + write_serializer_class = serializers.PrefixLengthSerializer + advisory_lock_key = 'available-prefixes' + + def get_parent(self, request, pk): + return get_object_or_404(Prefix.objects.restrict(request.user), pk=pk) + + def get_available_objects(self, parent, limit=None): + return parent.get_available_prefixes().iter_cidrs() + + def check_sufficient_available(self, requested_objects, available_objects): + available_prefixes = IPSet(available_objects) + for requested_object in requested_objects: + if not get_next_available_prefix(available_prefixes, requested_object['prefix_length']): + return False + return True + + def get_extra_context(self, parent): + return { + 'prefix': parent, + 'vrf': parent.vrf, + } + + def prep_object_data(self, requested_objects, available_objects, parent): + available_prefixes = IPSet(available_objects) + for i, request_data in enumerate(requested_objects): + + # Find the first available prefix equal to or larger than the requested size + if allocated_prefix := get_next_available_prefix(available_prefixes, request_data['prefix_length']): + request_data.update({ + 'prefix': allocated_prefix, + 'vrf': parent.vrf.pk if parent.vrf else None, + }) + else: + raise ValidationError("Insufficient space is available to accommodate the requested prefix size(s)") + + return requested_objects + + @extend_schema(methods=["get"], responses={200: serializers.AvailablePrefixSerializer(many=True)}) + def get(self, request, pk): + return super().get(request, pk) + + @extend_schema(methods=["post"], responses={201: serializers.PrefixSerializer(many=True)}) + def post(self, request, pk): + return super().post(request, pk) + + +class AvailableIPAddressesView(AvailableObjectsView): + queryset = IPAddress.objects.all() + read_serializer_class = serializers.AvailableIPSerializer + write_serializer_class = serializers.AvailableIPSerializer + advisory_lock_key = 'available-ips' + + def get_available_objects(self, parent, limit=None): # Calculate available IPs within the parent ip_list = [] for index, ip in enumerate(parent.get_available_ips(), start=1): ip_list.append(ip) if index == limit: break - serializer = serializers.AvailableIPSerializer(ip_list, many=True, context={ - 'request': request, + return ip_list + + def get_extra_context(self, parent): + return { 'parent': parent, 'vrf': parent.vrf, - }) + } - return Response(serializer.data) + def prep_object_data(self, requested_objects, available_objects, parent): + available_ips = iter(available_objects) + for i, request_data in enumerate(requested_objects): + request_data.update({ + 'address': f'{next(available_ips)}/{parent.mask_length}', + 'vrf': parent.vrf.pk if parent.vrf else None, + }) + + return requested_objects + + @extend_schema(methods=["get"], responses={200: serializers.AvailableIPSerializer(many=True)}) + def get(self, request, pk): + return super().get(request, pk) @extend_schema(methods=["post"], responses={201: serializers.IPAddressSerializer(many=True)}) - @advisory_lock(ADVISORY_LOCK_KEYS['available-ips']) def post(self, request, pk): - self.queryset = self.queryset.restrict(request.user, 'add') - parent = self.get_parent(request, pk) - - # Normalize to a list of objects - requested_ips = request.data if isinstance(request.data, list) else [request.data] - - # Determine if the requested number of IPs is available - available_ips = parent.get_available_ips() - if available_ips.size < len(requested_ips): - return Response( - { - "detail": f"An insufficient number of IP addresses are available within {parent} " - f"({len(requested_ips)} requested, {len(available_ips)} available)" - }, - status=status.HTTP_409_CONFLICT - ) - - # Assign addresses from the list of available IPs and copy VRF assignment from the parent - available_ips = iter(available_ips) - for requested_ip in requested_ips: - requested_ip['address'] = f'{next(available_ips)}/{parent.mask_length}' - requested_ip['vrf'] = parent.vrf.pk if parent.vrf else None - - # Initialize the serializer with a list or a single object depending on what was requested - context = {'request': request} - if isinstance(request.data, list): - serializer = serializers.IPAddressSerializer(data=requested_ips, many=True, context=context) - else: - serializer = serializers.IPAddressSerializer(data=requested_ips[0], context=context) - - # Create the new IP address(es) - if serializer.is_valid(): - try: - with transaction.atomic(): - created = serializer.save() - self._validate_objects(created) - except ObjectDoesNotExist: - raise PermissionDenied() - return Response(serializer.data, status=status.HTTP_201_CREATED) - - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def get_serializer_class(self): - if self.request.method == "GET": - return serializers.AvailableIPSerializer - - return serializers.IPAddressSerializer + return super().post(request, pk) class PrefixAvailableIPAddressesView(AvailableIPAddressesView): @@ -452,77 +452,36 @@ class IPRangeAvailableIPAddressesView(AvailableIPAddressesView): return get_object_or_404(IPRange.objects.restrict(request.user), pk=pk) -class AvailableVLANsView(ObjectValidationMixin, APIView): +class AvailableVLANsView(AvailableObjectsView): queryset = VLAN.objects.all() + read_serializer_class = serializers.AvailableVLANSerializer + write_serializer_class = serializers.CreateAvailableVLANSerializer + advisory_lock_key = 'available-vlans' + + def get_parent(self, request, pk): + return get_object_or_404(VLANGroup.objects.restrict(request.user), pk=pk) + + def get_available_objects(self, parent, limit=None): + return parent.get_available_vids()[:limit] + + def get_extra_context(self, parent): + return { + 'group': parent, + } + + def prep_object_data(self, requested_objects, available_objects, parent): + for i, request_data in enumerate(requested_objects): + request_data.update({ + 'vid': available_objects.pop(0), + 'group': parent.pk, + }) + + return requested_objects @extend_schema(methods=["get"], responses={200: serializers.AvailableVLANSerializer(many=True)}) def get(self, request, pk): - vlangroup = get_object_or_404(VLANGroup.objects.restrict(request.user), pk=pk) - limit = get_results_limit(request) - - available_vlans = vlangroup.get_available_vids()[:limit] - serializer = serializers.AvailableVLANSerializer(available_vlans, many=True, context={ - 'request': request, - 'group': vlangroup, - }) - - return Response(serializer.data) + return super().get(request, pk) @extend_schema(methods=["post"], responses={201: serializers.VLANSerializer(many=True)}) - @advisory_lock(ADVISORY_LOCK_KEYS['available-vlans']) def post(self, request, pk): - self.queryset = self.queryset.restrict(request.user, 'add') - vlangroup = get_object_or_404(VLANGroup.objects.restrict(request.user), pk=pk) - available_vlans = vlangroup.get_available_vids() - many = isinstance(request.data, list) - - # Validate requested VLANs - serializer = serializers.CreateAvailableVLANSerializer( - data=request.data if many else [request.data], - many=True, - context={ - 'request': request, - 'group': vlangroup, - } - ) - if not serializer.is_valid(): - return Response( - serializer.errors, - status=status.HTTP_400_BAD_REQUEST - ) - - requested_vlans = serializer.validated_data - - for i, requested_vlan in enumerate(requested_vlans): - try: - requested_vlan['vid'] = available_vlans.pop(0) - requested_vlan['group'] = vlangroup.pk - except IndexError: - return Response({ - "detail": "The requested number of VLANs is not available" - }, status=status.HTTP_409_CONFLICT) - - # Initialize the serializer with a list or a single object depending on what was requested - context = {'request': request} - if many: - serializer = serializers.VLANSerializer(data=requested_vlans, many=True, context=context) - else: - serializer = serializers.VLANSerializer(data=requested_vlans[0], context=context) - - # Create the new VLAN(s) - if serializer.is_valid(): - try: - with transaction.atomic(): - created = serializer.save() - self._validate_objects(created) - except ObjectDoesNotExist: - raise PermissionDenied() - return Response(serializer.data, status=status.HTTP_201_CREATED) - - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - - def get_serializer_class(self): - if self.request.method == "GET": - return serializers.AvailableVLANSerializer - - return serializers.VLANSerializer + return super().post(request, pk) diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py index fd0b315a0..683d40f49 100644 --- a/netbox/ipam/forms/bulk_import.py +++ b/netbox/ipam/forms/bulk_import.py @@ -1,6 +1,7 @@ from django import forms from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError +from django.db.models import Q from django.utils.translation import gettext as _ from dcim.models import Device, Interface, Site @@ -181,16 +182,31 @@ class PrefixImportForm(NetBoxModelImportForm): def __init__(self, data=None, *args, **kwargs): super().__init__(data, *args, **kwargs) - if data: + if not data: + return - # Limit VLAN queryset by assigned site and/or group (if specified) - params = {} - if data.get('site'): - params[f"site__{self.fields['site'].to_field_name}"] = data.get('site') - if data.get('vlan_group'): - params[f"group__{self.fields['vlan_group'].to_field_name}"] = data.get('vlan_group') - if params: - self.fields['vlan'].queryset = self.fields['vlan'].queryset.filter(**params) + site = data.get('site') + vlan_group = data.get('vlan_group') + + # Limit VLAN queryset by assigned site and/or group (if specified) + query = Q() + + if site: + query |= Q(**{ + f"site__{self.fields['site'].to_field_name}": site + }) + # Don't Forget to include VLANs without a site in the filter + query |= Q(**{ + f"site__{self.fields['site'].to_field_name}__isnull": True + }) + + if vlan_group: + query &= Q(**{ + f"group__{self.fields['vlan_group'].to_field_name}": vlan_group + }) + + queryset = self.fields['vlan'].queryset.filter(query) + self.fields['vlan'].queryset = queryset class IPRangeImportForm(NetBoxModelImportForm): diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py index cf8117bf7..b0b08e4e0 100644 --- a/netbox/ipam/forms/model_forms.py +++ b/netbox/ipam/forms/model_forms.py @@ -211,10 +211,8 @@ class PrefixForm(TenancyForm, NetBoxModelForm): vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), required=False, + selector=True, label=_('VLAN'), - query_params={ - 'site_id': '$site', - } ) role = DynamicModelChoiceField( queryset=Role.objects.all(), @@ -328,6 +326,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() @@ -340,7 +344,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 @@ -351,6 +360,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/tests/test_views.py b/netbox/ipam/tests/test_views.py index 44af9eae2..c9128c0f6 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -495,6 +495,65 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase): url = reverse('ipam:prefix_ipaddresses', kwargs={'pk': prefix.pk}) self.assertHttpStatus(self.client.get(url), 200) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_prefix_import(self): + """ + Custom import test for YAML-based imports (versus CSV) + """ + IMPORT_DATA = """ +prefix: 10.1.1.0/24 +status: active +vlan: 101 +site: Site 1 +""" + # Note, a site is not tied to the VLAN to verify the fix for #12622 + VLAN.objects.create(vid=101, name='VLAN101') + + # Add all required permissions to the test user + self.add_permissions('ipam.view_prefix', 'ipam.add_prefix') + + form_data = { + 'data': IMPORT_DATA, + 'format': 'yaml' + } + response = self.client.post(reverse('ipam:prefix_import'), data=form_data, follow=True) + self.assertHttpStatus(response, 200) + + prefix = Prefix.objects.get(prefix='10.1.1.0/24') + self.assertEqual(prefix.status, PrefixStatusChoices.STATUS_ACTIVE) + self.assertEqual(prefix.vlan.vid, 101) + self.assertEqual(prefix.site.name, "Site 1") + + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_prefix_import_with_vlan_group(self): + """ + This test covers a unique import edge case where VLAN group is specified during the import. + """ + IMPORT_DATA = """ +prefix: 10.1.2.0/24 +status: active +vlan: 102 +site: Site 1 +vlan_group: Group 1 +""" + vlan_group = VLANGroup.objects.create(name='Group 1', slug='group-1', scope=Site.objects.get(name="Site 1")) + VLAN.objects.create(vid=102, name='VLAN102', group=vlan_group) + + # Add all required permissions to the test user + self.add_permissions('ipam.view_prefix', 'ipam.add_prefix') + + form_data = { + 'data': IMPORT_DATA, + 'format': 'yaml' + } + response = self.client.post(reverse('ipam:prefix_import'), data=form_data, follow=True) + self.assertHttpStatus(response, 200) + + prefix = Prefix.objects.get(prefix='10.1.2.0/24') + self.assertEqual(prefix.status, PrefixStatusChoices.STATUS_ACTIVE) + self.assertEqual(prefix.vlan.vid, 102) + self.assertEqual(prefix.site.name, "Site 1") + class IPRangeTestCase(ViewTestCases.PrimaryObjectViewTestCase): model = IPRange diff --git a/netbox/ipam/utils.py b/netbox/ipam/utils.py index 93a40e5a0..f54c7d41d 100644 --- a/netbox/ipam/utils.py +++ b/netbox/ipam/utils.py @@ -1,7 +1,15 @@ import netaddr from .constants import * -from .models import ASN, Prefix, VLAN +from .models import Prefix, VLAN + +__all__ = ( + 'add_available_ipaddresses', + 'add_available_vlans', + 'add_requested_prefixes', + 'get_next_available_prefix', + 'rebuild_prefixes', +) def add_requested_prefixes(parent, prefix_list, show_available=True, show_assigned=True): @@ -184,3 +192,15 @@ def rebuild_prefixes(vrf): # Final flush of any remaining Prefixes Prefix.objects.bulk_update(update_queue, ['_depth', '_children']) + + +def get_next_available_prefix(ipset, prefix_size): + """ + Given a prefix length, allocate the next available prefix from an IPSet. + """ + for available_prefix in ipset.iter_cidrs(): + if prefix_size >= available_prefix.prefixlen: + allocated_prefix = f"{available_prefix.network}/{prefix_size}" + ipset.remove(allocated_prefix) + return allocated_prefix + return None 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/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/forms/base.py b/netbox/netbox/forms/base.py index 83c238e0f..88cec405f 100644 --- a/netbox/netbox/forms/base.py +++ b/netbox/netbox/forms/base.py @@ -31,6 +31,13 @@ class NetBoxModelForm(BootstrapMixin, CustomFieldsMixin, forms.ModelForm): required=False ) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Limit tags to those applicable to the object type + if (ct := self._get_content_type()) and hasattr(self.fields['tags'].widget, 'add_query_param'): + self.fields['tags'].widget.add_query_param('for_object_type_id', ct.pk) + def _get_content_type(self): return ContentType.objects.get_for_model(self._meta.model) diff --git a/netbox/netbox/middleware.py b/netbox/netbox/middleware.py index 461c018b9..76b3e42a8 100644 --- a/netbox/netbox/middleware.py +++ b/netbox/netbox/middleware.py @@ -181,19 +181,23 @@ class MaintenanceModeMiddleware: def __call__(self, request): if get_config().MAINTENANCE_MODE: - self._prevent_db_write_operations() + self._set_session_type( + allow_write=request.path_info.startswith(settings.MAINTENANCE_EXEMPT_PATHS) + ) return self.get_response(request) @staticmethod - def _prevent_db_write_operations(): + 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: - cursor.execute( - 'SET SESSION CHARACTERISTICS AS TRANSACTION READ ONLY;' - ) + mode = 'READ WRITE' if allow_write else 'READ ONLY' + cursor.execute(f'SET SESSION CHARACTERISTICS AS TRANSACTION {mode};') def process_exception(self, request, exception): """ diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index 6d82e2a2b..8d79dd6bc 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -71,6 +71,7 @@ class ChangeLoggingMixin(models.Model): `_prechange_snapshot` on the instance. """ self._prechange_snapshot = self.serialize_object() + snapshot.alters_data = True def to_objectchange(self, action): """ @@ -197,11 +198,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 +232,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 @@ -238,6 +245,7 @@ class CustomFieldsMixin(models.Model): """ for cf in self.custom_fields: self.custom_field_data[cf.name] = cf.default + populate_custom_field_defaults.alters_data = True def clean(self): super().clean() @@ -413,6 +421,7 @@ class SyncedDataMixin(models.Model): self.data_synced = None super().clean() + clean.alters_data = True def save(self, *args, **kwargs): from core.models import AutoSyncRecord @@ -460,6 +469,7 @@ class SyncedDataMixin(models.Model): self.data_synced = timezone.now() if save: self.save() + sync.alters_data = True def sync_data(self): """ diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index 6e5bcfc23..100de16da 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'), @@ -301,12 +301,14 @@ CUSTOMIZATION_MENU = Menu( MenuItem( link='extras:report_list', link_text=_('Reports'), - permissions=['extras.view_report'] + permissions=['extras.view_report'], + buttons=get_model_buttons('extras', "reportmodule", actions=['add']) ), MenuItem( link='extras:script_list', link_text=_('Scripts'), - permissions=['extras.view_script'] + permissions=['extras.view_script'], + buttons=get_model_buttons('extras', "scriptmodule", actions=['add']) ), ), ), @@ -344,6 +346,22 @@ OPERATIONS_MENU = Menu( ), ) +ADMIN_MENU = Menu( + label=_('Admin'), + icon_class='mdi mdi-account-multiple', + groups=( + MenuGroup( + label=_('Configuration'), + items=( + MenuItem( + link='extras:configrevision_list', + link_text=_('Config Revisions'), + permissions=['extras.view_configrevision'] + ), + ), + ), + ), +) MENUS = [ ORGANIZATION_MENU, @@ -358,6 +376,7 @@ MENUS = [ PROVISIONING_MENU, CUSTOMIZATION_MENU, OPERATIONS_MENU, + ADMIN_MENU, ] # diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 575755d2b..31363144f 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.2-dev' +VERSION = '3.5.5-dev' # Hostname HOSTNAME = platform.node() @@ -140,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) @@ -478,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/tables/tables.py b/netbox/netbox/tables/tables.py index 839d85996..20eab822d 100644 --- a/netbox/netbox/tables/tables.py +++ b/netbox/netbox/tables/tables.py @@ -140,10 +140,14 @@ class BaseTable(tables.Table): if request.user.is_authenticated: table_name = self.__class__.__name__ if self.prefixed_order_by_field in request.GET: - # If an ordering has been specified as a query parameter, save it as the - # user's preferred ordering for this table. - ordering = request.GET.getlist(self.prefixed_order_by_field) - request.user.config.set(f'tables.{table_name}.ordering', ordering, commit=True) + if request.GET[self.prefixed_order_by_field]: + # If an ordering has been specified as a query parameter, save it as the + # user's preferred ordering for this table. + ordering = request.GET.getlist(self.prefixed_order_by_field) + request.user.config.set(f'tables.{table_name}.ordering', ordering, commit=True) + else: + # If the ordering has been set to none (empty), clear any existing preference. + request.user.config.clear(f'tables.{table_name}.ordering', commit=True) elif ordering := request.user.config.get(f'tables.{table_name}.ordering'): # If no ordering has been specified, set the preferred ordering (if any). self.order_by = ordering diff --git a/netbox/netbox/tests/test_authentication.py b/netbox/netbox/tests/test_authentication.py index 4e46996b5..1804087d1 100644 --- a/netbox/netbox/tests/test_authentication.py +++ b/netbox/netbox/tests/test_authentication.py @@ -1,7 +1,8 @@ import datetime from django.conf import settings -from django.contrib.auth.models import Group, User +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group from django.contrib.contenttypes.models import ContentType from django.test import Client from django.test.utils import override_settings @@ -16,6 +17,9 @@ from utilities.testing import TestCase from utilities.testing.api import APITestCase +User = get_user_model() + + class TokenAuthenticationTestCase(APITestCase): @override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*']) 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/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/admin/extras/configrevision/restore.html b/netbox/templates/admin/extras/configrevision/restore.html deleted file mode 100644 index 4a0eb81a6..000000000 --- a/netbox/templates/admin/extras/configrevision/restore.html +++ /dev/null @@ -1,37 +0,0 @@ -{% extends "admin/base_site.html" %} -{% load static %} - -{% block content %} -

Restore configuration #{{ object.pk }} from {{ object.created }}?

- - - - - - - - - - - - {% for param, current, new in params %} - - - - - - - {% endfor %} - -
ParameterCurrent ValueNew Value
{{ param }}{{ current }}{{ new }}{% if current != new %}*{% endif %}
- -
- {% csrf_token %} -
- - Cancel -
-
-{% endblock content %} - - diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index b0e67269c..68fa84a24 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -76,6 +76,23 @@ {% endif %} + + GPS Coordinates + + {% if object.latitude and object.longitude %} + {% if config.MAPS_URL %} + + {% endif %} + {{ object.latitude }}, {{ object.longitude }} + {% else %} + {{ ''|placeholder }} + {% endif %} + + Tenant 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/device_edit.html b/netbox/templates/dcim/device_edit.html index 17780b513..2dbe1e3c5 100644 --- a/netbox/templates/dcim/device_edit.html +++ b/netbox/templates/dcim/device_edit.html @@ -53,6 +53,8 @@ {% else %} {% render_field form.face %} {% render_field form.position %} + {% render_field form.latitude %} + {% render_field form.longitude %} {% endif %} 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/rack.html b/netbox/templates/dcim/rack.html index 52b5d4bfe..01aeacff1 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -101,6 +101,12 @@ + + + +
MAC Address{{ object.mac_address|placeholder }}{{ object.mac_address|placeholder }}
WWN{{ object.wwn|placeholder }}{{ object.wwn|placeholder }}
VRFHeight {{ object.u_height }}U ({% if object.desc_units %}descending{% else %}ascending{% endif %})
Starting Unit + {{ object.starting_unit }} +
Outer Width diff --git a/netbox/templates/dcim/rack_edit.html b/netbox/templates/dcim/rack_edit.html index 4bbd72405..a1ebb7531 100644 --- a/netbox/templates/dcim/rack_edit.html +++ b/netbox/templates/dcim/rack_edit.html @@ -71,6 +71,7 @@ {% render_field form.mounting_depth %} {% render_field form.desc_units %} + {% render_field form.starting_unit %} {% if form.custom_fields %} diff --git a/netbox/templates/extras/configrevision.html b/netbox/templates/extras/configrevision.html new file mode 100644 index 000000000..1c7eeb2dd --- /dev/null +++ b/netbox/templates/extras/configrevision.html @@ -0,0 +1,200 @@ +{% extends 'generic/object.html' %} +{% load buttons %} +{% load custom_links %} +{% load helpers %} +{% load perms %} +{% load plugins %} +{% load static %} + +{% block breadcrumbs %} +{% endblock %} + +{% block controls %} +
+
+ {% plugin_buttons object %} +
+
+ {% custom_links object %} +
+
+{% endblock controls %} + +{% block content %} +
+
+
+
Rack Elevation
+
+ + + + + + + + + +
Rack elevation default unit height:{{ object.data.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT }}
Rack elevation default unit width:{{ object.data.RACK_ELEVATION_DEFAULT_UNIT_WIDTH }}
+
+
+ +
+
Power
+
+ + + + + + + + + + + + + +
Powerfeed default voltage:{{ object.data.POWERFEED_DEFAULT_VOLTAGE }}
Powerfeed default amperage:{{ object.data.POWERFEED_DEFAULT_AMPERAGE }}
Powerfeed default max utilization:{{ object.data.POWERFEED_DEFAULT_MAX_UTILIZATION }}
+
+
+ +
+
IPAM
+
+ + + + + + + + + +
IPAM enforce global unique:{{ object.data.ENFORCE_GLOBAL_UNIQUE }}
IPAM prefer IPV4:{{ object.data.PREFER_IPV4 }}
+
+
+ +
+
Security
+
+ + + + + +
Allowed URL schemes:{{ object.data.ALLOWED_URL_SCHEMES }}
+
+
+ +
+
Banners
+
+ + + + + + + + + + + + + + + + + +
Login banner:{{ object.data.BANNER_LOGIN }}
Maintenance banner:{{ object.data.BANNER_MAINTENANCE }}
Top banner:{{ object.data.BANNER_TOP }}
Bottom banner:{{ object.data.BANNER_BOTTOM }}
+
+
+ + +
+
+ +
+
Pagination
+
+ + + + + + + + + +
Paginate count:{{ object.data.PAGINATE_COUNT }}
Max page size:{{ object.data.MAX_PAGE_SIZE }}
+
+
+ +
+
Validation
+
+ + + + + +
Custom validators:{{ object.data.CUSTOM_VALIDATORS }}
+
+
+ +
+
User Preferences
+
+ + + + + +
Default user preferences:{{ object.data.DEFAULT_USER_PREFERENCES }}
+
+
+ +
+
Miscellaneous
+
+ + + + + + + + + + + + + + + + + + + + + +
Maintenance mode:{{ object.data.MAINTENANCE_MODE }}
GraphQL enabled:{{ object.data.GRAPHQL_ENABLED }}
Changelog retention:{{ object.data.CHANGELOG_RETENTION }}
Job retention:{{ object.data.JOB_RETENTION }}
Maps URL:{{ object.data.MAPS_URL }}
+
+
+ +
+
Config Revision
+
+ + + + + +
Comment:{{ object.comment }}
+
+
+ +
+
+{% endblock %} diff --git a/netbox/templates/extras/configrevision_restore.html b/netbox/templates/extras/configrevision_restore.html new file mode 100644 index 000000000..ac22f8cbd --- /dev/null +++ b/netbox/templates/extras/configrevision_restore.html @@ -0,0 +1,88 @@ +{% extends 'base/layout.html' %} +{% load helpers %} +{% load buttons %} +{% load perms %} +{% load static %} + +{% block title %}Restore: {{ object }}{% endblock %} + +{% block subtitle %} +
+ Created {{ object.created|annotated_date }} +
+{% endblock %} + +{% block header %} +
+ +
+ {{ block.super }} +{% endblock header %} + +{% block controls %} +
+
+ {% if request.user|can_delete:job %} + {% delete_button job %} + {% endif %} +
+
+{% endblock controls %} + +{% block tabs %} + +{% endblock %} + +{% block content %} +
+
+ + + + + + + + + + + {% for param, current, new in params %} + + + + + + + {% endfor %} + +
ParameterCurrent ValueNew Value
{{ param }}{{ current }}{{ new }}{% if current != new %}*{% endif %}
+
+
+ +
+ {% csrf_token %} +
+
+
+ + Cancel +
+
+
+
+ +{% endblock content %} + +{% block modals %} +{% endblock modals %} 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/tag.html b/netbox/templates/extras/tag.html index 6e4c5aee9..e5aa5cc75 100644 --- a/netbox/templates/extras/tag.html +++ b/netbox/templates/extras/tag.html @@ -43,9 +43,23 @@
-
- Tagged Item Types -
+
Allowed Object Types
+
+ + {% for ct in object.object_types.all %} + + + + {% empty %} + + + + {% endfor %} +
{{ ct }}
Any
+
+
+
+
Tagged Item Types
{% for object_type in object_types %} diff --git a/netbox/templates/generic/object.html b/netbox/templates/generic/object.html index d3a617455..ebbeb2dfc 100644 --- a/netbox/templates/generic/object.html +++ b/netbox/templates/generic/object.html @@ -38,7 +38,7 @@ Context: {{ block.super }} -{% endblock %} +{% endblock header %} {% block title %}{{ object }}{% endblock %} @@ -48,7 +48,7 @@ Context: ·Updated {{ object.last_updated|timesince }} ago -{% endblock %} +{% endblock subtitle %} {% block controls %} {# Clone/Edit/Delete Buttons #} diff --git a/netbox/templates/inc/panels/image_attachments.html b/netbox/templates/inc/panels/image_attachments.html index 0c1d212d9..a09fe78d5 100644 --- a/netbox/templates/inc/panels/image_attachments.html +++ b/netbox/templates/inc/panels/image_attachments.html @@ -1,12 +1,8 @@ {% load helpers %}
-
- Images -
-
+
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/tenancy/views.py b/netbox/tenancy/views.py index 5f8a7e314..bbe901bde 100644 --- a/netbox/tenancy/views.py +++ b/netbox/tenancy/views.py @@ -15,21 +15,31 @@ from .models import * class ObjectContactsView(generic.ObjectChildrenView): - child_model = Contact - table = tables.ContactTable - filterset = filtersets.ContactFilterSet + 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_contact', + permission='tenancy.view_contactassignment', weight=5000 ) def get_children(self, request, parent): - return Contact.objects.annotate( - assignment_count=count_related(ContactAssignment, 'contact') - ).restrict(request.user, 'view').filter(assignments__object_id=parent.pk) + 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 { diff --git a/netbox/users/api/nested_serializers.py b/netbox/users/api/nested_serializers.py index 3510184ae..5e15fa41a 100644 --- a/netbox/users/api/nested_serializers.py +++ b/netbox/users/api/nested_serializers.py @@ -1,4 +1,5 @@ -from django.contrib.auth.models import Group, User +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group from django.contrib.contenttypes.models import ContentType from drf_spectacular.utils import extend_schema_field from drf_spectacular.types import OpenApiTypes @@ -28,7 +29,7 @@ class NestedUserSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='users-api:user-detail') class Meta: - model = User + model = get_user_model() fields = ['id', 'url', 'display', 'username'] @extend_schema_field(OpenApiTypes.STR) diff --git a/netbox/users/api/serializers.py b/netbox/users/api/serializers.py index 1b975791f..1f4bf4ea0 100644 --- a/netbox/users/api/serializers.py +++ b/netbox/users/api/serializers.py @@ -1,5 +1,6 @@ from django.conf import settings -from django.contrib.auth.models import Group, User +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group from django.contrib.contenttypes.models import ContentType from drf_spectacular.utils import extend_schema_field from drf_spectacular.types import OpenApiTypes @@ -30,7 +31,7 @@ class UserSerializer(ValidatedModelSerializer): ) class Meta: - model = User + model = get_user_model() fields = ( 'id', 'url', 'display', 'username', 'password', 'first_name', 'last_name', 'email', 'is_staff', 'is_active', 'date_joined', 'groups', @@ -124,7 +125,7 @@ class ObjectPermissionSerializer(ValidatedModelSerializer): many=True ) users = SerializedPKRelatedField( - queryset=User.objects.all(), + queryset=get_user_model().objects.all(), serializer=NestedUserSerializer, required=False, many=True diff --git a/netbox/users/api/views.py b/netbox/users/api/views.py index 04b3ae336..4a8e1b154 100644 --- a/netbox/users/api/views.py +++ b/netbox/users/api/views.py @@ -1,5 +1,6 @@ from django.contrib.auth import authenticate -from django.contrib.auth.models import Group, User +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group from django.db.models import Count from drf_spectacular.utils import extend_schema from drf_spectacular.types import OpenApiTypes @@ -32,7 +33,7 @@ class UsersRootView(APIRootView): # class UserViewSet(NetBoxModelViewSet): - queryset = RestrictedQuerySet(model=User).prefetch_related('groups').order_by('username') + queryset = RestrictedQuerySet(model=get_user_model()).prefetch_related('groups').order_by('username') serializer_class = serializers.UserSerializer filterset_class = filtersets.UserFilterSet diff --git a/netbox/users/filtersets.py b/netbox/users/filtersets.py index 4ae9df89a..44ad98cc2 100644 --- a/netbox/users/filtersets.py +++ b/netbox/users/filtersets.py @@ -1,5 +1,6 @@ import django_filters -from django.contrib.auth.models import Group, User +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group from django.db.models import Q from django.utils.translation import gettext as _ @@ -47,7 +48,7 @@ class UserFilterSet(BaseFilterSet): ) class Meta: - model = User + model = get_user_model() fields = ['id', 'username', 'first_name', 'last_name', 'email', 'is_staff', 'is_active'] def search(self, queryset, name, value): @@ -68,12 +69,12 @@ class TokenFilterSet(BaseFilterSet): ) user_id = django_filters.ModelMultipleChoiceFilter( field_name='user', - queryset=User.objects.all(), + queryset=get_user_model().objects.all(), label=_('User'), ) user = django_filters.ModelMultipleChoiceFilter( field_name='user__username', - queryset=User.objects.all(), + queryset=get_user_model().objects.all(), to_field_name='username', label=_('User (name)'), ) @@ -116,12 +117,12 @@ class ObjectPermissionFilterSet(BaseFilterSet): ) user_id = django_filters.ModelMultipleChoiceFilter( field_name='users', - queryset=User.objects.all(), + queryset=get_user_model().objects.all(), label=_('User'), ) user = django_filters.ModelMultipleChoiceFilter( field_name='users__username', - queryset=User.objects.all(), + queryset=get_user_model().objects.all(), to_field_name='username', label=_('User (name)'), ) diff --git a/netbox/users/graphql/schema.py b/netbox/users/graphql/schema.py index 3b04d8418..f033a535a 100644 --- a/netbox/users/graphql/schema.py +++ b/netbox/users/graphql/schema.py @@ -1,6 +1,7 @@ import graphene -from django.contrib.auth.models import Group, User +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group from netbox.graphql.fields import ObjectField, ObjectListField from .types import * from utilities.graphql_optimizer import gql_query_optimizer @@ -17,4 +18,4 @@ class UsersQuery(graphene.ObjectType): user_list = ObjectListField(UserType) def resolve_user_list(root, info, **kwargs): - return gql_query_optimizer(User.objects.all(), info) + return gql_query_optimizer(get_user_model().objects.all(), info) diff --git a/netbox/users/graphql/types.py b/netbox/users/graphql/types.py index d948686c6..4254f1791 100644 --- a/netbox/users/graphql/types.py +++ b/netbox/users/graphql/types.py @@ -1,4 +1,5 @@ -from django.contrib.auth.models import Group, User +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group from graphene_django import DjangoObjectType from users import filtersets @@ -25,7 +26,7 @@ class GroupType(DjangoObjectType): class UserType(DjangoObjectType): class Meta: - model = User + model = get_user_model() fields = ( 'id', 'username', 'password', 'first_name', 'last_name', 'email', 'is_staff', 'is_active', 'date_joined', 'groups', @@ -34,4 +35,4 @@ class UserType(DjangoObjectType): @classmethod def get_queryset(cls, queryset, info): - return RestrictedQuerySet(model=User).restrict(info.context.user, 'view') + return RestrictedQuerySet(model=get_user_model()).restrict(info.context.user, 'view') 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/users/tests/test_api.py b/netbox/users/tests/test_api.py index 281f656d2..2de243775 100644 --- a/netbox/users/tests/test_api.py +++ b/netbox/users/tests/test_api.py @@ -1,4 +1,5 @@ -from django.contrib.auth.models import Group, User +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group from django.contrib.contenttypes.models import ContentType from django.urls import reverse @@ -7,6 +8,9 @@ from utilities.testing import APIViewTestCases, APITestCase from utilities.utils import deepmerge +User = get_user_model() + + class AppTest(APITestCase): def test_root(self): diff --git a/netbox/users/tests/test_filtersets.py b/netbox/users/tests/test_filtersets.py index 33ed7e7ba..d632687ef 100644 --- a/netbox/users/tests/test_filtersets.py +++ b/netbox/users/tests/test_filtersets.py @@ -1,6 +1,7 @@ import datetime -from django.contrib.auth.models import Group, User +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group from django.contrib.contenttypes.models import ContentType from django.test import TestCase from django.utils.timezone import make_aware @@ -10,6 +11,9 @@ from users.models import ObjectPermission, Token from utilities.testing import BaseFilterSetTests +User = get_user_model() + + class UserTestCase(TestCase, BaseFilterSetTests): queryset = User.objects.all() filterset = filtersets.UserFilterSet diff --git a/netbox/users/tests/test_models.py b/netbox/users/tests/test_models.py index 7a2337f33..791ea8fb4 100644 --- a/netbox/users/tests/test_models.py +++ b/netbox/users/tests/test_models.py @@ -1,7 +1,10 @@ -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.test import TestCase +User = get_user_model() + + class UserConfigTest(TestCase): @classmethod diff --git a/netbox/users/tests/test_preferences.py b/netbox/users/tests/test_preferences.py index f1e947d67..203a67bdd 100644 --- a/netbox/users/tests/test_preferences.py +++ b/netbox/users/tests/test_preferences.py @@ -1,4 +1,4 @@ -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.test import override_settings from django.test.client import RequestFactory from django.urls import reverse @@ -16,6 +16,9 @@ DEFAULT_USER_PREFERENCES = { } +User = get_user_model() + + class UserPreferencesTest(TestCase): user_permissions = ['dcim.view_site'] diff --git a/netbox/utilities/rqworker.py b/netbox/utilities/rqworker.py index 5866dfee0..61f594767 100644 --- a/netbox/utilities/rqworker.py +++ b/netbox/utilities/rqworker.py @@ -1,11 +1,12 @@ from django_rq.queues import get_connection -from rq import Worker +from rq import Retry, Worker from netbox.config import get_config from netbox.constants import RQ_QUEUE_DEFAULT __all__ = ( 'get_queue_for_model', + 'get_rq_retry', 'get_workers_for_queue', ) @@ -22,3 +23,14 @@ def get_workers_for_queue(queue_name): Returns True if a worker process is currently servicing the specified queue. """ return Worker.count(get_connection(queue_name)) + + +def get_rq_retry(): + """ + If RQ_RETRY_MAX is defined and greater than zero, instantiate and return a Retry object to be + used when queuing a job. Otherwise, return None. + """ + retry_max = get_config().RQ_RETRY_MAX + retry_interval = get_config().RQ_RETRY_INTERVAL + if retry_max: + return Retry(max=retry_max, interval=retry_interval) diff --git a/netbox/utilities/templates/builtins/htmx_table.html b/netbox/utilities/templates/builtins/htmx_table.html new file mode 100644 index 000000000..7e871931c --- /dev/null +++ b/netbox/utilities/templates/builtins/htmx_table.html @@ -0,0 +1,4 @@ +
diff --git a/netbox/utilities/templatetags/builtins/tags.py b/netbox/utilities/templatetags/builtins/tags.py index cdc517b97..dc86586e7 100644 --- a/netbox/utilities/templatetags/builtins/tags.py +++ b/netbox/utilities/templatetags/builtins/tags.py @@ -1,4 +1,5 @@ from django import template +from django.http import QueryDict __all__ = ( 'badge', @@ -74,3 +75,22 @@ def checkmark(value, show_false=True, true='Yes', false='No'): 'true_label': true, 'false_label': false, } + + +@register.inclusion_tag('builtins/htmx_table.html', takes_context=True) +def htmx_table(context, viewname, return_url=None, **kwargs): + """ + Embed an object list table retrieved using HTMX. Any extra keyword arguments are passed as URL query parameters. + + Args: + context: The current request context + viewname: The name of the view to use for the HTMX request (e.g. `dcim:site_list`) + return_url: The URL to pass as the `return_url`. If not provided, the current request's path will be used. + """ + url_params = QueryDict(mutable=True) + url_params.update(kwargs) + url_params['return_url'] = return_url or context['request'].path + return { + 'viewname': viewname, + 'url_params': url_params, + } diff --git a/netbox/utilities/testing/api.py b/netbox/utilities/testing/api.py index 7f24c86b8..8cfe1cdd7 100644 --- a/netbox/utilities/testing/api.py +++ b/netbox/utilities/testing/api.py @@ -2,7 +2,7 @@ import inspect import json from django.conf import settings -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.urls import reverse from django.test import override_settings @@ -26,6 +26,9 @@ __all__ = ( ) +User = get_user_model() + + # # REST/GraphQL API Tests # diff --git a/netbox/utilities/testing/base.py b/netbox/utilities/testing/base.py index 04ceca1e2..76a9fac06 100644 --- a/netbox/utilities/testing/base.py +++ b/netbox/utilities/testing/base.py @@ -1,6 +1,6 @@ import json -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.fields import ArrayField from django.core.exceptions import FieldDoesNotExist @@ -27,7 +27,7 @@ class TestCase(_TestCase): def setUp(self): # Create the test user and assign permissions - self.user = User.objects.create_user(username='testuser') + self.user = get_user_model().objects.create_user(username='testuser') self.add_permissions(*self.user_permissions) # Initialize the test client diff --git a/netbox/utilities/testing/utils.py b/netbox/utilities/testing/utils.py index 52ccd002d..87fc3319c 100644 --- a/netbox/utilities/testing/utils.py +++ b/netbox/utilities/testing/utils.py @@ -2,7 +2,8 @@ import logging import re from contextlib import contextmanager -from django.contrib.auth.models import Permission, User +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Permission from django.utils.text import slugify from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site @@ -63,7 +64,7 @@ def create_test_user(username='testuser', permissions=None): """ Create a User with the given permissions. """ - user = User.objects.create_user(username=username) + user = get_user_model().objects.create_user(username=username) if permissions is None: permissions = () for perm_name in permissions: diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index b1504e62f..4b4a2631a 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -302,7 +302,7 @@ def to_meters(length, unit): if unit == CableLengthUnitChoices.UNIT_FOOT: return length * Decimal(0.3048) if unit == CableLengthUnitChoices.UNIT_INCH: - return length * Decimal(0.3048) * 12 + return length * Decimal(0.0254) raise ValueError(f"Unknown unit {unit}. Must be 'km', 'm', 'cm', 'mi', 'ft', or 'in'.") diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 43ca9a589..589b71f50 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -178,7 +178,7 @@ def register_model_view(model, name='', path=None, kwargs=None): This decorator can be used to "attach" a view to any model in NetBox. This is typically used to inject additional tabs within a model's detail view. For example, to add a custom tab to NetBox's dcim.Site model: - @netbox_model_view(Site, 'myview', path='my-custom-view') + @register_model_view(Site, 'myview', path='my-custom-view') class MyView(ObjectView): ... diff --git a/netbox/virtualization/forms/bulk_import.py b/netbox/virtualization/forms/bulk_import.py index a229bd935..15651f2ae 100644 --- a/netbox/virtualization/forms/bulk_import.py +++ b/netbox/virtualization/forms/bulk_import.py @@ -65,7 +65,7 @@ class ClusterImportForm(NetBoxModelImportForm): class Meta: model = Cluster - fields = ('name', 'type', 'group', 'status', 'site', 'description', 'comments', 'tags') + fields = ('name', 'type', 'group', 'status', 'site', 'tenant', 'description', 'comments', 'tags') class VirtualMachineImportForm(NetBoxModelImportForm): diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 4a501e14e..75e83f9e1 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -415,7 +415,6 @@ class VMInterfaceListView(generic.ObjectListView): filterset = filtersets.VMInterfaceFilterSet filterset_form = forms.VMInterfaceFilterForm table = tables.VMInterfaceTable - actions = ('import', 'export', 'bulk_edit', 'bulk_delete') @register_model_view(VMInterface) diff --git a/requirements.txt b/requirements.txt index c3d9c8c38..2ffcd852b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,37 +1,37 @@ bleach==6.0.0 -boto3==1.26.127 -Django==4.1.9 -django-cors-headers==3.14.0 -django-debug-toolbar==4.0.0 +boto3==1.26.156 +Django==4.2.2 +django-cors-headers==4.1.0 +django-debug-toolbar==4.1.0 django-filter==23.2 django-graphiql-debug-toolbar==0.2.0 django-mptt==0.14 django-pglocks==1.0.4 django-prometheus==2.3.1 -django-redis==5.2.0 -django-rich==1.5.0 -django-rq==2.8.0 +django-redis==5.3.0 +django-rich==1.6.0 +django-rq==2.8.1 django-tables2==2.5.3 django-taggit==4.0.0 -django-timezone-field==5.0 +django-timezone-field==5.1 djangorestframework==3.14.0 drf-spectacular==0.26.2 -drf-spectacular-sidecar==2023.5.1 +drf-spectacular-sidecar==2023.6.1 dulwich==0.21.5 feedparser==6.0.10 graphene-django==3.0.0 gunicorn==20.1.0 Jinja2==3.1.2 Markdown==3.3.7 -mkdocs-material==9.1.9 -mkdocstrings[python-legacy]==0.21.2 +mkdocs-material==9.1.16 +mkdocstrings[python-legacy]==0.22.0 netaddr==0.8.0 Pillow==9.5.0 -psycopg2-binary==2.9.6 +psycopg[binary,pool]==3.1.9 PyYAML==6.0 -sentry-sdk==1.22.1 +sentry-sdk==1.25.1 social-auth-app-django==5.2.0 social-auth-core[openidconnect]==4.4.2 svgwrite==1.4.3 -tablib==3.4.0 +tablib==3.5.0 tzdata==2023.3
MAC Address{{ object.mac_address|placeholder }}{{ object.mac_address|placeholder }}
802.1Q Mode