diff --git a/docs/customization/custom-validation.md b/docs/customization/custom-validation.md index 909846e20..4a2aab998 100644 --- a/docs/customization/custom-validation.md +++ b/docs/customization/custom-validation.md @@ -86,8 +86,6 @@ CUSTOM_VALIDATORS = { #### Referencing Related Object Attributes -!!! info "This feature was introduced in NetBox v4.0." - The attributes of a related object can be referenced by specifying a dotted path. For example, to reference the name of a region to which a site is assigned, use `region.name`: ```python @@ -104,8 +102,6 @@ CUSTOM_VALIDATORS = { #### Validating Request Parameters -!!! info "This feature was introduced in NetBox v4.0." - In addition to validating object attributes, custom validators can also match against parameters of the current request (where available). For example, the following rule will permit only the user named "admin" to modify an object: ```json diff --git a/docs/features/event-rules.md b/docs/features/event-rules.md index 158dc111a..14b54f000 100644 --- a/docs/features/event-rules.md +++ b/docs/features/event-rules.md @@ -1,9 +1,10 @@ # Event Rules -NetBox includes the ability to execute certain functions in response to internal object changes. These include: +NetBox includes the ability to automatically perform certain functions in response to internal events. These include: -* [Scripts](../customization/custom-scripts.md) execution -* [Webhooks](../integrations/webhooks.md) execution +* Executing a [custom script](../customization/custom-scripts.md) +* Sending a [webhook](../integrations/webhooks.md) +* Generating [user notifications](../features/notifications.md) For example, suppose you want to automatically configure a monitoring system to start monitoring a device when its operational status is changed to active, and remove it from monitoring for any other status. You can create a webhook in NetBox for the device model and craft its content and destination URL to effect the desired change on the receiving system. You can then associate an event rule with this webhook and the webhook will be sent automatically by NetBox whenever the configured constraints are met. diff --git a/docs/features/notifications.md b/docs/features/notifications.md new file mode 100644 index 000000000..a28a17947 --- /dev/null +++ b/docs/features/notifications.md @@ -0,0 +1,10 @@ +# Notifications + +!!! info "This feature was introduced in NetBox v4.1." + +NetBox includes a system for generating user notifications, which can be marked as read or deleted by individual users. There are two built-in mechanisms for generating a notification: + +* A user can subscribe to an object. When that object is modified, a notification is created to inform the user of the change. +* An [event rule](./event-rules.md) can be defined to automatically generate a notification for one or more users in response to specific system events. + +Additionally, NetBox plugins can generate notifications for their own purposes. diff --git a/docs/models/circuits/circuitgroup.md b/docs/models/circuits/circuitgroup.md index 6d1503509..faa9dbc14 100644 --- a/docs/models/circuits/circuitgroup.md +++ b/docs/models/circuits/circuitgroup.md @@ -1,5 +1,7 @@ # Circuit Groups +!!! info "This feature was introduced in NetBox v4.1." + [Circuits](./circuit.md) can be arranged into administrative groups for organization. The assignment of a circuit to a group is optional. ## Fields diff --git a/docs/models/dcim/moduletype.md b/docs/models/dcim/moduletype.md index 7077e16c2..225873d61 100644 --- a/docs/models/dcim/moduletype.md +++ b/docs/models/dcim/moduletype.md @@ -42,4 +42,6 @@ The numeric weight of the module, including a unit designation (e.g. 3 kilograms ### Airflow +!!! info "The `airflow` field was introduced in NetBox v4.1." + The direction in which air circulates through the device chassis for cooling. diff --git a/docs/models/dcim/racktype.md b/docs/models/dcim/racktype.md index 476dbe178..04dd63eee 100644 --- a/docs/models/dcim/racktype.md +++ b/docs/models/dcim/racktype.md @@ -1,5 +1,7 @@ # Rack Types +!!! info "This feature was introduced in NetBox v4.1." + A rack type defines the physical characteristics of a particular model of [rack](./rack.md). ## Fields @@ -8,9 +10,13 @@ A rack type defines the physical characteristics of a particular model of [rack] The [manufacturer](./manufacturer.md) which produces this type of rack. -### Name +### Model -The unique name of the rack type. +The model number assigned to this rack type by its manufacturer. Must be unique to the manufacturer. + +### Slug + +A unique URL-friendly representation of the model identifier. (This value can be used for filtering.) ### Form Factor diff --git a/docs/models/extras/customfield.md b/docs/models/extras/customfield.md index 164ce3a74..626f320be 100644 --- a/docs/models/extras/customfield.md +++ b/docs/models/extras/customfield.md @@ -44,6 +44,8 @@ For object and multiple-object fields only. Designates the type of NetBox object ### Related Object Filter +!!! info "This field was introduced in NetBox v4.1." + For object and multi-object custom fields, a filter may be defined to limit the available objects when populating a field value. This filter maps object attributes to values. For example, `{"status": "active"}` will include only objects with a status of "active." !!! warning diff --git a/docs/models/extras/eventrule.md b/docs/models/extras/eventrule.md index c105a2630..b48e17a1e 100644 --- a/docs/models/extras/eventrule.md +++ b/docs/models/extras/eventrule.md @@ -18,17 +18,22 @@ The type(s) of object in NetBox that will trigger the rule. If not selected, the event rule will not be processed. -### Events +### Events Types -The events which will trigger the rule. At least one event type must be selected. +The event types which will trigger the rule. At least one event type must be selected. -| Name | Description | -|------------|--------------------------------------| -| Creations | A new object has been created | -| Updates | An existing object has been modified | -| Deletions | An object has been deleted | -| Job starts | A job for an object starts | -| Job ends | A job for an object terminates | +| Name | Description | +|----------------|---------------------------------------------| +| Object created | A new object has been created | +| Object updated | An existing object has been modified | +| Object deleted | An object has been deleted | +| Job started | A background job is initiated | +| Job completed | A background job completes successfully | +| Job failed | A background job fails | +| Job errored | A background job is aborted due to an error | + +!!! tip "Custom Event Types" + The above list includes only built-in event types. NetBox plugins can also register their own custom event types. ### Conditions diff --git a/docs/models/ipam/vlangroup.md b/docs/models/ipam/vlangroup.md index 67050ab4c..20989452f 100644 --- a/docs/models/ipam/vlangroup.md +++ b/docs/models/ipam/vlangroup.md @@ -16,6 +16,8 @@ A unique URL-friendly identifier. (This value can be used for filtering.) ### VLAN ID Ranges +!!! info "This field replaced the legacy `min_vid` and `max_vid` fields in NetBox v4.1." + The set of VLAN IDs which are encompassed by the group. By default, this will be the entire range of valid IEEE 802.1Q VLAN IDs (1 to 4094, inclusive). VLANs created within a group must have a VID that falls within one of these ranges. Ranges may not overlap. ### Scope diff --git a/docs/models/virtualization/virtualmachine.md b/docs/models/virtualization/virtualmachine.md index 7a801ca65..7ea31111c 100644 --- a/docs/models/virtualization/virtualmachine.md +++ b/docs/models/virtualization/virtualmachine.md @@ -50,9 +50,13 @@ The amount of running memory provisioned, in megabytes. ### Disk -The amount of disk storage provisioned, in gigabytes. +The amount of disk storage provisioned, in megabytes. + +!!! warning + This field may be directly modified only on virtual machines which do not define discrete [virtual disks](./virtualdisk.md). Otherwise, it will report the sum of all attached disks. ### Serial Number -Optional serial number assigned to this VM. +!!! info "This field was introduced in NetBox v4.1." +Optional serial number assigned to this virtual machine. Unlike devices, uniqueness is not enforced for virtual machine serial numbers. diff --git a/docs/models/wireless/wirelesslink.md b/docs/models/wireless/wirelesslink.md index e670b69ec..7553902b0 100644 --- a/docs/models/wireless/wirelesslink.md +++ b/docs/models/wireless/wirelesslink.md @@ -20,6 +20,12 @@ The operational status of the link. Options include: The service set identifier (SSID) for the wireless link (optional). +### Distance + +!!! info "This field was introduced in NetBox v4.1." + +The distance between the link's two endpoints, including a unit designation (e.g. 100 meters or 25 feet). + ### Authentication Type The type of wireless authentication in use. Options include: @@ -40,7 +46,3 @@ The security cipher used to apply wireless authentication. Options include: ### Pre-Shared Key The security key configured on each client to grant access to the secured wireless LAN. This applies only to certain authentication types. - -### Distance - -The numeric distance of the link, including a unit designation (e.g. 100 meters or 25 feet). diff --git a/docs/plugins/development/background-jobs.md b/docs/plugins/development/background-jobs.md index e642fe585..873390a58 100644 --- a/docs/plugins/development/background-jobs.md +++ b/docs/plugins/development/background-jobs.md @@ -1,5 +1,7 @@ # Background Jobs +!!! info "This feature was introduced in NetBox v4.1." + NetBox plugins can defer certain operations by enqueuing [background jobs](../../features/background-jobs.md), which are executed asynchronously by background workers. This is helpful for decoupling long-running processes from the user-facing request-response cycle. For example, your plugin might need to fetch data from a remote system. Depending on the amount of data and the responsiveness of the remote server, this could take a few minutes. Deferring this task to a queued job ensures that it can be completed in the background, without interrupting the user. The data it fetches can be made available once the job has completed. @@ -8,12 +10,12 @@ For example, your plugin might need to fetch data from a remote system. Dependin A background job implements a basic [Job](../../models/core/job.md) executor for all kinds of tasks. It has logic implemented to handle the management of the associated job object, rescheduling of periodic jobs in the given interval and error handling. Adding custom jobs is done by subclassing NetBox's `JobRunner` class. -::: utilities.jobs.JobRunner +::: netbox.jobs.JobRunner #### Example ```python title="jobs.py" -from utilities.jobs import JobRunner +from netbox.jobs import JobRunner class MyTestJob(JobRunner): @@ -45,7 +47,7 @@ As described above, jobs can be scheduled for immediate execution or at any late #### Example ```python title="jobs.py" -from utilities.jobs import JobRunner +from netbox.jobs import JobRunner class MyHousekeepingJob(JobRunner): diff --git a/docs/plugins/development/events.md b/docs/plugins/development/event-types.md similarity index 52% rename from docs/plugins/development/events.md rename to docs/plugins/development/event-types.md index f41ed7ff5..4bcdeea31 100644 --- a/docs/plugins/development/events.md +++ b/docs/plugins/development/event-types.md @@ -1,16 +1,18 @@ -# Events +# Event Types -Plugins can register their own custom event types for use with NetBox [event rules](../../models/extras/eventrule.md). This is accomplished by calling the `register()` method on an instance of the `Event` class. This can be done anywhere within the plugin. An example is provided below. +!!! info "This feature was introduced in NetBox v4.1." + +Plugins can register their own custom event types for use with NetBox [event rules](../../models/extras/eventrule.md). This is accomplished by calling the `register()` method on an instance of the `EventType` class. This can be done anywhere within the plugin. An example is provided below. ```python from django.utils.translation import gettext_lazy as _ -from netbox.events import Event, EVENT_TYPE_SUCCESS +from netbox.events import EventType, EVENT_TYPE_KIND_SUCCESS -Event( +EventType( name='ticket_opened', text=_('Ticket opened'), - type=EVENT_TYPE_SUCCESS + kind=EVENT_TYPE_KIND_SUCCESS ).register() ``` -::: netbox.events.Event +::: netbox.events.EventType diff --git a/docs/plugins/development/views.md b/docs/plugins/development/views.md index cbf920ad5..1f5f164fd 100644 --- a/docs/plugins/development/views.md +++ b/docs/plugins/development/views.md @@ -203,7 +203,7 @@ Plugins can inject custom content into certain areas of core NetBox views. This | `right_page()` | Object view | Inject content on the right side of the page | | `full_width_page()` | Object view | Inject content across the entire bottom of the page | -!!! info "The `navbar()` method was introduced in NetBox v4.1." +!!! info "The `navbar()` and `alerts()` methods were introduced in NetBox v4.1." Additionally, a `render()` method is available for convenience. This method accepts the name of a template to render, and any additional context data you want to pass. Its use is optional, however. diff --git a/docs/release-notes/version-4.1.md b/docs/release-notes/version-4.1.md index 38660447f..dd846269d 100644 --- a/docs/release-notes/version-4.1.md +++ b/docs/release-notes/version-4.1.md @@ -5,8 +5,9 @@ ### Breaking Changes * Several filters deprecated in v4.0 have been removed (see [#15410](https://github.com/netbox-community/netbox/issues/15410)). -* The unit size for virtual disk size has been changed from 1 gigabyte to 1 megabyte. Existing values have been updated accordingly. +* The unit size for `VirtualMachine.disk` and `VirtualDisk.size` been changed from 1 gigabyte to 1 megabyte. Existing values have been updated accordingly. * The `min_vid` and `max_vid` fields on the VLAN group model have been replaced with `vid_ranges`, an array of starting and ending integer pairs. +* The five individual event type fields on the EventRule model have been replaced by a single `event_types` array field, indicating each assigned event type by name. * The `validate()` method on CustomValidator subclasses now **must** accept the request argument (deprecated in v4.0 by #14279). ### New Features @@ -72,6 +73,9 @@ NetBox now includes a user notification system. Users can subscribe to individua * Added the optional `airflow` choice field * extras.CustomField * Added the `related_object_filter` JSON field for object and multi-object custom fields +* extras.EventRule + * Removed the `type_create`, `type_update`, `type_delete`, `type_job_start`, and `type_job_end` boolean fields + * Added the `event_types` array field * ipam.VLANGroup * Removed the `min_vid` and `max_vid` fields * Added the `vid_ranges` field, and array of starting & ending VLAN IDs diff --git a/mkdocs.yml b/mkdocs.yml index ea93ee584..072c564e8 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -86,6 +86,7 @@ nav: - Change Logging: 'features/change-logging.md' - Journaling: 'features/journaling.md' - Event Rules: 'features/event-rules.md' + - Notifications: 'features/notifications.md' - Background Jobs: 'features/background-jobs.md' - Auth & Permissions: 'features/authentication-permissions.md' - API & Integration: 'features/api-integration.md' @@ -142,7 +143,7 @@ nav: - Forms: 'plugins/development/forms.md' - Filters & Filter Sets: 'plugins/development/filtersets.md' - Search: 'plugins/development/search.md' - - Events: 'plugins/development/events.md' + - Event Types: 'plugins/development/event-types.md' - Data Backends: 'plugins/development/data-backends.md' - REST API: 'plugins/development/rest-api.md' - GraphQL API: 'plugins/development/graphql-api.md' diff --git a/netbox/core/events.py b/netbox/core/events.py index 4855e5b23..98a1ea18f 100644 --- a/netbox/core/events.py +++ b/netbox/core/events.py @@ -1,6 +1,6 @@ from django.utils.translation import gettext as _ -from netbox.events import Event, EVENT_TYPE_DANGER, EVENT_TYPE_SUCCESS, EVENT_TYPE_WARNING +from netbox.events import EventType, EVENT_TYPE_KIND_DANGER, EVENT_TYPE_KIND_SUCCESS, EVENT_TYPE_KIND_WARNING __all__ = ( 'JOB_COMPLETED', @@ -24,10 +24,10 @@ JOB_FAILED = 'job_failed' JOB_ERRORED = 'job_errored' # Register core events -Event(OBJECT_CREATED, _('Object created')).register() -Event(OBJECT_UPDATED, _('Object updated')).register() -Event(OBJECT_DELETED, _('Object deleted')).register() -Event(JOB_STARTED, _('Job started')).register() -Event(JOB_COMPLETED, _('Job completed'), type=EVENT_TYPE_SUCCESS).register() -Event(JOB_FAILED, _('Job failed'), type=EVENT_TYPE_WARNING).register() -Event(JOB_ERRORED, _('Job errored'), type=EVENT_TYPE_DANGER).register() +EventType(OBJECT_CREATED, _('Object created')).register() +EventType(OBJECT_UPDATED, _('Object updated')).register() +EventType(OBJECT_DELETED, _('Object deleted')).register() +EventType(JOB_STARTED, _('Job started')).register() +EventType(JOB_COMPLETED, _('Job completed'), kind=EVENT_TYPE_KIND_SUCCESS).register() +EventType(JOB_FAILED, _('Job failed'), kind=EVENT_TYPE_KIND_WARNING).register() +EventType(JOB_ERRORED, _('Job errored'), kind=EVENT_TYPE_KIND_DANGER).register() diff --git a/netbox/core/filtersets.py b/netbox/core/filtersets.py index f622e789c..21fdaa4ab 100644 --- a/netbox/core/filtersets.py +++ b/netbox/core/filtersets.py @@ -1,4 +1,3 @@ -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 _ @@ -7,6 +6,7 @@ import django_filters from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet from netbox.utils import get_data_backend_choices +from users.models import User from utilities.filters import ContentTypeFilter from .choices import * from .models import * @@ -141,12 +141,12 @@ class ObjectChangeFilterSet(BaseFilterSet): queryset=ContentType.objects.all() ) user_id = django_filters.ModelMultipleChoiceFilter( - queryset=get_user_model().objects.all(), + queryset=User.objects.all(), label=_('User (ID)'), ) user = django_filters.ModelMultipleChoiceFilter( field_name='user__username', - queryset=get_user_model().objects.all(), + queryset=User.objects.all(), to_field_name='username', label=_('User name'), ) diff --git a/netbox/core/forms/filtersets.py b/netbox/core/forms/filtersets.py index c629841ae..ab4b869b7 100644 --- a/netbox/core/forms/filtersets.py +++ b/netbox/core/forms/filtersets.py @@ -1,5 +1,4 @@ from django import forms -from django.contrib.auth import get_user_model from django.utils.translation import gettext_lazy as _ from core.choices import * @@ -7,6 +6,7 @@ from core.models import * from netbox.forms import NetBoxModelFilterSetForm from netbox.forms.mixins import SavedFiltersMixin from netbox.utils import get_data_backend_choices +from users.models import User from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice from utilities.forms.fields import ( ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, @@ -121,7 +121,7 @@ class JobFilterForm(SavedFiltersMixin, FilterForm): widget=DateTimePicker() ) user = DynamicModelMultipleChoiceField( - queryset=get_user_model().objects.all(), + queryset=User.objects.all(), required=False, label=_('User') ) @@ -150,7 +150,7 @@ class ObjectChangeFilterForm(SavedFiltersMixin, FilterForm): required=False ) user_id = DynamicModelMultipleChoiceField( - queryset=get_user_model().objects.all(), + queryset=User.objects.all(), required=False, label=_('User') ) diff --git a/netbox/core/jobs.py b/netbox/core/jobs.py index 1c38cf61f..d2b846398 100644 --- a/netbox/core/jobs.py +++ b/netbox/core/jobs.py @@ -1,7 +1,7 @@ import logging +from netbox.jobs import JobRunner from netbox.search.backends import search_backend -from utilities.jobs import JobRunner from .choices import DataSourceStatusChoices from .exceptions import SyncError from .models import DataSource diff --git a/netbox/core/management/commands/nbshell.py b/netbox/core/management/commands/nbshell.py index 7270c005a..e26f956c8 100644 --- a/netbox/core/management/commands/nbshell.py +++ b/netbox/core/management/commands/nbshell.py @@ -5,10 +5,10 @@ import sys from django import get_version from django.apps import apps from django.conf import settings -from django.contrib.auth import get_user_model from django.core.management.base import BaseCommand from core.models import ObjectType +from users.models import User APPS = ('circuits', 'core', 'dcim', 'extras', 'ipam', 'tenancy', 'users', 'virtualization', 'vpn', 'wireless') @@ -61,7 +61,7 @@ class Command(BaseCommand): # Additional objects to include namespace['ObjectType'] = ObjectType - namespace['User'] = get_user_model() + namespace['User'] = User # Load convenience commands namespace.update({ diff --git a/netbox/core/models/data.py b/netbox/core/models/data.py index a8e90ec3f..97f9fdac7 100644 --- a/netbox/core/models/data.py +++ b/netbox/core/models/data.py @@ -21,7 +21,6 @@ from netbox.registry import registry from utilities.querysets import RestrictedQuerySet from ..choices import * from ..exceptions import SyncError -from ..signals import post_sync, pre_sync __all__ = ( 'AutoSyncRecord', @@ -159,6 +158,8 @@ class DataSource(JobsMixin, PrimaryModel): """ Create/update/delete child DataFiles as necessary to synchronize with the remote source. """ + from core.signals import post_sync, pre_sync + if self.status == DataSourceStatusChoices.SYNCING: raise SyncError(_("Cannot initiate sync; syncing already in progress.")) diff --git a/netbox/core/models/jobs.py b/netbox/core/models/jobs.py index 1d0e7fdeb..4a327a1d8 100644 --- a/netbox/core/models/jobs.py +++ b/netbox/core/models/jobs.py @@ -214,7 +214,7 @@ class Job(models.Model): management commands only. """ if schedule_at and immediate: - raise ValueError("enqueue() cannot be called with values for both schedule_at and immediate.") + raise ValueError(_("enqueue() cannot be called with values for both schedule_at and immediate.")) if instance: object_type = ObjectType.objects.get_for_model(instance, for_concrete_model=False) diff --git a/netbox/core/plugins.py b/netbox/core/plugins.py index 374cfbe9a..9fac88005 100644 --- a/netbox/core/plugins.py +++ b/netbox/core/plugins.py @@ -7,12 +7,12 @@ from typing import Optional import requests from django.conf import settings from django.core.cache import cache -from django.utils.translation import gettext_lazy as _ from netbox.plugins import PluginConfig from utilities.datetime import datetime_from_timestamp USER_AGENT_STRING = f'NetBox/{settings.RELEASE.version} {settings.RELEASE.edition}' +CACHE_KEY_CATALOG_FEED = 'plugins-catalog-feed' @dataclass @@ -68,16 +68,19 @@ class Plugin: installed_version: str = '' -def get_local_plugins(): +def get_local_plugins(plugins=None): """ Return a dictionary of all locally-installed plugins, mapped by name. """ - plugins = {} + plugins = plugins or {} + local_plugins = {} + + # Gather all locally-installed plugins for plugin_name in settings.PLUGINS: plugin = importlib.import_module(plugin_name) plugin_config: PluginConfig = plugin.config - plugins[plugin_config.name] = Plugin( + local_plugins[plugin_config.name] = Plugin( slug=plugin_config.name, title_short=plugin_config.verbose_name, tag_line=plugin_config.description, @@ -87,6 +90,14 @@ def get_local_plugins(): installed_version=plugin_config.version, ) + # Update catalog entries for local plugins, or add them to the list if not listed + for k, v in local_plugins.items(): + if k in plugins: + plugins[k].is_local = True + plugins[k].is_installed = True + else: + plugins[k] = v + return plugins @@ -95,7 +106,6 @@ def get_catalog_plugins(): Return a dictionary of all entries in the plugins catalog, mapped by name. """ session = requests.Session() - plugins = {} def get_pages(): # TODO: pagination is currently broken in API @@ -121,88 +131,80 @@ def get_catalog_plugins(): ).json() yield next_page - for page in get_pages(): - for data in page['data']: + def make_plugin_dict(): + plugins = {} - # Populate releases - releases = [] - for version in data['release_recent_history']: - releases.append( - PluginVersion( - date=datetime_from_timestamp(version['date']), - version=version['version'], - netbox_min_version=version['netbox_min_version'], - netbox_max_version=version['netbox_max_version'], - has_model=version['has_model'], - is_certified=version['is_certified'], - is_feature=version['is_feature'], - is_integration=version['is_integration'], - is_netboxlabs_supported=version['is_netboxlabs_supported'], + for page in get_pages(): + for data in page['data']: + + # Populate releases + releases = [] + for version in data['release_recent_history']: + releases.append( + PluginVersion( + date=datetime_from_timestamp(version['date']), + version=version['version'], + netbox_min_version=version['netbox_min_version'], + netbox_max_version=version['netbox_max_version'], + has_model=version['has_model'], + is_certified=version['is_certified'], + is_feature=version['is_feature'], + is_integration=version['is_integration'], + is_netboxlabs_supported=version['is_netboxlabs_supported'], + ) ) + releases = sorted(releases, key=lambda x: x.date, reverse=True) + latest_release = PluginVersion( + date=datetime_from_timestamp(data['release_latest']['date']), + version=data['release_latest']['version'], + netbox_min_version=data['release_latest']['netbox_min_version'], + netbox_max_version=data['release_latest']['netbox_max_version'], + has_model=data['release_latest']['has_model'], + is_certified=data['release_latest']['is_certified'], + is_feature=data['release_latest']['is_feature'], + is_integration=data['release_latest']['is_integration'], + is_netboxlabs_supported=data['release_latest']['is_netboxlabs_supported'], ) - releases = sorted(releases, key=lambda x: x.date, reverse=True) - latest_release = PluginVersion( - date=datetime_from_timestamp(data['release_latest']['date']), - version=data['release_latest']['version'], - netbox_min_version=data['release_latest']['netbox_min_version'], - netbox_max_version=data['release_latest']['netbox_max_version'], - has_model=data['release_latest']['has_model'], - is_certified=data['release_latest']['is_certified'], - is_feature=data['release_latest']['is_feature'], - is_integration=data['release_latest']['is_integration'], - is_netboxlabs_supported=data['release_latest']['is_netboxlabs_supported'], - ) - # Populate author (if any) - if data['author']: - author = PluginAuthor( - name=data['author']['name'], - org_id=data['author']['org_id'], - url=data['author']['url'], + # Populate author (if any) + if data['author']: + author = PluginAuthor( + name=data['author']['name'], + org_id=data['author']['org_id'], + url=data['author']['url'], + ) + else: + author = None + + # Populate plugin data + plugins[data['slug']] = Plugin( + id=data['id'], + status=data['status'], + title_short=data['title_short'], + title_long=data['title_long'], + tag_line=data['tag_line'], + description_short=data['description_short'], + slug=data['slug'], + author=author, + created_at=datetime_from_timestamp(data['created_at']), + updated_at=datetime_from_timestamp(data['updated_at']), + license_type=data['license_type'], + homepage_url=data['homepage_url'], + package_name_pypi=data['package_name_pypi'], + config_name=data['config_name'], + is_certified=data['is_certified'], + release_latest=latest_release, + release_recent_history=releases, ) - else: - author = None - # Populate plugin data - plugins[data['slug']] = Plugin( - id=data['id'], - status=data['status'], - title_short=data['title_short'], - title_long=data['title_long'], - tag_line=data['tag_line'], - description_short=data['description_short'], - slug=data['slug'], - author=author, - created_at=datetime_from_timestamp(data['created_at']), - updated_at=datetime_from_timestamp(data['updated_at']), - license_type=data['license_type'], - homepage_url=data['homepage_url'], - package_name_pypi=data['package_name_pypi'], - config_name=data['config_name'], - is_certified=data['is_certified'], - release_latest=latest_release, - release_recent_history=releases, - ) + return plugins - return plugins - - -def get_plugins(): - """ - Return a dictionary of all plugins (both catalog and locally installed), mapped by name. - """ - local_plugins = get_local_plugins() - catalog_plugins = cache.get('plugins-catalog-feed') + catalog_plugins = cache.get(CACHE_KEY_CATALOG_FEED, default={}) if not catalog_plugins: - catalog_plugins = get_catalog_plugins() - cache.set('plugins-catalog-feed', catalog_plugins, 3600) + try: + catalog_plugins = make_plugin_dict() + cache.set(CACHE_KEY_CATALOG_FEED, catalog_plugins, 3600) + except requests.exceptions.RequestException: + pass - plugins = catalog_plugins - for k, v in local_plugins.items(): - if k in plugins: - plugins[k].is_local = True - plugins[k].is_installed = True - else: - plugins[k] = v - - return plugins + return catalog_plugins diff --git a/netbox/core/signals.py b/netbox/core/signals.py index f884a27b4..06432bf4c 100644 --- a/netbox/core/signals.py +++ b/netbox/core/signals.py @@ -1,9 +1,26 @@ -from django.db.models.signals import post_save -from django.dispatch import Signal, receiver +import logging +from django.contrib.contenttypes.models import ContentType +from django.core.exceptions import ValidationError +from django.db.models.fields.reverse_related import ManyToManyRel +from django.db.models.signals import m2m_changed, post_save, pre_delete +from django.dispatch import receiver, Signal +from django.utils.translation import gettext_lazy as _ +from django_prometheus.models import model_deletes, model_inserts, model_updates + +from core.choices import ObjectChangeActionChoices +from core.events import * +from core.models import ObjectChange +from extras.events import enqueue_event +from extras.utils import run_validators +from netbox.config import get_config +from netbox.context import current_request, events_queue +from netbox.models.features import ChangeLoggingMixin +from utilities.exceptions import AbortRequest from .models import ConfigRevision __all__ = ( + 'clear_events', 'job_end', 'job_start', 'post_sync', @@ -18,6 +35,152 @@ job_end = Signal() pre_sync = Signal() post_sync = Signal() +# Event signals +clear_events = Signal() + + +# +# Change logging & event handling +# + +@receiver((post_save, m2m_changed)) +def handle_changed_object(sender, instance, **kwargs): + """ + Fires when an object is created or updated. + """ + m2m_changed = False + + if not hasattr(instance, 'to_objectchange'): + return + + # Get the current request, or bail if not set + request = current_request.get() + if request is None: + return + + # Determine the type of change being made + if kwargs.get('created'): + event_type = OBJECT_CREATED + elif 'created' in kwargs: + event_type = OBJECT_UPDATED + elif kwargs.get('action') in ['post_add', 'post_remove'] and kwargs['pk_set']: + # m2m_changed with objects added or removed + m2m_changed = True + event_type = OBJECT_UPDATED + else: + return + + # Create/update an ObjectChange record for this change + action = { + OBJECT_CREATED: ObjectChangeActionChoices.ACTION_CREATE, + OBJECT_UPDATED: ObjectChangeActionChoices.ACTION_UPDATE, + OBJECT_DELETED: ObjectChangeActionChoices.ACTION_DELETE, + }[event_type] + objectchange = instance.to_objectchange(action) + # If this is a many-to-many field change, check for a previous ObjectChange instance recorded + # for this object by this request and update it + if m2m_changed and ( + prev_change := ObjectChange.objects.filter( + changed_object_type=ContentType.objects.get_for_model(instance), + changed_object_id=instance.pk, + request_id=request.id + ).first() + ): + prev_change.postchange_data = objectchange.postchange_data + prev_change.save() + elif objectchange and objectchange.has_changes: + objectchange.user = request.user + objectchange.request_id = request.id + objectchange.save() + + # Ensure that we're working with fresh M2M assignments + if m2m_changed: + instance.refresh_from_db() + + # Enqueue the object for event processing + queue = events_queue.get() + enqueue_event(queue, instance, request.user, request.id, event_type) + events_queue.set(queue) + + # Increment metric counters + if event_type == OBJECT_CREATED: + model_inserts.labels(instance._meta.model_name).inc() + elif event_type == OBJECT_UPDATED: + model_updates.labels(instance._meta.model_name).inc() + + +@receiver(pre_delete) +def handle_deleted_object(sender, instance, **kwargs): + """ + Fires when an object is deleted. + """ + # Run any deletion protection rules for the object. Note that this must occur prior + # to queueing any events for the object being deleted, in case a validation error is + # raised, causing the deletion to fail. + model_name = f'{sender._meta.app_label}.{sender._meta.model_name}' + validators = get_config().PROTECTION_RULES.get(model_name, []) + try: + run_validators(instance, validators) + except ValidationError as e: + raise AbortRequest( + _("Deletion is prevented by a protection rule: {message}").format(message=e) + ) + + # Get the current request, or bail if not set + request = current_request.get() + if request is None: + return + + # Record an ObjectChange if applicable + if hasattr(instance, 'to_objectchange'): + if hasattr(instance, 'snapshot') and not getattr(instance, '_prechange_snapshot', None): + instance.snapshot() + objectchange = instance.to_objectchange(ObjectChangeActionChoices.ACTION_DELETE) + objectchange.user = request.user + objectchange.request_id = request.id + objectchange.save() + + # Django does not automatically send an m2m_changed signal for the reverse direction of a + # many-to-many relationship (see https://code.djangoproject.com/ticket/17688), so we need to + # trigger one manually. We do this by checking for any reverse M2M relationships on the + # instance being deleted, and explicitly call .remove() on the remote M2M field to delete + # the association. This triggers an m2m_changed signal with the `post_remove` action type + # for the forward direction of the relationship, ensuring that the change is recorded. + for relation in instance._meta.related_objects: + if type(relation) is not ManyToManyRel: + continue + related_model = relation.related_model + related_field_name = relation.remote_field.name + if not issubclass(related_model, ChangeLoggingMixin): + # We only care about triggering the m2m_changed signal for models which support + # change logging + continue + for obj in related_model.objects.filter(**{related_field_name: instance.pk}): + obj.snapshot() # Ensure the change record includes the "before" state + getattr(obj, related_field_name).remove(instance) + + # Enqueue the object for event processing + queue = events_queue.get() + enqueue_event(queue, instance, request.user, request.id, OBJECT_DELETED) + events_queue.set(queue) + + # Increment metric counters + model_deletes.labels(instance._meta.model_name).inc() + + +@receiver(clear_events) +def clear_events_queue(sender, **kwargs): + """ + Delete any queued events (e.g. because of an aborted bulk transaction) + """ + logger = logging.getLogger('events') + logger.info(f"Clearing {len(events_queue.get())} queued events ({sender})") + events_queue.set({}) + + +# +# DataSource handlers +# @receiver(post_sync) def auto_sync(instance, **kwargs): diff --git a/netbox/core/views.py b/netbox/core/views.py index 06c8b00d0..cb250b68e 100644 --- a/netbox/core/views.py +++ b/netbox/core/views.py @@ -2,6 +2,7 @@ import json import platform from django import __version__ as DJANGO_VERSION +from django.apps import apps from django.conf import settings from django.contrib import messages from django.contrib.auth.mixins import UserPassesTestMixin @@ -37,7 +38,7 @@ from . import filtersets, forms, tables from .choices import DataSourceStatusChoices from .jobs import SyncDataSourceJob from .models import * -from .plugins import get_plugins +from .plugins import get_catalog_plugins, get_local_plugins from .tables import CatalogPluginTable, PluginVersionTable @@ -630,10 +631,13 @@ class SystemView(UserPassesTestMixin, View): # Raw data export if 'export' in request.GET: + stats['netbox_release'] = stats['netbox_release'].asdict() + params = [param.name for param in PARAMS] data = { **stats, + 'plugins': settings.PLUGINS, 'config': { - k: config.data[k] for k in sorted(config.data) + k: getattr(config, k) for k in sorted(params) }, } response = HttpResponse(json.dumps(data, indent=4), content_type='text/json') @@ -650,15 +654,31 @@ class SystemView(UserPassesTestMixin, View): # Plugins # -class PluginListView(UserPassesTestMixin, View): +class BasePluginView(UserPassesTestMixin, View): + CACHE_KEY_CATALOG_ERROR = 'plugins-catalog-error' def test_func(self): return self.request.user.is_staff + def get_cached_plugins(self, request): + catalog_plugins = {} + catalog_plugins_error = cache.get(self.CACHE_KEY_CATALOG_ERROR, default=False) + if not catalog_plugins_error: + catalog_plugins = get_catalog_plugins() + if not catalog_plugins: + # Cache for 5 minutes to avoid spamming connection + cache.set(self.CACHE_KEY_CATALOG_ERROR, True, 300) + messages.warning(request, _("Plugins catalog could not be loaded")) + + return get_local_plugins(catalog_plugins) + + +class PluginListView(BasePluginView): + def get(self, request): q = request.GET.get('q', None) - plugins = get_plugins().values() + plugins = self.get_cached_plugins(request).values() if q: plugins = [obj for obj in plugins if q.casefold() in obj.title_short.casefold()] @@ -676,14 +696,11 @@ class PluginListView(UserPassesTestMixin, View): }) -class PluginView(UserPassesTestMixin, View): - - def test_func(self): - return self.request.user.is_staff +class PluginView(BasePluginView): def get(self, request, name): - plugins = get_plugins() + plugins = self.get_cached_plugins(request) if name not in plugins: raise Http404(_("Plugin {name} not found").format(name=name)) plugin = plugins[name] diff --git a/netbox/dcim/api/serializers_/racks.py b/netbox/dcim/api/serializers_/racks.py index 17c1c174e..4a0fe5daa 100644 --- a/netbox/dcim/api/serializers_/racks.py +++ b/netbox/dcim/api/serializers_/racks.py @@ -73,12 +73,12 @@ class RackTypeSerializer(RackBaseSerializer): class Meta: model = RackType fields = [ - 'id', 'url', 'display_url', 'display', 'manufacturer', 'name', 'slug', 'description', 'form_factor', + 'id', 'url', 'display_url', 'display', 'manufacturer', 'model', 'slug', 'description', 'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'weight', 'max_weight', 'weight_unit', 'mounting_depth', 'airflow', 'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', ] - brief_fields = ('id', 'url', 'display', 'manufacturer', 'name', 'slug', 'description') + brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'slug', 'description') class RackSerializer(RackBaseSerializer): diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index ce10680f6..1ae198165 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -1,5 +1,4 @@ import django_filters -from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.utils.translation import gettext as _ from drf_spectacular.types import OpenApiTypes @@ -16,6 +15,7 @@ from netbox.filtersets import ( ) from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet from tenancy.models import * +from users.models import User from utilities.filters import ( ContentTypeFilter, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, MultiValueWWNFilter, NumericArrayFilter, TreeNodeMultipleChoiceFilter, @@ -311,7 +311,7 @@ class RackTypeFilterSet(NetBoxModelFilterSet): class Meta: model = RackType fields = ( - 'id', 'name', 'slug', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', + 'id', 'model', 'slug', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'airflow', 'weight', 'max_weight', 'weight_unit', 'description', ) @@ -319,7 +319,7 @@ class RackTypeFilterSet(NetBoxModelFilterSet): if not value.strip(): return queryset return queryset.filter( - Q(name__icontains=value) | + Q(model__icontains=value) | Q(description__icontains=value) | Q(comments__icontains=value) ) @@ -375,6 +375,17 @@ class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe to_field_name='slug', label=_('Location (slug)'), ) + manufacturer_id = django_filters.ModelMultipleChoiceFilter( + field_name='rack_type__manufacturer', + queryset=Manufacturer.objects.all(), + label=_('Manufacturer (ID)'), + ) + manufacturer = django_filters.ModelMultipleChoiceFilter( + field_name='rack_type__manufacturer__slug', + queryset=Manufacturer.objects.all(), + to_field_name='slug', + label=_('Manufacturer (slug)'), + ) rack_type = django_filters.ModelMultipleChoiceFilter( field_name='rack_type__slug', queryset=RackType.objects.all(), @@ -486,12 +497,12 @@ class RackReservationFilterSet(NetBoxModelFilterSet, TenancyFilterSet): label=_('Location (slug)'), ) user_id = django_filters.ModelMultipleChoiceFilter( - queryset=get_user_model().objects.all(), + queryset=User.objects.all(), label=_('User (ID)'), ) user = django_filters.ModelMultipleChoiceFilter( field_name='user__username', - queryset=get_user_model().objects.all(), + queryset=User.objects.all(), to_field_name='username', label=_('User (name)'), ) diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index bc0f0bd71..c739c8384 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -1,6 +1,5 @@ from django import forms from django.conf import settings -from django.contrib.auth import get_user_model from django.utils.translation import gettext_lazy as _ from timezone_field import TimeZoneFormField @@ -11,6 +10,7 @@ from extras.models import ConfigTemplate from ipam.models import ASN, VLAN, VLANGroup, VRF from netbox.forms import NetBoxModelBulkEditForm from tenancy.models import Tenant +from users.models import User from utilities.forms import BulkEditForm, add_blank_choice, form_from_model from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField from utilities.forms.rendering import FieldSet, InlineFields @@ -459,9 +459,7 @@ class RackBulkEditForm(NetBoxModelBulkEditForm): class RackReservationBulkEditForm(NetBoxModelBulkEditForm): user = forms.ModelChoiceField( label=_('User'), - queryset=get_user_model().objects.order_by( - 'username' - ), + queryset=User.objects.order_by('username'), required=False ) tenant = DynamicModelChoiceField( diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 785b4fb42..2e537d978 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -222,7 +222,7 @@ class RackTypeImportForm(NetBoxModelImportForm): class Meta: model = RackType fields = ( - 'manufacturer', 'name', 'slug', 'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units', + 'manufacturer', 'model', 'slug', 'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'airflow', 'weight', 'max_weight', 'weight_unit', 'description', 'comments', 'tags', ) diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index c8124aa11..a8531a9e9 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -1,5 +1,4 @@ from django import forms -from django.contrib.auth import get_user_model from django.utils.translation import gettext_lazy as _ from dcim.choices import * @@ -10,6 +9,7 @@ from extras.models import ConfigTemplate from ipam.models import ASN, VRF from netbox.forms import NetBoxModelFilterSetForm from tenancy.forms import ContactModelFilterForm, TenancyFilterForm +from users.models import User from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice from utilities.forms.fields import ColorField, DynamicModelMultipleChoiceField, TagFilterField from utilities.forms.rendering import FieldSet @@ -312,8 +312,8 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, RackBaseFilterFo FieldSet('q', 'filter_id', 'tag'), FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), - FieldSet('status', 'role_id', 'serial', 'asset_tag', name=_('Rack')), - FieldSet('form_factor', 'width', 'u_height', 'airflow', name=_('Rack Type')), + FieldSet('status', 'role_id', 'manufacturer_id', 'rack_type_id', 'serial', 'asset_tag', name=_('Rack')), + FieldSet('form_factor', 'width', 'u_height', 'airflow', name=_('Hardware')), FieldSet('starting_unit', 'desc_units', name=_('Numbering')), FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')), FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')), @@ -357,6 +357,19 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, RackBaseFilterFo null_option='None', label=_('Role') ) + manufacturer_id = DynamicModelMultipleChoiceField( + queryset=Manufacturer.objects.all(), + required=False, + label=_('Manufacturer') + ) + rack_type_id = DynamicModelMultipleChoiceField( + queryset=RackType.objects.all(), + required=False, + query_params={ + 'manufacturer_id': '$manufacturer_id' + }, + label=_('Rack type') + ) serial = forms.CharField( label=_('Serial'), required=False @@ -435,7 +448,7 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): label=_('Rack') ) user_id = DynamicModelMultipleChoiceField( - queryset=get_user_model().objects.all(), + queryset=User.objects.all(), required=False, label=_('User') ) diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index f9498c4a8..d1ea12d4b 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -1,5 +1,4 @@ from django import forms -from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.utils.translation import gettext_lazy as _ from timezone_field import TimeZoneFormField @@ -11,6 +10,7 @@ from extras.models import ConfigTemplate from ipam.models import ASN, IPAddress, VLAN, VLANGroup, VRF from netbox.forms import NetBoxModelForm from tenancy.forms import TenancyForm +from users.models import User from utilities.forms import add_blank_choice, get_field_value from utilities.forms.fields import ( CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField, NumericArrayField, SlugField, @@ -208,10 +208,13 @@ class RackTypeForm(NetBoxModelForm): queryset=Manufacturer.objects.all() ) comments = CommentField() - slug = SlugField() + slug = SlugField( + label=_('Slug'), + slug_source='model' + ) fieldsets = ( - FieldSet('manufacturer', 'name', 'slug', 'description', 'form_factor', 'airflow', 'tags', name=_('Rack Type')), + FieldSet('manufacturer', 'model', 'slug', 'description', 'form_factor', 'airflow', 'tags', name=_('Rack Type')), FieldSet( 'width', 'u_height', InlineFields('outer_width', 'outer_depth', 'outer_unit', label=_('Outer Dimensions')), @@ -224,7 +227,7 @@ class RackTypeForm(NetBoxModelForm): class Meta: model = RackType fields = [ - 'manufacturer', 'name', 'slug', 'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units', + 'manufacturer', 'model', 'slug', 'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'airflow', 'description', 'comments', 'tags', ] @@ -311,9 +314,7 @@ class RackReservationForm(TenancyForm, NetBoxModelForm): ) user = forms.ModelChoiceField( label=_('User'), - queryset=get_user_model().objects.order_by( - 'username' - ) + queryset=User.objects.order_by('username') ) comments = CommentField() diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index d309c9322..bc667240f 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -613,7 +613,6 @@ class PowerPortTemplateType(ModularComponentTemplateType): filters=RackTypeFilter ) class RackTypeType(NetBoxObjectType): - _name: str manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')] diff --git a/netbox/dcim/migrations/0188_racktype.py b/netbox/dcim/migrations/0188_racktype.py index 18e4152b6..b23169093 100644 --- a/netbox/dcim/migrations/0188_racktype.py +++ b/netbox/dcim/migrations/0188_racktype.py @@ -37,14 +37,7 @@ class Migration(migrations.Migration): related_name='rack_types', to='dcim.manufacturer' )), - ('name', models.CharField(max_length=100)), - ('_name', utilities.fields.NaturalOrderingField( - 'name', - blank=True, - max_length=100, - naturalize_function=utilities.ordering.naturalize - ), - ), + ('model', models.CharField(max_length=100)), ('slug', models.SlugField(max_length=100, unique=True)), ('form_factor', models.CharField(blank=True, max_length=50)), ('width', models.PositiveSmallIntegerField(default=19)), @@ -71,7 +64,7 @@ class Migration(migrations.Migration): options={ 'verbose_name': 'racktype', 'verbose_name_plural': 'racktypes', - 'ordering': ('_name', 'pk'), + 'ordering': ('manufacturer', 'model'), }, ), migrations.RenameField( @@ -90,4 +83,16 @@ class Migration(migrations.Migration): to='dcim.racktype', ), ), + migrations.AddConstraint( + model_name='racktype', + constraint=models.UniqueConstraint( + fields=('manufacturer', 'model'), name='dcim_racktype_unique_manufacturer_model' + ), + ), + migrations.AddConstraint( + model_name='racktype', + constraint=models.UniqueConstraint( + fields=('manufacturer', 'slug'), name='dcim_racktype_unique_manufacturer_slug' + ), + ), ] diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index ad093d6b8..069fd9f98 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -221,7 +221,7 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin): return reverse('dcim:devicetype', args=[self.pk]) @property - def get_full_name(self): + def full_name(self): return f"{self.manufacturer} {self.model}" def to_yaml(self): @@ -417,6 +417,10 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin): def get_absolute_url(self): return reverse('dcim:moduletype', args=[self.pk]) + @property + def full_name(self): + return f"{self.manufacturer} {self.model}" + def to_yaml(self): data = { 'manufacturer': self.manufacturer.name, diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index cfa8f28be..68088b63a 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -136,15 +136,10 @@ class RackType(RackBase): on_delete=models.PROTECT, related_name='rack_types' ) - name = models.CharField( - verbose_name=_('name'), + model = models.CharField( + verbose_name=_('model'), max_length=100 ) - _name = NaturalOrderingField( - target_field='name', - max_length=100, - blank=True - ) slug = models.SlugField( verbose_name=_('slug'), max_length=100, @@ -152,24 +147,38 @@ class RackType(RackBase): ) clone_fields = ( - 'manufacturer', 'form_factor', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', - 'mounting_depth', 'weight', 'max_weight', 'weight_unit', + 'manufacturer', 'form_factor', 'width', 'u_height', 'airflow', 'desc_units', 'outer_width', 'outer_depth', + 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', ) prerequisite_models = ( 'dcim.Manufacturer', ) class Meta: - ordering = ('_name', 'pk') # (site, location, name) may be non-unique + ordering = ('manufacturer', 'model') + constraints = ( + models.UniqueConstraint( + fields=('manufacturer', 'model'), + name='%(app_label)s_%(class)s_unique_manufacturer_model' + ), + models.UniqueConstraint( + fields=('manufacturer', 'slug'), + name='%(app_label)s_%(class)s_unique_manufacturer_slug' + ), + ) verbose_name = _('rack type') verbose_name_plural = _('rack types') def __str__(self): - return self.name + return self.model def get_absolute_url(self): return reverse('dcim:racktype', args=[self.pk]) + @property + def full_name(self): + return f"{self.manufacturer} {self.model}" + def clean(self): super().clean() diff --git a/netbox/dcim/search.py b/netbox/dcim/search.py index 32add68d0..38c1843fe 100644 --- a/netbox/dcim/search.py +++ b/netbox/dcim/search.py @@ -246,7 +246,7 @@ class PowerPortIndex(SearchIndex): class RackTypeIndex(SearchIndex): model = models.RackType fields = ( - ('name', 100), + ('model', 100), ('description', 500), ('comments', 5000), ) diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py index 064a5a43d..a6b704161 100644 --- a/netbox/dcim/tables/racks.py +++ b/netbox/dcim/tables/racks.py @@ -50,9 +50,8 @@ class RackRoleTable(NetBoxTable): # class RackTypeTable(NetBoxTable): - name = tables.Column( - verbose_name=_('Name'), - order_by=('_name',), + model = tables.Column( + verbose_name=_('Model'), linkify=True ) manufacturer = tables.Column( @@ -84,6 +83,11 @@ class RackTypeTable(NetBoxTable): comments = columns.MarkdownColumn( verbose_name=_('Comments'), ) + instance_count = columns.LinkedCountColumn( + viewname='dcim:rack_list', + url_params={'rack_type_id': 'pk'}, + verbose_name=_('Instances') + ) tags = columns.TagColumn( url_name='dcim:rack_list' ) @@ -91,12 +95,12 @@ class RackTypeTable(NetBoxTable): class Meta(NetBoxTable.Meta): model = RackType fields = ( - 'pk', 'id', 'name', 'manufacturer', 'form_factor', 'u_height', 'starting_unit', 'width', 'outer_width', - 'outer_depth', 'mounting_depth', 'airflow', 'weight', 'max_weight', 'description', 'comments', 'tags', - 'created', 'last_updated', + 'pk', 'id', 'model', 'manufacturer', 'form_factor', 'u_height', 'starting_unit', 'width', 'outer_width', + 'outer_depth', 'mounting_depth', 'airflow', 'weight', 'max_weight', 'description', 'comments', + 'instance_count', 'tags', 'created', 'last_updated', ) default_columns = ( - 'pk', 'name', 'manufacturer', 'type', 'u_height', 'description', + 'pk', 'model', 'manufacturer', 'type', 'u_height', 'description', 'instance_count', ) @@ -124,6 +128,15 @@ class RackTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): role = columns.ColoredLabelColumn( verbose_name=_('Role'), ) + manufacturer = tables.Column( + verbose_name=_('Manufacturer'), + accessor=Accessor('rack_type__manufacturer'), + linkify=True + ) + rack_type = tables.Column( + linkify=True, + verbose_name=_('Type') + ) u_height = tables.TemplateColumn( template_code="{{ value }}U", verbose_name=_('Height') @@ -169,14 +182,14 @@ class RackTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = Rack fields = ( - 'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'tenant_group', 'role', 'serial', - 'asset_tag', 'form_factor', 'u_height', 'starting_unit', 'width', 'outer_width', 'outer_depth', - 'mounting_depth', 'airflow', 'weight', 'max_weight', 'comments', 'device_count', 'get_utilization', - 'get_power_utilization', 'description', 'contacts', 'tags', 'created', 'last_updated', + 'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'tenant_group', 'role', + 'rack_type', 'serial', 'asset_tag', 'form_factor', 'u_height', 'starting_unit', 'width', 'outer_width', + 'outer_depth', 'mounting_depth', 'airflow', 'weight', 'max_weight', 'comments', 'device_count', + 'get_utilization', 'get_power_utilization', 'description', 'contacts', 'tags', 'created', 'last_updated', ) default_columns = ( - 'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count', - 'get_utilization', + 'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'rack_type', 'u_height', + 'device_count', 'get_utilization', ) diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 8d14c077f..b690007be 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -1,4 +1,3 @@ -from django.contrib.auth import get_user_model from django.test import override_settings from django.urls import reverse from django.utils.translation import gettext as _ @@ -11,15 +10,13 @@ from extras.models import ConfigTemplate from ipam.models import ASN, RIR, VLAN, VRF from netbox.api.serializers import GenericObjectSerializer from tenancy.models import Tenant +from users.models import User from utilities.testing import APITestCase, APIViewTestCases, create_test_device from virtualization.models import Cluster, ClusterType from wireless.choices import WirelessChannelChoices from wireless.models import WirelessLAN -User = get_user_model() - - class AppTest(APITestCase): def test_root(self): @@ -276,7 +273,7 @@ class RackRoleTest(APIViewTestCases.APIViewTestCase): class RackTypeTest(APIViewTestCases.APIViewTestCase): model = RackType - brief_fields = ['description', 'display', 'id', 'manufacturer', 'name', 'slug', 'url'] + brief_fields = ['description', 'display', 'id', 'manufacturer', 'model', 'slug', 'url'] bulk_update_data = { 'description': 'new description', } @@ -290,26 +287,26 @@ class RackTypeTest(APIViewTestCases.APIViewTestCase): Manufacturer.objects.bulk_create(manufacturers) rack_types = ( - RackType(manufacturer=manufacturers[0], name='Rack Type 1', slug='rack-type-1'), - RackType(manufacturer=manufacturers[0], name='Rack Type 2', slug='rack-type-2'), - RackType(manufacturer=manufacturers[0], name='Rack Type 3', slug='rack-type-3'), + RackType(manufacturer=manufacturers[0], model='Rack Type 1', slug='rack-type-1'), + RackType(manufacturer=manufacturers[0], model='Rack Type 2', slug='rack-type-2'), + RackType(manufacturer=manufacturers[0], model='Rack Type 3', slug='rack-type-3'), ) RackType.objects.bulk_create(rack_types) cls.create_data = [ { 'manufacturer': manufacturers[1].pk, - 'name': 'Rack Type 4', + 'model': 'Rack Type 4', 'slug': 'rack-type-4', }, { 'manufacturer': manufacturers[1].pk, - 'name': 'Rack Type 5', + 'model': 'Rack Type 5', 'slug': 'rack-type-5', }, { 'manufacturer': manufacturers[1].pk, - 'name': 'Rack Type 6', + 'model': 'Rack Type 6', 'slug': 'rack-type-6', }, ] diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 2c86ae4d6..07b93dbaf 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -1,4 +1,3 @@ -from django.contrib.auth import get_user_model from django.test import TestCase from circuits.models import Circuit, CircuitTermination, CircuitType, Provider @@ -8,12 +7,11 @@ from dcim.models import * from ipam.models import ASN, IPAddress, RIR, VRF from netbox.choices import ColorChoices from tenancy.models import Tenant, TenantGroup +from users.models import User from utilities.testing import ChangeLoggedFilterSetTests, create_test_device from virtualization.models import Cluster, ClusterType, ClusterGroup from wireless.choices import WirelessChannelChoices, WirelessRoleChoices -User = get_user_model() - class DeviceComponentFilterSetTests: @@ -484,7 +482,7 @@ class RackTypeTestCase(TestCase, ChangeLoggedFilterSetTests): racks = ( RackType( manufacturer=manufacturers[0], - name='RackType 1', + model='RackType 1', slug='rack-type-1', form_factor=RackFormFactorChoices.TYPE_2POST, width=RackWidthChoices.WIDTH_19IN, @@ -502,7 +500,7 @@ class RackTypeTestCase(TestCase, ChangeLoggedFilterSetTests): ), RackType( manufacturer=manufacturers[1], - name='RackType 2', + model='RackType 2', slug='rack-type-2', form_factor=RackFormFactorChoices.TYPE_4POST, width=RackWidthChoices.WIDTH_21IN, @@ -520,7 +518,7 @@ class RackTypeTestCase(TestCase, ChangeLoggedFilterSetTests): ), RackType( manufacturer=manufacturers[2], - name='RackType 3', + model='RackType 3', slug='rack-type-3', form_factor=RackFormFactorChoices.TYPE_CABINET, width=RackWidthChoices.WIDTH_23IN, @@ -550,8 +548,8 @@ class RackTypeTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'manufacturer': [manufacturers[0].slug, manufacturers[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_name(self): - params = {'name': ['RackType 1', 'RackType 2']} + def test_model(self): + params = {'model': ['RackType 1', 'RackType 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_slug(self): @@ -653,6 +651,53 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests): for location in locations: location.save() + manufacturers = ( + Manufacturer(name='Manufacturer 1', slug='manufacturer-1'), + Manufacturer(name='Manufacturer 2', slug='manufacturer-2'), + Manufacturer(name='Manufacturer 3', slug='manufacturer-3'), + ) + Manufacturer.objects.bulk_create(manufacturers) + + rack_types = ( + RackType( + manufacturer=manufacturers[0], + model='RackType 1', + slug='rack-type-1', + form_factor=RackFormFactorChoices.TYPE_2POST, + width=RackWidthChoices.WIDTH_19IN, + u_height=42, + starting_unit=1, + desc_units=False, + outer_width=100, + outer_depth=100, + outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER, + mounting_depth=100, + weight=10, + max_weight=1000, + weight_unit=WeightUnitChoices.UNIT_POUND, + description='foobar1' + ), + RackType( + manufacturer=manufacturers[1], + model='RackType 2', + slug='rack-type-2', + form_factor=RackFormFactorChoices.TYPE_4POST, + width=RackWidthChoices.WIDTH_21IN, + u_height=43, + starting_unit=2, + desc_units=False, + outer_width=200, + outer_depth=200, + outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER, + mounting_depth=200, + weight=20, + max_weight=2000, + weight_unit=WeightUnitChoices.UNIT_POUND, + description='foobar2' + ), + ) + RackType.objects.bulk_create(rack_types) + rack_roles = ( RackRole(name='Rack Role 1', slug='rack-role-1'), RackRole(name='Rack Role 2', slug='rack-role-2'), @@ -742,6 +787,28 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests): weight_unit=WeightUnitChoices.UNIT_KILOGRAM, description='foobar3' ), + Rack( + name='Rack 4', + facility_id='rack-4', + site=sites[2], + location=locations[2], + tenant=tenants[2], + status=RackStatusChoices.STATUS_PLANNED, + role=rack_roles[2], + rack_type=rack_types[0], + description='foobar4' + ), + Rack( + name='Rack 5', + facility_id='rack-5', + site=sites[2], + location=locations[2], + tenant=tenants[2], + status=RackStatusChoices.STATUS_PLANNED, + role=rack_roles[2], + rack_type=rack_types[1], + description='foobar5' + ), ) Rack.objects.bulk_create(racks) @@ -771,15 +838,15 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests): def test_width(self): params = {'width': [RackWidthChoices.WIDTH_19IN, RackWidthChoices.WIDTH_21IN]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) def test_u_height(self): params = {'u_height': [42, 43]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) def test_starting_unit(self): params = {'starting_unit': [1]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 5) params = {'starting_unit': [2]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0) @@ -787,7 +854,7 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'desc_units': 'true'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) params = {'desc_units': 'false'} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) def test_outer_width(self): params = {'outer_width': [100, 200]} @@ -798,7 +865,7 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_outer_unit(self): - self.assertEqual(Rack.objects.filter(outer_unit__isnull=False).count(), 3) + self.assertEqual(Rack.objects.filter(outer_unit__isnull=False).count(), 5) params = {'outer_unit': RackDimensionUnitChoices.UNIT_MILLIMETER} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -832,7 +899,7 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests): def test_status(self): params = {'status': [RackStatusChoices.STATUS_ACTIVE, RackStatusChoices.STATUS_PLANNED]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) def test_role(self): roles = RackRole.objects.all()[:2] @@ -873,6 +940,20 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'weight_unit': WeightUnitChoices.UNIT_POUND} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_manufacturer(self): + manufacturers = Manufacturer.objects.all()[:2] + params = {'manufacturer_id': [manufacturers[0].pk, manufacturers[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'manufacturer': [manufacturers[0].slug, manufacturers[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_rack_type(self): + rack_types = RackType.objects.all()[:2] + params = {'rack_type_id': [rack_types[0].pk, rack_types[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'rack_type': [rack_types[0].slug, rack_types[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = RackReservation.objects.all() diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index 229edc0de..148b9e35f 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -82,7 +82,7 @@ class RackTypeTestCase(TestCase): RackType.objects.create( manufacturer=manufacturer, - name='RackType 1', + model='RackType 1', slug='rack-type-1', width=11, u_height=22, diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 04281b9fd..c8763e5b9 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -2,7 +2,6 @@ from decimal import Decimal from zoneinfo import ZoneInfo import yaml -from django.contrib.auth import get_user_model from django.test import override_settings from django.urls import reverse from netaddr import EUI @@ -13,11 +12,10 @@ from dcim.models import * from ipam.models import ASN, RIR, VLAN, VRF from netbox.choices import CSVDelimiterChoices, ImportFormatChoices from tenancy.models import Tenant +from users.models import User from utilities.testing import ViewTestCases, create_tags, create_test_device, post_data from wireless.models import WirelessLAN -User = get_user_model() - class RegionTestCase(ViewTestCases.OrganizationalObjectViewTestCase): model = Region @@ -348,9 +346,9 @@ class RackTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase): Manufacturer.objects.bulk_create(manufacturers) rack_types = ( - RackType(manufacturer=manufacturers[0], name='RackType 1', slug='rack-type-1',), - RackType(manufacturer=manufacturers[0], name='RackType 2', slug='rack-type-2',), - RackType(manufacturer=manufacturers[0], name='RackType 3', slug='rack-type-3',), + RackType(manufacturer=manufacturers[0], model='RackType 1', slug='rack-type-1',), + RackType(manufacturer=manufacturers[0], model='RackType 2', slug='rack-type-2',), + RackType(manufacturer=manufacturers[0], model='RackType 3', slug='rack-type-3',), ) RackType.objects.bulk_create(rack_types) @@ -358,7 +356,7 @@ class RackTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase): cls.form_data = { 'manufacturer': manufacturers[1].pk, - 'name': 'RackType X', + 'model': 'RackType X', 'slug': 'rack-type-x', 'type': RackFormFactorChoices.TYPE_CABINET, 'width': RackWidthChoices.WIDTH_19IN, @@ -376,14 +374,14 @@ class RackTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase): } cls.csv_data = ( - "manufacturer,name,slug,width,u_height,weight,max_weight,weight_unit", + "manufacturer,model,slug,width,u_height,weight,max_weight,weight_unit", "Manufacturer 1,RackType 4,rack-type-4,19,42,100,2000,kg", "Manufacturer 1,RackType 5,rack-type-5,19,42,100,2000,kg", "Manufacturer 1,RackType 6,rack-type-6,19,42,100,2000,kg", ) cls.csv_update_data = ( - "id,name", + "id,model", f"{rack_types[0].pk},RackType 7", f"{rack_types[1].pk},RackType 8", f"{rack_types[2].pk},RackType 9", diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index bde8de644..ac6f51e8c 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -584,7 +584,9 @@ class RackRoleBulkDeleteView(generic.BulkDeleteView): # class RackTypeListView(generic.ObjectListView): - queryset = RackType.objects.all() + queryset = RackType.objects.annotate( + instance_count=count_related(Rack, 'rack_type') + ) filterset = filtersets.RackTypeFilterSet filterset_form = forms.RackTypeFilterForm table = tables.RackTypeTable diff --git a/netbox/extras/api/serializers_/journaling.py b/netbox/extras/api/serializers_/journaling.py index 4afd3e70a..cba56fc32 100644 --- a/netbox/extras/api/serializers_/journaling.py +++ b/netbox/extras/api/serializers_/journaling.py @@ -1,4 +1,3 @@ -from django.contrib.auth import get_user_model from django.core.exceptions import ObjectDoesNotExist from drf_spectacular.utils import extend_schema_field from rest_framework import serializers @@ -8,6 +7,7 @@ from extras.choices import * from extras.models import JournalEntry from netbox.api.fields import ChoiceField, ContentTypeField from netbox.api.serializers import NetBoxModelSerializer +from users.models import User from utilities.api import get_serializer_for_model __all__ = ( @@ -22,7 +22,7 @@ class JournalEntrySerializer(NetBoxModelSerializer): assigned_object = serializers.SerializerMethodField(read_only=True) created_by = serializers.PrimaryKeyRelatedField( allow_null=True, - queryset=get_user_model().objects.all(), + queryset=User.objects.all(), required=False, default=serializers.CurrentUserDefault() ) diff --git a/netbox/extras/events.py b/netbox/extras/events.py index e80f4a558..ad603d11a 100644 --- a/netbox/extras/events.py +++ b/netbox/extras/events.py @@ -1,9 +1,7 @@ -from collections import defaultdict import logging from collections import defaultdict from django.conf import settings -from django.contrib.auth import get_user_model from django.contrib.contenttypes.models import ContentType from django.utils import timezone from django.utils.module_loading import import_string @@ -14,6 +12,7 @@ from core.events import * from netbox.config import get_config from netbox.constants import RQ_QUEUE_DEFAULT from netbox.registry import registry +from users.models import User from utilities.api import get_serializer_for_model from utilities.rqworker import get_rq_retry from utilities.serialization import serialize_object @@ -83,7 +82,7 @@ def enqueue_event(queue, instance, user, request_id, event_type): def process_event_rules(event_rules, object_type, event_type, data, username=None, snapshots=None, request_id=None): - user = get_user_model().objects.get(username=username) if username else None + user = User.objects.get(username=username) if username else None for event_rule in event_rules: diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index 3b3de0bfa..38e7dfc9d 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -1,5 +1,4 @@ import django_filters -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 _ @@ -284,12 +283,12 @@ class SavedFilterFilterSet(ChangeLoggedModelFilterSet): field_name='object_types' ) user_id = django_filters.ModelMultipleChoiceFilter( - queryset=get_user_model().objects.all(), + queryset=User.objects.all(), label=_('User (ID)'), ) user = django_filters.ModelMultipleChoiceFilter( field_name='user__username', - queryset=get_user_model().objects.all(), + queryset=User.objects.all(), to_field_name='username', label=_('User (name)'), ) @@ -328,12 +327,12 @@ class BookmarkFilterSet(BaseFilterSet): object_type_id = MultiValueNumberFilter() object_type = ContentTypeFilter() user_id = django_filters.ModelMultipleChoiceFilter( - queryset=get_user_model().objects.all(), + queryset=User.objects.all(), label=_('User (ID)'), ) user = django_filters.ModelMultipleChoiceFilter( field_name='user__username', - queryset=get_user_model().objects.all(), + queryset=User.objects.all(), to_field_name='username', label=_('User (name)'), ) @@ -410,12 +409,12 @@ class JournalEntryFilterSet(NetBoxModelFilterSet): queryset=ContentType.objects.all() ) created_by_id = django_filters.ModelMultipleChoiceFilter( - queryset=get_user_model().objects.all(), + queryset=User.objects.all(), label=_('User (ID)'), ) created_by = django_filters.ModelMultipleChoiceFilter( field_name='created_by__username', - queryset=get_user_model().objects.all(), + queryset=User.objects.all(), to_field_name='username', label=_('User (name)'), ) diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index 18f777ea8..bd3883877 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -1,5 +1,4 @@ from django import forms -from django.contrib.auth import get_user_model from django.utils.translation import gettext_lazy as _ from core.models import ObjectType, DataFile, DataSource @@ -10,7 +9,7 @@ from netbox.events import get_event_type_choices from netbox.forms.base import NetBoxModelFilterSetForm from netbox.forms.mixins import SavedFiltersMixin from tenancy.models import Tenant, TenantGroup -from users.models import Group +from users.models import Group, User from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice from utilities.forms.fields import ( ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, TagFilterField, @@ -453,7 +452,7 @@ class JournalEntryFilterForm(NetBoxModelFilterSetForm): widget=DateTimePicker() ) created_by_id = DynamicModelMultipleChoiceField( - queryset=get_user_model().objects.all(), + queryset=User.objects.all(), required=False, label=_('User') ) @@ -472,7 +471,7 @@ class JournalEntryFilterForm(NetBoxModelFilterSetForm): class NotificationGroupFilterForm(SavedFiltersMixin, FilterForm): user_id = DynamicModelMultipleChoiceField( - queryset=get_user_model().objects.all(), + queryset=User.objects.all(), required=False, label=_('User') ) diff --git a/netbox/extras/jobs.py b/netbox/extras/jobs.py index 62f8f6959..e540ef439 100644 --- a/netbox/extras/jobs.py +++ b/netbox/extras/jobs.py @@ -5,11 +5,11 @@ from contextlib import nullcontext from django.db import transaction from django.utils.translation import gettext as _ +from core.signals import clear_events from extras.models import Script as ScriptModel -from extras.signals import clear_events from netbox.context_managers import event_tracking +from netbox.jobs import JobRunner from utilities.exceptions import AbortScript, AbortTransaction -from utilities.jobs import JobRunner from .utils import is_report diff --git a/netbox/extras/management/commands/runscript.py b/netbox/extras/management/commands/runscript.py index b6d6810ac..ab0d6d894 100644 --- a/netbox/extras/management/commands/runscript.py +++ b/netbox/extras/management/commands/runscript.py @@ -3,12 +3,11 @@ import logging import sys import uuid -from django.contrib.auth import get_user_model from django.core.management.base import BaseCommand, CommandError -from django.utils.module_loading import import_string from extras.jobs import ScriptJob from extras.scripts import get_module_and_script +from users.models import User from utilities.request import NetBoxFakeRequest @@ -28,8 +27,6 @@ class Command(BaseCommand): parser.add_argument('script', help="Script to run") def handle(self, *args, **options): - User = get_user_model() - # Params script = options['script'] loglevel = options['loglevel'] diff --git a/netbox/extras/models/dashboard.py b/netbox/extras/models/dashboard.py index 7b9293777..669785cdd 100644 --- a/netbox/extras/models/dashboard.py +++ b/netbox/extras/models/dashboard.py @@ -1,4 +1,3 @@ -from django.contrib.auth import get_user_model from django.db import models from django.utils.translation import gettext_lazy as _ @@ -11,7 +10,7 @@ __all__ = ( class Dashboard(models.Model): user = models.OneToOneField( - to=get_user_model(), + to='users.User', on_delete=models.CASCADE, related_name='dashboard' ) diff --git a/netbox/extras/models/notifications.py b/netbox/extras/models/notifications.py index dba059ea7..a15dfe9aa 100644 --- a/netbox/extras/models/notifications.py +++ b/netbox/extras/models/notifications.py @@ -27,7 +27,7 @@ def get_event_type_choices(): """ return [ (name, event.text) - for name, event in registry['events'].items() + for name, event in registry['event_types'].items() ] @@ -102,7 +102,7 @@ class Notification(models.Model): """ Returns the registered Event which triggered this Notification. """ - return registry['events'].get(self.event_type) + return registry['event_types'].get(self.event_type) class NotificationGroup(ChangeLoggedModel): diff --git a/netbox/extras/models/staging.py b/netbox/extras/models/staging.py index 7ffbde089..b944a6fb9 100644 --- a/netbox/extras/models/staging.py +++ b/netbox/extras/models/staging.py @@ -1,6 +1,5 @@ import logging -from django.contrib.auth import get_user_model from django.contrib.contenttypes.fields import GenericForeignKey from django.db import models, transaction from django.utils.translation import gettext_lazy as _ @@ -34,7 +33,7 @@ class Branch(ChangeLoggedModel): blank=True ) user = models.ForeignKey( - to=get_user_model(), + to='users.User', on_delete=models.SET_NULL, blank=True, null=True diff --git a/netbox/extras/signals.py b/netbox/extras/signals.py index b9e4726bf..eae9c02a0 100644 --- a/netbox/extras/signals.py +++ b/netbox/extras/signals.py @@ -1,194 +1,18 @@ -import importlib -import logging - from django.contrib.contenttypes.models import ContentType -from django.core.exceptions import ImproperlyConfigured, ValidationError -from django.db.models.fields.reverse_related import ManyToManyRel from django.db.models.signals import m2m_changed, post_save, pre_delete -from django.dispatch import receiver, Signal -from django.utils.translation import gettext_lazy as _ -from django_prometheus.models import model_deletes, model_inserts, model_updates +from django.dispatch import receiver -from core.choices import ObjectChangeActionChoices from core.events import * -from core.models import ObjectChange, ObjectType +from core.models import ObjectType from core.signals import job_end, job_start from extras.events import process_event_rules from extras.models import EventRule, Notification, Subscription from netbox.config import get_config -from netbox.context import current_request, events_queue -from netbox.models.features import ChangeLoggingMixin from netbox.registry import registry from netbox.signals import post_clean from utilities.exceptions import AbortRequest -from .events import enqueue_event from .models import CustomField, TaggedItem -from .validators import CustomValidator - - -def run_validators(instance, validators): - """ - Run the provided iterable of validators for the instance. - """ - request = current_request.get() - for validator in validators: - - # Loading a validator class by dotted path - if type(validator) is str: - module, cls = validator.rsplit('.', 1) - validator = getattr(importlib.import_module(module), cls)() - - # Constructing a new instance on the fly from a ruleset - elif type(validator) is dict: - validator = CustomValidator(validator) - - elif not issubclass(validator.__class__, CustomValidator): - raise ImproperlyConfigured(f"Invalid value for custom validator: {validator}") - - validator(instance, request) - - -# -# Change logging/webhooks -# - -# Define a custom signal that can be sent to clear any queued events -clear_events = Signal() - - -@receiver((post_save, m2m_changed)) -def handle_changed_object(sender, instance, **kwargs): - """ - Fires when an object is created or updated. - """ - m2m_changed = False - - if not hasattr(instance, 'to_objectchange'): - return - - # Get the current request, or bail if not set - request = current_request.get() - if request is None: - return - - # Determine the type of change being made - if kwargs.get('created'): - event_type = OBJECT_CREATED - elif 'created' in kwargs: - event_type = OBJECT_UPDATED - elif kwargs.get('action') in ['post_add', 'post_remove'] and kwargs['pk_set']: - # m2m_changed with objects added or removed - m2m_changed = True - event_type = OBJECT_UPDATED - else: - return - - # Create/update an ObjectChange record for this change - action = { - OBJECT_CREATED: ObjectChangeActionChoices.ACTION_CREATE, - OBJECT_UPDATED: ObjectChangeActionChoices.ACTION_UPDATE, - OBJECT_DELETED: ObjectChangeActionChoices.ACTION_DELETE, - }[event_type] - objectchange = instance.to_objectchange(action) - # If this is a many-to-many field change, check for a previous ObjectChange instance recorded - # for this object by this request and update it - if m2m_changed and ( - prev_change := ObjectChange.objects.filter( - changed_object_type=ContentType.objects.get_for_model(instance), - changed_object_id=instance.pk, - request_id=request.id - ).first() - ): - prev_change.postchange_data = objectchange.postchange_data - prev_change.save() - elif objectchange and objectchange.has_changes: - objectchange.user = request.user - objectchange.request_id = request.id - objectchange.save() - - # Ensure that we're working with fresh M2M assignments - if m2m_changed: - instance.refresh_from_db() - - # Enqueue the object for event processing - queue = events_queue.get() - enqueue_event(queue, instance, request.user, request.id, event_type) - events_queue.set(queue) - - # Increment metric counters - if event_type == OBJECT_CREATED: - model_inserts.labels(instance._meta.model_name).inc() - elif event_type == OBJECT_UPDATED: - model_updates.labels(instance._meta.model_name).inc() - - -@receiver(pre_delete) -def handle_deleted_object(sender, instance, **kwargs): - """ - Fires when an object is deleted. - """ - # Run any deletion protection rules for the object. Note that this must occur prior - # to queueing any events for the object being deleted, in case a validation error is - # raised, causing the deletion to fail. - model_name = f'{sender._meta.app_label}.{sender._meta.model_name}' - validators = get_config().PROTECTION_RULES.get(model_name, []) - try: - run_validators(instance, validators) - except ValidationError as e: - raise AbortRequest( - _("Deletion is prevented by a protection rule: {message}").format(message=e) - ) - - # Get the current request, or bail if not set - request = current_request.get() - if request is None: - return - - # Record an ObjectChange if applicable - if hasattr(instance, 'to_objectchange'): - if hasattr(instance, 'snapshot') and not getattr(instance, '_prechange_snapshot', None): - instance.snapshot() - objectchange = instance.to_objectchange(ObjectChangeActionChoices.ACTION_DELETE) - objectchange.user = request.user - objectchange.request_id = request.id - objectchange.save() - - # Django does not automatically send an m2m_changed signal for the reverse direction of a - # many-to-many relationship (see https://code.djangoproject.com/ticket/17688), so we need to - # trigger one manually. We do this by checking for any reverse M2M relationships on the - # instance being deleted, and explicitly call .remove() on the remote M2M field to delete - # the association. This triggers an m2m_changed signal with the `post_remove` action type - # for the forward direction of the relationship, ensuring that the change is recorded. - for relation in instance._meta.related_objects: - if type(relation) is not ManyToManyRel: - continue - related_model = relation.related_model - related_field_name = relation.remote_field.name - if not issubclass(related_model, ChangeLoggingMixin): - # We only care about triggering the m2m_changed signal for models which support - # change logging - continue - for obj in related_model.objects.filter(**{related_field_name: instance.pk}): - obj.snapshot() # Ensure the change record includes the "before" state - getattr(obj, related_field_name).remove(instance) - - # Enqueue the object for event processing - queue = events_queue.get() - enqueue_event(queue, instance, request.user, request.id, OBJECT_DELETED) - events_queue.set(queue) - - # Increment metric counters - model_deletes.labels(instance._meta.model_name).inc() - - -@receiver(clear_events) -def clear_events_queue(sender, **kwargs): - """ - Delete any queued events (e.g. because of an aborted bulk transaction) - """ - logger = logging.getLogger('events') - logger.info(f"Clearing {len(events_queue.get())} queued events ({sender})") - events_queue.set({}) +from .utils import run_validators # diff --git a/netbox/extras/utils.py b/netbox/extras/utils.py index e67b9b50c..28d2e13f0 100644 --- a/netbox/extras/utils.py +++ b/netbox/extras/utils.py @@ -1,5 +1,19 @@ +import importlib + +from django.core.exceptions import ImproperlyConfigured from taggit.managers import _TaggableManager +from netbox.context import current_request +from .validators import CustomValidator + +__all__ = ( + 'image_upload', + 'is_report', + 'is_script', + 'is_taggable', + 'run_validators', +) + def is_taggable(obj): """ @@ -48,3 +62,25 @@ def is_report(obj): return issubclass(obj, Report) and obj != Report except TypeError: return False + + +def run_validators(instance, validators): + """ + Run the provided iterable of CustomValidators for the instance. + """ + request = current_request.get() + for validator in validators: + + # Loading a validator class by dotted path + if type(validator) is str: + module, cls = validator.rsplit('.', 1) + validator = getattr(importlib.import_module(module), cls)() + + # Constructing a new instance on the fly from a ruleset + elif type(validator) is dict: + validator = CustomValidator(validator) + + elif not issubclass(validator.__class__, CustomValidator): + raise ImproperlyConfigured(f"Invalid value for custom validator: {validator}") + + validator(instance, request) diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 54f00265a..8eb9f412d 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -551,14 +551,6 @@ class EventRuleListView(generic.ObjectListView): class EventRuleView(generic.ObjectView): queryset = EventRule.objects.all() - def get_extra_context(self, request, instance): - return { - 'event_types': [ - event for name, event in registry['events'].items() - if name in instance.event_types - ] - } - @register_model_view(EventRule, 'edit') class EventRuleEditView(generic.ObjectEditView): diff --git a/netbox/ipam/utils.py b/netbox/ipam/utils.py index ccf6cb632..3297abd8f 100644 --- a/netbox/ipam/utils.py +++ b/netbox/ipam/utils.py @@ -90,42 +90,45 @@ def add_available_ipaddresses(prefix, ipaddress_list, is_pool=False): return output -def available_vlans_from_range(vlans, vlan_group, vlan_range): +def available_vlans_from_range(vlans, vlan_group, vid_range): """ Create fake records for all gaps between used VLANs """ - min_vid = int(vlan_range.lower) if vlan_range else VLAN_VID_MIN - max_vid = int(vlan_range.upper) if vlan_range else VLAN_VID_MAX + min_vid = int(vid_range.lower) if vid_range else VLAN_VID_MIN + max_vid = int(vid_range.upper) if vid_range else VLAN_VID_MAX if not vlans: return [{ 'vid': min_vid, 'vlan_group': vlan_group, - 'available': max_vid - min_vid + 1 + 'available': max_vid - min_vid }] - prev_vid = max_vid + prev_vid = min_vid - 1 new_vlans = [] for vlan in vlans: + + # Ignore VIDs outside the range + if not min_vid <= vlan.vid < max_vid: + continue + + # Annotate any available VIDs between the previous (or minimum) VID + # and the current VID if vlan.vid - prev_vid > 1: new_vlans.append({ 'vid': prev_vid + 1, 'vlan_group': vlan_group, 'available': vlan.vid - prev_vid - 1, }) + prev_vid = vlan.vid - if vlans[0].vid > min_vid: - new_vlans.append({ - 'vid': min_vid, - 'vlan_group': vlan_group, - 'available': vlans[0].vid - min_vid, - }) + # Annotate any remaining available VLANs if prev_vid < max_vid: new_vlans.append({ 'vid': prev_vid + 1, 'vlan_group': vlan_group, - 'available': max_vid - prev_vid, + 'available': max_vid - prev_vid - 1, }) return new_vlans @@ -136,8 +139,8 @@ def add_available_vlans(vlans, vlan_group): Create fake records for all gaps between used VLANs """ new_vlans = [] - for vlan_range in vlan_group.vid_ranges: - new_vlans.extend(available_vlans_from_range(vlans, vlan_group, vlan_range)) + for vid_range in vlan_group.vid_ranges: + new_vlans.extend(available_vlans_from_range(vlans, vlan_group, vid_range)) vlans = list(vlans) + new_vlans vlans.sort(key=lambda v: v.vid if type(v) is VLAN else v['vid']) diff --git a/netbox/netbox/authentication/__init__.py b/netbox/netbox/authentication/__init__.py index 8c4bde436..7394f6ded 100644 --- a/netbox/netbox/authentication/__init__.py +++ b/netbox/netbox/authentication/__init__.py @@ -2,7 +2,6 @@ import logging from collections import defaultdict from django.conf import settings -from django.contrib.auth import get_user_model from django.contrib.auth.backends import ModelBackend, RemoteUserBackend as _RemoteUserBackend from django.contrib.auth.models import AnonymousUser from django.core.exceptions import ImproperlyConfigured @@ -10,14 +9,12 @@ from django.db.models import Q from django.utils.translation import gettext_lazy as _ from users.constants import CONSTRAINT_TOKEN_USER -from users.models import Group, ObjectPermission +from users.models import Group, ObjectPermission, User from utilities.permissions import ( permission_is_exempt, qs_filter_from_constraints, resolve_permission, resolve_permission_type, ) from .misc import _mirror_groups -UserModel = get_user_model() - AUTH_BACKEND_ATTRS = { # backend name: title, MDI icon name 'amazon': ('Amazon AWS', 'aws'), @@ -218,15 +215,15 @@ class RemoteUserBackend(_RemoteUserBackend): # instead we use get_or_create when creating unknown users since it has # built-in safeguards for multiple threads. if self.create_unknown_user: - user, created = UserModel._default_manager.get_or_create(**{ - UserModel.USERNAME_FIELD: username + user, created = User._default_manager.get_or_create(**{ + User.USERNAME_FIELD: username }) if created: user = self.configure_user(request, user) else: try: - user = UserModel._default_manager.get_by_natural_key(username) - except UserModel.DoesNotExist: + user = User._default_manager.get_by_natural_key(username) + except User.DoesNotExist: pass if self.user_can_authenticate(user): if settings.REMOTE_AUTH_GROUP_SYNC_ENABLED: diff --git a/netbox/netbox/events.py b/netbox/netbox/events.py index ee2561720..2fa4b6e78 100644 --- a/netbox/netbox/events.py +++ b/netbox/netbox/events.py @@ -2,41 +2,41 @@ from dataclasses import dataclass from netbox.registry import registry -EVENT_TYPE_INFO = 'info' -EVENT_TYPE_SUCCESS = 'success' -EVENT_TYPE_WARNING = 'warning' -EVENT_TYPE_DANGER = 'danger' +EVENT_TYPE_KIND_INFO = 'info' +EVENT_TYPE_KIND_SUCCESS = 'success' +EVENT_TYPE_KIND_WARNING = 'warning' +EVENT_TYPE_KIND_DANGER = 'danger' __all__ = ( - 'EVENT_TYPE_DANGER', - 'EVENT_TYPE_INFO', - 'EVENT_TYPE_SUCCESS', - 'EVENT_TYPE_WARNING', - 'Event', - 'get_event', + 'EVENT_TYPE_KIND_DANGER', + 'EVENT_TYPE_KIND_INFO', + 'EVENT_TYPE_KIND_SUCCESS', + 'EVENT_TYPE_KIND_WARNING', + 'EventType', + 'get_event_type', 'get_event_type_choices', 'get_event_text', ) -def get_event(name): - return registry['events'].get(name) +def get_event_type(name): + return registry['event_types'].get(name) def get_event_text(name): - if event := registry['events'].get(name): + if event := registry['event_types'].get(name): return event.text return '' def get_event_type_choices(): return [ - (event.name, event.text) for event in registry['events'].values() + (event.name, event.text) for event in registry['event_types'].values() ] @dataclass -class Event: +class EventType: """ A type of event which can occur in NetBox. Event rules can be defined to automatically perform some action in response to an event. @@ -44,32 +44,32 @@ class Event: Args: name: The unique name under which the event is registered. text: The human-friendly event name. This should support translation. - type: The event's classification (info, success, warning, or danger). The default type is info. + kind: The event's classification (info, success, warning, or danger). The default type is info. """ name: str text: str - type: str = EVENT_TYPE_INFO + kind: str = EVENT_TYPE_KIND_INFO def __str__(self): return self.text def register(self): - if self.name in registry['events']: - raise Exception(f"An event named {self.name} has already been registered!") - registry['events'][self.name] = self + if self.name in registry['event_types']: + raise Exception(f"An event type named {self.name} has already been registered!") + registry['event_types'][self.name] = self def color(self): return { - EVENT_TYPE_INFO: 'blue', - EVENT_TYPE_SUCCESS: 'green', - EVENT_TYPE_WARNING: 'orange', - EVENT_TYPE_DANGER: 'red', - }.get(self.type) + EVENT_TYPE_KIND_INFO: 'blue', + EVENT_TYPE_KIND_SUCCESS: 'green', + EVENT_TYPE_KIND_WARNING: 'orange', + EVENT_TYPE_KIND_DANGER: 'red', + }.get(self.kind) def icon(self): return { - EVENT_TYPE_INFO: 'mdi mdi-information', - EVENT_TYPE_SUCCESS: 'mdi mdi-check-circle', - EVENT_TYPE_WARNING: 'mdi mdi-alert-box', - EVENT_TYPE_DANGER: 'mdi mdi-alert-octagon', - }.get(self.type) + EVENT_TYPE_KIND_INFO: 'mdi mdi-information', + EVENT_TYPE_KIND_SUCCESS: 'mdi mdi-check-circle', + EVENT_TYPE_KIND_WARNING: 'mdi mdi-alert-box', + EVENT_TYPE_KIND_DANGER: 'mdi mdi-alert-octagon', + }.get(self.kind) diff --git a/netbox/utilities/jobs.py b/netbox/netbox/jobs.py similarity index 100% rename from netbox/utilities/jobs.py rename to netbox/netbox/jobs.py diff --git a/netbox/netbox/registry.py b/netbox/netbox/registry.py index 44cdfb92b..0920cbccf 100644 --- a/netbox/netbox/registry.py +++ b/netbox/netbox/registry.py @@ -25,7 +25,7 @@ registry = Registry({ 'counter_fields': collections.defaultdict(dict), 'data_backends': dict(), 'denormalized_fields': collections.defaultdict(list), - 'events': dict(), + 'event_types': dict(), 'model_features': dict(), 'models': collections.defaultdict(set), 'plugins': dict(), diff --git a/netbox/netbox/tests/test_authentication.py b/netbox/netbox/tests/test_authentication.py index 6e049dcaf..5c7a30dc7 100644 --- a/netbox/netbox/tests/test_authentication.py +++ b/netbox/netbox/tests/test_authentication.py @@ -1,7 +1,6 @@ import datetime from django.conf import settings -from django.contrib.auth import get_user_model from django.test import Client from django.test.utils import override_settings from django.urls import reverse @@ -11,14 +10,11 @@ from rest_framework.test import APIClient from core.models import ObjectType from dcim.models import Site from ipam.models import Prefix -from users.models import Group, ObjectPermission, Token +from users.models import Group, ObjectPermission, Token, User 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/utilities/tests/test_jobs.py b/netbox/netbox/tests/test_jobs.py similarity index 100% rename from netbox/utilities/tests/test_jobs.py rename to netbox/netbox/tests/test_jobs.py diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index 71ce411ba..7a2d4c08b 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -8,7 +8,7 @@ from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist, Valida from django.db import transaction, IntegrityError from django.db.models import ManyToManyField, ProtectedError, RestrictedError from django.db.models.fields.reverse_related import ManyToManyRel -from django.forms import HiddenInput, ModelMultipleChoiceField, MultipleHiddenInput +from django.forms import ModelMultipleChoiceField, MultipleHiddenInput from django.http import HttpResponse from django.shortcuts import get_object_or_404, redirect, render from django.urls import reverse @@ -17,8 +17,8 @@ from django.utils.translation import gettext as _ from django_tables2.export import TableExport from core.models import ObjectType +from core.signals import clear_events from extras.models import ExportTemplate -from extras.signals import clear_events from utilities.error_handlers import handle_protectederror from utilities.exceptions import AbortRequest, AbortTransaction, PermissionsViolation from utilities.forms import BulkRenameForm, ConfirmationForm, restrict_form_fields diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index cad7facd3..85f90cbc1 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -13,7 +13,7 @@ from django.utils.html import escape from django.utils.safestring import mark_safe from django.utils.translation import gettext as _ -from extras.signals import clear_events +from core.signals import clear_events from utilities.error_handlers import handle_protectederror from utilities.exceptions import AbortRequest, PermissionsViolation from utilities.forms import ConfirmationForm, restrict_form_fields diff --git a/netbox/project-static/yarn.lock b/netbox/project-static/yarn.lock index 4f9111c89..bb0bea154 100644 --- a/netbox/project-static/yarn.lock +++ b/netbox/project-static/yarn.lock @@ -867,13 +867,20 @@ brace-expansion@^1.1.7: balanced-match "^1.0.0" concat-map "0.0.1" -braces@^3.0.2, braces@~3.0.2: +braces@^3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107" integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A== dependencies: fill-range "^7.0.1" +braces@~3.0.2: + version "3.0.3" + resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.3.tgz#490332f40919452272d55a8480adc0c441358789" + integrity sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA== + dependencies: + fill-range "^7.1.1" + call-bind@^1.0.2, call-bind@^1.0.5, call-bind@^1.0.6, call-bind@^1.0.7: version "1.0.7" resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.7.tgz#06016599c40c56498c18769d2730be242b6fa3b9" @@ -1520,10 +1527,10 @@ file-entry-cache@^6.0.1: dependencies: flat-cache "^3.0.4" -fill-range@^7.0.1: - version "7.0.1" - resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40" - integrity sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ== +fill-range@^7.0.1, fill-range@^7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292" + integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg== dependencies: to-regex-range "^5.0.1" @@ -1816,9 +1823,9 @@ ignore@^5.2.0: integrity sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw== immutable@^4.0.0: - version "4.3.6" - resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.3.6.tgz#6a05f7858213238e587fb83586ffa3b4b27f0447" - integrity sha512-Ju0+lEMyzMVZarkTn/gqRpdqd5dOPaz1mCZ0SH3JV6iFw81PldE/PEB1hWVEA288HPt4WXW8O7AWxB10M+03QQ== + version "4.3.7" + resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.3.7.tgz#c70145fc90d89fb02021e65c84eb0226e4e5a381" + integrity sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw== import-fresh@^3.2.1: version "3.3.0" diff --git a/netbox/templates/core/rq_worker_list.html b/netbox/templates/core/rq_worker_list.html index d0d8b9149..92ea601ac 100644 --- a/netbox/templates/core/rq_worker_list.html +++ b/netbox/templates/core/rq_worker_list.html @@ -24,7 +24,12 @@ {% endblock page-header %} -{% block title %}{{ status|capfirst }} {% trans "Workers in " %}{{ queue.name }}{% endblock %} +{% block title %} + {{ status|capfirst }} + {% blocktrans trimmed with queue_name=queue.name %} + Workers in {{ queue_name }} + {% endblocktrans %} +{% endblock %} {% block controls %}{% endblock %} diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 80266c78d..b12dee568 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -87,7 +87,7 @@ {% trans "Device Type" %} - {{ object.device_type|linkify:"get_full_name" }} ({{ object.device_type.u_height|floatformat }}U) + {{ object.device_type|linkify:"full_name" }} ({{ object.device_type.u_height|floatformat }}U) diff --git a/netbox/templates/dcim/module.html b/netbox/templates/dcim/module.html index f5fb0246b..675b0ec72 100644 --- a/netbox/templates/dcim/module.html +++ b/netbox/templates/dcim/module.html @@ -63,7 +63,7 @@ {% trans "Module Type" %} - {{ object.module_type|linkify }} + {{ object.module_type|linkify:"full_name" }} {% trans "Status" %} diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index f69abe77a..128be6d3f 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -43,7 +43,7 @@ {% trans "Rack Type" %} - {{ object.rack_type|linkify|placeholder }} + {{ object.rack_type|linkify:"full_name"|placeholder }} {% trans "Role" %} diff --git a/netbox/templates/dcim/racktype.html b/netbox/templates/dcim/racktype.html index c4e445146..8a1971af9 100644 --- a/netbox/templates/dcim/racktype.html +++ b/netbox/templates/dcim/racktype.html @@ -17,8 +17,8 @@ {{ object.manufacturer|linkify }} - {% trans "Name" %} - {{ object.name }} + {% trans "Model" %} + {{ object.model }} {% trans "Description" %} diff --git a/netbox/templates/extras/eventrule.html b/netbox/templates/extras/eventrule.html index 4ee09af03..51b10f4d9 100644 --- a/netbox/templates/extras/eventrule.html +++ b/netbox/templates/extras/eventrule.html @@ -36,7 +36,7 @@
{% trans "Event Types" %}