Merge branch 'feature' into 10500-nested-modules

This commit is contained in:
Arthur Hanson 2024-08-02 13:55:38 +07:00
commit 16e9a9ce6d
91 changed files with 797 additions and 614 deletions

View File

@ -86,8 +86,6 @@ CUSTOM_VALIDATORS = {
#### Referencing Related Object Attributes #### 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`: 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 ```python
@ -104,8 +102,6 @@ CUSTOM_VALIDATORS = {
#### Validating Request Parameters #### 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: 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 ```json

View File

@ -1,9 +1,10 @@
# Event Rules # 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 * Executing a [custom script](../customization/custom-scripts.md)
* [Webhooks](../integrations/webhooks.md) execution * 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. 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.

View File

@ -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.

View File

@ -1,5 +1,7 @@
# Circuit Groups # 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. [Circuits](./circuit.md) can be arranged into administrative groups for organization. The assignment of a circuit to a group is optional.
## Fields ## Fields

View File

@ -42,4 +42,6 @@ The numeric weight of the module, including a unit designation (e.g. 3 kilograms
### Airflow ### Airflow
!!! info "The `airflow` field was introduced in NetBox v4.1."
The direction in which air circulates through the device chassis for cooling. The direction in which air circulates through the device chassis for cooling.

View File

@ -1,5 +1,7 @@
# Rack Types # 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). A rack type defines the physical characteristics of a particular model of [rack](./rack.md).
## Fields ## 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. 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 ### Form Factor

View File

@ -44,6 +44,8 @@ For object and multiple-object fields only. Designates the type of NetBox object
### Related Object Filter ### 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." 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 !!! warning

View File

@ -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. 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 | | Name | Description |
|------------|--------------------------------------| |----------------|---------------------------------------------|
| Creations | A new object has been created | | Object created | A new object has been created |
| Updates | An existing object has been modified | | Object updated | An existing object has been modified |
| Deletions | An object has been deleted | | Object deleted | An object has been deleted |
| Job starts | A job for an object starts | | Job started | A background job is initiated |
| Job ends | A job for an object terminates | | 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 ### Conditions

View File

@ -16,6 +16,8 @@ A unique URL-friendly identifier. (This value can be used for filtering.)
### VLAN ID Ranges ### 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. 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 ### Scope

View File

@ -50,9 +50,13 @@ The amount of running memory provisioned, in megabytes.
### Disk ### 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 ### 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.

View File

@ -20,6 +20,12 @@ The operational status of the link. Options include:
The service set identifier (SSID) for the wireless link (optional). 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 ### Authentication Type
The type of wireless authentication in use. Options include: 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 ### 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. 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).

View File

@ -1,5 +1,7 @@
# Background Jobs # 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. 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. 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. 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 #### Example
```python title="jobs.py" ```python title="jobs.py"
from utilities.jobs import JobRunner from netbox.jobs import JobRunner
class MyTestJob(JobRunner): class MyTestJob(JobRunner):
@ -45,7 +47,7 @@ As described above, jobs can be scheduled for immediate execution or at any late
#### Example #### Example
```python title="jobs.py" ```python title="jobs.py"
from utilities.jobs import JobRunner from netbox.jobs import JobRunner
class MyHousekeepingJob(JobRunner): class MyHousekeepingJob(JobRunner):

View File

@ -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 ```python
from django.utils.translation import gettext_lazy as _ 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', name='ticket_opened',
text=_('Ticket opened'), text=_('Ticket opened'),
type=EVENT_TYPE_SUCCESS kind=EVENT_TYPE_KIND_SUCCESS
).register() ).register()
``` ```
::: netbox.events.Event ::: netbox.events.EventType

View File

@ -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 | | `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 | | `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. 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.

View File

@ -5,8 +5,9 @@
### Breaking Changes ### Breaking Changes
* Several filters deprecated in v4.0 have been removed (see [#15410](https://github.com/netbox-community/netbox/issues/15410)). * 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 `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). * The `validate()` method on CustomValidator subclasses now **must** accept the request argument (deprecated in v4.0 by #14279).
### New Features ### New Features
@ -72,6 +73,9 @@ NetBox now includes a user notification system. Users can subscribe to individua
* Added the optional `airflow` choice field * Added the optional `airflow` choice field
* extras.CustomField * extras.CustomField
* Added the `related_object_filter` JSON field for object and multi-object custom fields * 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 * ipam.VLANGroup
* Removed the `min_vid` and `max_vid` fields * Removed the `min_vid` and `max_vid` fields
* Added the `vid_ranges` field, and array of starting & ending VLAN IDs * Added the `vid_ranges` field, and array of starting & ending VLAN IDs

View File

@ -86,6 +86,7 @@ nav:
- Change Logging: 'features/change-logging.md' - Change Logging: 'features/change-logging.md'
- Journaling: 'features/journaling.md' - Journaling: 'features/journaling.md'
- Event Rules: 'features/event-rules.md' - Event Rules: 'features/event-rules.md'
- Notifications: 'features/notifications.md'
- Background Jobs: 'features/background-jobs.md' - Background Jobs: 'features/background-jobs.md'
- Auth & Permissions: 'features/authentication-permissions.md' - Auth & Permissions: 'features/authentication-permissions.md'
- API & Integration: 'features/api-integration.md' - API & Integration: 'features/api-integration.md'
@ -142,7 +143,7 @@ nav:
- Forms: 'plugins/development/forms.md' - Forms: 'plugins/development/forms.md'
- Filters & Filter Sets: 'plugins/development/filtersets.md' - Filters & Filter Sets: 'plugins/development/filtersets.md'
- Search: 'plugins/development/search.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' - Data Backends: 'plugins/development/data-backends.md'
- REST API: 'plugins/development/rest-api.md' - REST API: 'plugins/development/rest-api.md'
- GraphQL API: 'plugins/development/graphql-api.md' - GraphQL API: 'plugins/development/graphql-api.md'

View File

@ -1,6 +1,6 @@
from django.utils.translation import gettext as _ 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__ = ( __all__ = (
'JOB_COMPLETED', 'JOB_COMPLETED',
@ -24,10 +24,10 @@ JOB_FAILED = 'job_failed'
JOB_ERRORED = 'job_errored' JOB_ERRORED = 'job_errored'
# Register core events # Register core events
Event(OBJECT_CREATED, _('Object created')).register() EventType(OBJECT_CREATED, _('Object created')).register()
Event(OBJECT_UPDATED, _('Object updated')).register() EventType(OBJECT_UPDATED, _('Object updated')).register()
Event(OBJECT_DELETED, _('Object deleted')).register() EventType(OBJECT_DELETED, _('Object deleted')).register()
Event(JOB_STARTED, _('Job started')).register() EventType(JOB_STARTED, _('Job started')).register()
Event(JOB_COMPLETED, _('Job completed'), type=EVENT_TYPE_SUCCESS).register() EventType(JOB_COMPLETED, _('Job completed'), kind=EVENT_TYPE_KIND_SUCCESS).register()
Event(JOB_FAILED, _('Job failed'), type=EVENT_TYPE_WARNING).register() EventType(JOB_FAILED, _('Job failed'), kind=EVENT_TYPE_KIND_WARNING).register()
Event(JOB_ERRORED, _('Job errored'), type=EVENT_TYPE_DANGER).register() EventType(JOB_ERRORED, _('Job errored'), kind=EVENT_TYPE_KIND_DANGER).register()

View File

@ -1,4 +1,3 @@
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db.models import Q from django.db.models import Q
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
@ -7,6 +6,7 @@ import django_filters
from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet
from netbox.utils import get_data_backend_choices from netbox.utils import get_data_backend_choices
from users.models import User
from utilities.filters import ContentTypeFilter from utilities.filters import ContentTypeFilter
from .choices import * from .choices import *
from .models import * from .models import *
@ -141,12 +141,12 @@ class ObjectChangeFilterSet(BaseFilterSet):
queryset=ContentType.objects.all() queryset=ContentType.objects.all()
) )
user_id = django_filters.ModelMultipleChoiceFilter( user_id = django_filters.ModelMultipleChoiceFilter(
queryset=get_user_model().objects.all(), queryset=User.objects.all(),
label=_('User (ID)'), label=_('User (ID)'),
) )
user = django_filters.ModelMultipleChoiceFilter( user = django_filters.ModelMultipleChoiceFilter(
field_name='user__username', field_name='user__username',
queryset=get_user_model().objects.all(), queryset=User.objects.all(),
to_field_name='username', to_field_name='username',
label=_('User name'), label=_('User name'),
) )

View File

@ -1,5 +1,4 @@
from django import forms from django import forms
from django.contrib.auth import get_user_model
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from core.choices import * from core.choices import *
@ -7,6 +6,7 @@ from core.models import *
from netbox.forms import NetBoxModelFilterSetForm from netbox.forms import NetBoxModelFilterSetForm
from netbox.forms.mixins import SavedFiltersMixin from netbox.forms.mixins import SavedFiltersMixin
from netbox.utils import get_data_backend_choices 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 import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
from utilities.forms.fields import ( from utilities.forms.fields import (
ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField,
@ -121,7 +121,7 @@ class JobFilterForm(SavedFiltersMixin, FilterForm):
widget=DateTimePicker() widget=DateTimePicker()
) )
user = DynamicModelMultipleChoiceField( user = DynamicModelMultipleChoiceField(
queryset=get_user_model().objects.all(), queryset=User.objects.all(),
required=False, required=False,
label=_('User') label=_('User')
) )
@ -150,7 +150,7 @@ class ObjectChangeFilterForm(SavedFiltersMixin, FilterForm):
required=False required=False
) )
user_id = DynamicModelMultipleChoiceField( user_id = DynamicModelMultipleChoiceField(
queryset=get_user_model().objects.all(), queryset=User.objects.all(),
required=False, required=False,
label=_('User') label=_('User')
) )

View File

@ -1,7 +1,7 @@
import logging import logging
from netbox.jobs import JobRunner
from netbox.search.backends import search_backend from netbox.search.backends import search_backend
from utilities.jobs import JobRunner
from .choices import DataSourceStatusChoices from .choices import DataSourceStatusChoices
from .exceptions import SyncError from .exceptions import SyncError
from .models import DataSource from .models import DataSource

View File

@ -5,10 +5,10 @@ import sys
from django import get_version from django import get_version
from django.apps import apps from django.apps import apps
from django.conf import settings from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
from core.models import ObjectType from core.models import ObjectType
from users.models import User
APPS = ('circuits', 'core', 'dcim', 'extras', 'ipam', 'tenancy', 'users', 'virtualization', 'vpn', 'wireless') APPS = ('circuits', 'core', 'dcim', 'extras', 'ipam', 'tenancy', 'users', 'virtualization', 'vpn', 'wireless')
@ -61,7 +61,7 @@ class Command(BaseCommand):
# Additional objects to include # Additional objects to include
namespace['ObjectType'] = ObjectType namespace['ObjectType'] = ObjectType
namespace['User'] = get_user_model() namespace['User'] = User
# Load convenience commands # Load convenience commands
namespace.update({ namespace.update({

View File

@ -21,7 +21,6 @@ from netbox.registry import registry
from utilities.querysets import RestrictedQuerySet from utilities.querysets import RestrictedQuerySet
from ..choices import * from ..choices import *
from ..exceptions import SyncError from ..exceptions import SyncError
from ..signals import post_sync, pre_sync
__all__ = ( __all__ = (
'AutoSyncRecord', 'AutoSyncRecord',
@ -159,6 +158,8 @@ class DataSource(JobsMixin, PrimaryModel):
""" """
Create/update/delete child DataFiles as necessary to synchronize with the remote source. 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: if self.status == DataSourceStatusChoices.SYNCING:
raise SyncError(_("Cannot initiate sync; syncing already in progress.")) raise SyncError(_("Cannot initiate sync; syncing already in progress."))

View File

@ -214,7 +214,7 @@ class Job(models.Model):
management commands only. management commands only.
""" """
if schedule_at and immediate: 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: if instance:
object_type = ObjectType.objects.get_for_model(instance, for_concrete_model=False) object_type = ObjectType.objects.get_for_model(instance, for_concrete_model=False)

View File

@ -7,12 +7,12 @@ from typing import Optional
import requests import requests
from django.conf import settings from django.conf import settings
from django.core.cache import cache from django.core.cache import cache
from django.utils.translation import gettext_lazy as _
from netbox.plugins import PluginConfig from netbox.plugins import PluginConfig
from utilities.datetime import datetime_from_timestamp from utilities.datetime import datetime_from_timestamp
USER_AGENT_STRING = f'NetBox/{settings.RELEASE.version} {settings.RELEASE.edition}' USER_AGENT_STRING = f'NetBox/{settings.RELEASE.version} {settings.RELEASE.edition}'
CACHE_KEY_CATALOG_FEED = 'plugins-catalog-feed'
@dataclass @dataclass
@ -68,16 +68,19 @@ class Plugin:
installed_version: str = '' installed_version: str = ''
def get_local_plugins(): def get_local_plugins(plugins=None):
""" """
Return a dictionary of all locally-installed plugins, mapped by name. 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: for plugin_name in settings.PLUGINS:
plugin = importlib.import_module(plugin_name) plugin = importlib.import_module(plugin_name)
plugin_config: PluginConfig = plugin.config plugin_config: PluginConfig = plugin.config
plugins[plugin_config.name] = Plugin( local_plugins[plugin_config.name] = Plugin(
slug=plugin_config.name, slug=plugin_config.name,
title_short=plugin_config.verbose_name, title_short=plugin_config.verbose_name,
tag_line=plugin_config.description, tag_line=plugin_config.description,
@ -87,6 +90,14 @@ def get_local_plugins():
installed_version=plugin_config.version, 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 return plugins
@ -95,7 +106,6 @@ def get_catalog_plugins():
Return a dictionary of all entries in the plugins catalog, mapped by name. Return a dictionary of all entries in the plugins catalog, mapped by name.
""" """
session = requests.Session() session = requests.Session()
plugins = {}
def get_pages(): def get_pages():
# TODO: pagination is currently broken in API # TODO: pagination is currently broken in API
@ -121,88 +131,80 @@ def get_catalog_plugins():
).json() ).json()
yield next_page yield next_page
for page in get_pages(): def make_plugin_dict():
for data in page['data']: plugins = {}
# Populate releases for page in get_pages():
releases = [] for data in page['data']:
for version in data['release_recent_history']:
releases.append( # Populate releases
PluginVersion( releases = []
date=datetime_from_timestamp(version['date']), for version in data['release_recent_history']:
version=version['version'], releases.append(
netbox_min_version=version['netbox_min_version'], PluginVersion(
netbox_max_version=version['netbox_max_version'], date=datetime_from_timestamp(version['date']),
has_model=version['has_model'], version=version['version'],
is_certified=version['is_certified'], netbox_min_version=version['netbox_min_version'],
is_feature=version['is_feature'], netbox_max_version=version['netbox_max_version'],
is_integration=version['is_integration'], has_model=version['has_model'],
is_netboxlabs_supported=version['is_netboxlabs_supported'], 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) # Populate author (if any)
if data['author']: if data['author']:
author = PluginAuthor( author = PluginAuthor(
name=data['author']['name'], name=data['author']['name'],
org_id=data['author']['org_id'], org_id=data['author']['org_id'],
url=data['author']['url'], 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 return plugins
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 catalog_plugins = cache.get(CACHE_KEY_CATALOG_FEED, default={})
def get_plugins():
"""
Return a dictionary of all plugins (both catalog and locally installed), mapped by name.
"""
local_plugins = get_local_plugins()
catalog_plugins = cache.get('plugins-catalog-feed')
if not catalog_plugins: if not catalog_plugins:
catalog_plugins = get_catalog_plugins() try:
cache.set('plugins-catalog-feed', catalog_plugins, 3600) catalog_plugins = make_plugin_dict()
cache.set(CACHE_KEY_CATALOG_FEED, catalog_plugins, 3600)
except requests.exceptions.RequestException:
pass
plugins = catalog_plugins return catalog_plugins
for k, v in local_plugins.items():
if k in plugins:
plugins[k].is_local = True
plugins[k].is_installed = True
else:
plugins[k] = v
return plugins

View File

@ -1,9 +1,26 @@
from django.db.models.signals import post_save import logging
from django.dispatch import Signal, receiver
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 from .models import ConfigRevision
__all__ = ( __all__ = (
'clear_events',
'job_end', 'job_end',
'job_start', 'job_start',
'post_sync', 'post_sync',
@ -18,6 +35,152 @@ job_end = Signal()
pre_sync = Signal() pre_sync = Signal()
post_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) @receiver(post_sync)
def auto_sync(instance, **kwargs): def auto_sync(instance, **kwargs):

View File

@ -2,6 +2,7 @@ import json
import platform import platform
from django import __version__ as DJANGO_VERSION from django import __version__ as DJANGO_VERSION
from django.apps import apps
from django.conf import settings from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.contrib.auth.mixins import UserPassesTestMixin from django.contrib.auth.mixins import UserPassesTestMixin
@ -37,7 +38,7 @@ from . import filtersets, forms, tables
from .choices import DataSourceStatusChoices from .choices import DataSourceStatusChoices
from .jobs import SyncDataSourceJob from .jobs import SyncDataSourceJob
from .models import * from .models import *
from .plugins import get_plugins from .plugins import get_catalog_plugins, get_local_plugins
from .tables import CatalogPluginTable, PluginVersionTable from .tables import CatalogPluginTable, PluginVersionTable
@ -630,10 +631,13 @@ class SystemView(UserPassesTestMixin, View):
# Raw data export # Raw data export
if 'export' in request.GET: if 'export' in request.GET:
stats['netbox_release'] = stats['netbox_release'].asdict()
params = [param.name for param in PARAMS]
data = { data = {
**stats, **stats,
'plugins': settings.PLUGINS,
'config': { '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') response = HttpResponse(json.dumps(data, indent=4), content_type='text/json')
@ -650,15 +654,31 @@ class SystemView(UserPassesTestMixin, View):
# Plugins # Plugins
# #
class PluginListView(UserPassesTestMixin, View): class BasePluginView(UserPassesTestMixin, View):
CACHE_KEY_CATALOG_ERROR = 'plugins-catalog-error'
def test_func(self): def test_func(self):
return self.request.user.is_staff 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): def get(self, request):
q = request.GET.get('q', None) q = request.GET.get('q', None)
plugins = get_plugins().values() plugins = self.get_cached_plugins(request).values()
if q: if q:
plugins = [obj for obj in plugins if q.casefold() in obj.title_short.casefold()] 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): class PluginView(BasePluginView):
def test_func(self):
return self.request.user.is_staff
def get(self, request, name): def get(self, request, name):
plugins = get_plugins() plugins = self.get_cached_plugins(request)
if name not in plugins: if name not in plugins:
raise Http404(_("Plugin {name} not found").format(name=name)) raise Http404(_("Plugin {name} not found").format(name=name))
plugin = plugins[name] plugin = plugins[name]

View File

@ -73,12 +73,12 @@ class RackTypeSerializer(RackBaseSerializer):
class Meta: class Meta:
model = RackType model = RackType
fields = [ 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', 'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'weight',
'max_weight', 'weight_unit', 'mounting_depth', 'airflow', 'description', 'comments', 'tags', 'max_weight', 'weight_unit', 'mounting_depth', 'airflow', 'description', 'comments', 'tags',
'custom_fields', 'created', 'last_updated', '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): class RackSerializer(RackBaseSerializer):

View File

@ -1,5 +1,4 @@
import django_filters import django_filters
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
@ -16,6 +15,7 @@ from netbox.filtersets import (
) )
from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet from tenancy.filtersets import TenancyFilterSet, ContactModelFilterSet
from tenancy.models import * from tenancy.models import *
from users.models import User
from utilities.filters import ( from utilities.filters import (
ContentTypeFilter, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, MultiValueWWNFilter, ContentTypeFilter, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, MultiValueWWNFilter,
NumericArrayFilter, TreeNodeMultipleChoiceFilter, NumericArrayFilter, TreeNodeMultipleChoiceFilter,
@ -311,7 +311,7 @@ class RackTypeFilterSet(NetBoxModelFilterSet):
class Meta: class Meta:
model = RackType model = RackType
fields = ( 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', 'mounting_depth', 'airflow', 'weight', 'max_weight', 'weight_unit', 'description',
) )
@ -319,7 +319,7 @@ class RackTypeFilterSet(NetBoxModelFilterSet):
if not value.strip(): if not value.strip():
return queryset return queryset
return queryset.filter( return queryset.filter(
Q(name__icontains=value) | Q(model__icontains=value) |
Q(description__icontains=value) | Q(description__icontains=value) |
Q(comments__icontains=value) Q(comments__icontains=value)
) )
@ -375,6 +375,17 @@ class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe
to_field_name='slug', to_field_name='slug',
label=_('Location (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( rack_type = django_filters.ModelMultipleChoiceFilter(
field_name='rack_type__slug', field_name='rack_type__slug',
queryset=RackType.objects.all(), queryset=RackType.objects.all(),
@ -486,12 +497,12 @@ class RackReservationFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
label=_('Location (slug)'), label=_('Location (slug)'),
) )
user_id = django_filters.ModelMultipleChoiceFilter( user_id = django_filters.ModelMultipleChoiceFilter(
queryset=get_user_model().objects.all(), queryset=User.objects.all(),
label=_('User (ID)'), label=_('User (ID)'),
) )
user = django_filters.ModelMultipleChoiceFilter( user = django_filters.ModelMultipleChoiceFilter(
field_name='user__username', field_name='user__username',
queryset=get_user_model().objects.all(), queryset=User.objects.all(),
to_field_name='username', to_field_name='username',
label=_('User (name)'), label=_('User (name)'),
) )

View File

@ -1,6 +1,5 @@
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.contrib.auth import get_user_model
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from timezone_field import TimeZoneFormField from timezone_field import TimeZoneFormField
@ -11,6 +10,7 @@ from extras.models import ConfigTemplate
from ipam.models import ASN, VLAN, VLANGroup, VRF from ipam.models import ASN, VLAN, VLANGroup, VRF
from netbox.forms import NetBoxModelBulkEditForm from netbox.forms import NetBoxModelBulkEditForm
from tenancy.models import Tenant from tenancy.models import Tenant
from users.models import User
from utilities.forms import BulkEditForm, add_blank_choice, form_from_model from utilities.forms import BulkEditForm, add_blank_choice, form_from_model
from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField
from utilities.forms.rendering import FieldSet, InlineFields from utilities.forms.rendering import FieldSet, InlineFields
@ -459,9 +459,7 @@ class RackBulkEditForm(NetBoxModelBulkEditForm):
class RackReservationBulkEditForm(NetBoxModelBulkEditForm): class RackReservationBulkEditForm(NetBoxModelBulkEditForm):
user = forms.ModelChoiceField( user = forms.ModelChoiceField(
label=_('User'), label=_('User'),
queryset=get_user_model().objects.order_by( queryset=User.objects.order_by('username'),
'username'
),
required=False required=False
) )
tenant = DynamicModelChoiceField( tenant = DynamicModelChoiceField(

View File

@ -222,7 +222,7 @@ class RackTypeImportForm(NetBoxModelImportForm):
class Meta: class Meta:
model = RackType model = RackType
fields = ( 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', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'airflow', 'weight', 'max_weight',
'weight_unit', 'description', 'comments', 'tags', 'weight_unit', 'description', 'comments', 'tags',
) )

View File

@ -1,5 +1,4 @@
from django import forms from django import forms
from django.contrib.auth import get_user_model
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from dcim.choices import * from dcim.choices import *
@ -10,6 +9,7 @@ from extras.models import ConfigTemplate
from ipam.models import ASN, VRF from ipam.models import ASN, VRF
from netbox.forms import NetBoxModelFilterSetForm from netbox.forms import NetBoxModelFilterSetForm
from tenancy.forms import ContactModelFilterForm, TenancyFilterForm 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 import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
from utilities.forms.fields import ColorField, DynamicModelMultipleChoiceField, TagFilterField from utilities.forms.fields import ColorField, DynamicModelMultipleChoiceField, TagFilterField
from utilities.forms.rendering import FieldSet from utilities.forms.rendering import FieldSet
@ -312,8 +312,8 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, RackBaseFilterFo
FieldSet('q', 'filter_id', 'tag'), FieldSet('q', 'filter_id', 'tag'),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')), FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')), FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('status', 'role_id', 'serial', 'asset_tag', name=_('Rack')), FieldSet('status', 'role_id', 'manufacturer_id', 'rack_type_id', 'serial', 'asset_tag', name=_('Rack')),
FieldSet('form_factor', 'width', 'u_height', 'airflow', name=_('Rack Type')), FieldSet('form_factor', 'width', 'u_height', 'airflow', name=_('Hardware')),
FieldSet('starting_unit', 'desc_units', name=_('Numbering')), FieldSet('starting_unit', 'desc_units', name=_('Numbering')),
FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')), FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')), FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
@ -357,6 +357,19 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, RackBaseFilterFo
null_option='None', null_option='None',
label=_('Role') 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( serial = forms.CharField(
label=_('Serial'), label=_('Serial'),
required=False required=False
@ -435,7 +448,7 @@ class RackReservationFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
label=_('Rack') label=_('Rack')
) )
user_id = DynamicModelMultipleChoiceField( user_id = DynamicModelMultipleChoiceField(
queryset=get_user_model().objects.all(), queryset=User.objects.all(),
required=False, required=False,
label=_('User') label=_('User')
) )

View File

@ -1,5 +1,4 @@
from django import forms from django import forms
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from timezone_field import TimeZoneFormField from timezone_field import TimeZoneFormField
@ -11,6 +10,7 @@ from extras.models import ConfigTemplate
from ipam.models import ASN, IPAddress, VLAN, VLANGroup, VRF from ipam.models import ASN, IPAddress, VLAN, VLANGroup, VRF
from netbox.forms import NetBoxModelForm from netbox.forms import NetBoxModelForm
from tenancy.forms import TenancyForm from tenancy.forms import TenancyForm
from users.models import User
from utilities.forms import add_blank_choice, get_field_value from utilities.forms import add_blank_choice, get_field_value
from utilities.forms.fields import ( from utilities.forms.fields import (
CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField, NumericArrayField, SlugField, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField, NumericArrayField, SlugField,
@ -208,10 +208,13 @@ class RackTypeForm(NetBoxModelForm):
queryset=Manufacturer.objects.all() queryset=Manufacturer.objects.all()
) )
comments = CommentField() comments = CommentField()
slug = SlugField() slug = SlugField(
label=_('Slug'),
slug_source='model'
)
fieldsets = ( 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( FieldSet(
'width', 'u_height', 'width', 'u_height',
InlineFields('outer_width', 'outer_depth', 'outer_unit', label=_('Outer Dimensions')), InlineFields('outer_width', 'outer_depth', 'outer_unit', label=_('Outer Dimensions')),
@ -224,7 +227,7 @@ class RackTypeForm(NetBoxModelForm):
class Meta: class Meta:
model = RackType model = RackType
fields = [ 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', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit',
'airflow', 'description', 'comments', 'tags', 'airflow', 'description', 'comments', 'tags',
] ]
@ -311,9 +314,7 @@ class RackReservationForm(TenancyForm, NetBoxModelForm):
) )
user = forms.ModelChoiceField( user = forms.ModelChoiceField(
label=_('User'), label=_('User'),
queryset=get_user_model().objects.order_by( queryset=User.objects.order_by('username')
'username'
)
) )
comments = CommentField() comments = CommentField()

View File

@ -613,7 +613,6 @@ class PowerPortTemplateType(ModularComponentTemplateType):
filters=RackTypeFilter filters=RackTypeFilter
) )
class RackTypeType(NetBoxObjectType): class RackTypeType(NetBoxObjectType):
_name: str
manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')] manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')]

View File

@ -37,14 +37,7 @@ class Migration(migrations.Migration):
related_name='rack_types', related_name='rack_types',
to='dcim.manufacturer' to='dcim.manufacturer'
)), )),
('name', models.CharField(max_length=100)), ('model', models.CharField(max_length=100)),
('_name', utilities.fields.NaturalOrderingField(
'name',
blank=True,
max_length=100,
naturalize_function=utilities.ordering.naturalize
),
),
('slug', models.SlugField(max_length=100, unique=True)), ('slug', models.SlugField(max_length=100, unique=True)),
('form_factor', models.CharField(blank=True, max_length=50)), ('form_factor', models.CharField(blank=True, max_length=50)),
('width', models.PositiveSmallIntegerField(default=19)), ('width', models.PositiveSmallIntegerField(default=19)),
@ -71,7 +64,7 @@ class Migration(migrations.Migration):
options={ options={
'verbose_name': 'racktype', 'verbose_name': 'racktype',
'verbose_name_plural': 'racktypes', 'verbose_name_plural': 'racktypes',
'ordering': ('_name', 'pk'), 'ordering': ('manufacturer', 'model'),
}, },
), ),
migrations.RenameField( migrations.RenameField(
@ -90,4 +83,16 @@ class Migration(migrations.Migration):
to='dcim.racktype', 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'
),
),
] ]

View File

@ -221,7 +221,7 @@ class DeviceType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
return reverse('dcim:devicetype', args=[self.pk]) return reverse('dcim:devicetype', args=[self.pk])
@property @property
def get_full_name(self): def full_name(self):
return f"{self.manufacturer} {self.model}" return f"{self.manufacturer} {self.model}"
def to_yaml(self): def to_yaml(self):
@ -417,6 +417,10 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
def get_absolute_url(self): def get_absolute_url(self):
return reverse('dcim:moduletype', args=[self.pk]) return reverse('dcim:moduletype', args=[self.pk])
@property
def full_name(self):
return f"{self.manufacturer} {self.model}"
def to_yaml(self): def to_yaml(self):
data = { data = {
'manufacturer': self.manufacturer.name, 'manufacturer': self.manufacturer.name,

View File

@ -136,15 +136,10 @@ class RackType(RackBase):
on_delete=models.PROTECT, on_delete=models.PROTECT,
related_name='rack_types' related_name='rack_types'
) )
name = models.CharField( model = models.CharField(
verbose_name=_('name'), verbose_name=_('model'),
max_length=100 max_length=100
) )
_name = NaturalOrderingField(
target_field='name',
max_length=100,
blank=True
)
slug = models.SlugField( slug = models.SlugField(
verbose_name=_('slug'), verbose_name=_('slug'),
max_length=100, max_length=100,
@ -152,24 +147,38 @@ class RackType(RackBase):
) )
clone_fields = ( clone_fields = (
'manufacturer', 'form_factor', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'manufacturer', 'form_factor', 'width', 'u_height', 'airflow', 'desc_units', 'outer_width', 'outer_depth',
'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit',
) )
prerequisite_models = ( prerequisite_models = (
'dcim.Manufacturer', 'dcim.Manufacturer',
) )
class Meta: 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 = _('rack type')
verbose_name_plural = _('rack types') verbose_name_plural = _('rack types')
def __str__(self): def __str__(self):
return self.name return self.model
def get_absolute_url(self): def get_absolute_url(self):
return reverse('dcim:racktype', args=[self.pk]) return reverse('dcim:racktype', args=[self.pk])
@property
def full_name(self):
return f"{self.manufacturer} {self.model}"
def clean(self): def clean(self):
super().clean() super().clean()

View File

@ -246,7 +246,7 @@ class PowerPortIndex(SearchIndex):
class RackTypeIndex(SearchIndex): class RackTypeIndex(SearchIndex):
model = models.RackType model = models.RackType
fields = ( fields = (
('name', 100), ('model', 100),
('description', 500), ('description', 500),
('comments', 5000), ('comments', 5000),
) )

View File

@ -50,9 +50,8 @@ class RackRoleTable(NetBoxTable):
# #
class RackTypeTable(NetBoxTable): class RackTypeTable(NetBoxTable):
name = tables.Column( model = tables.Column(
verbose_name=_('Name'), verbose_name=_('Model'),
order_by=('_name',),
linkify=True linkify=True
) )
manufacturer = tables.Column( manufacturer = tables.Column(
@ -84,6 +83,11 @@ class RackTypeTable(NetBoxTable):
comments = columns.MarkdownColumn( comments = columns.MarkdownColumn(
verbose_name=_('Comments'), verbose_name=_('Comments'),
) )
instance_count = columns.LinkedCountColumn(
viewname='dcim:rack_list',
url_params={'rack_type_id': 'pk'},
verbose_name=_('Instances')
)
tags = columns.TagColumn( tags = columns.TagColumn(
url_name='dcim:rack_list' url_name='dcim:rack_list'
) )
@ -91,12 +95,12 @@ class RackTypeTable(NetBoxTable):
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = RackType model = RackType
fields = ( fields = (
'pk', 'id', 'name', 'manufacturer', 'form_factor', 'u_height', 'starting_unit', 'width', 'outer_width', 'pk', 'id', 'model', 'manufacturer', 'form_factor', 'u_height', 'starting_unit', 'width', 'outer_width',
'outer_depth', 'mounting_depth', 'airflow', 'weight', 'max_weight', 'description', 'comments', 'tags', 'outer_depth', 'mounting_depth', 'airflow', 'weight', 'max_weight', 'description', 'comments',
'created', 'last_updated', 'instance_count', 'tags', 'created', 'last_updated',
) )
default_columns = ( 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( role = columns.ColoredLabelColumn(
verbose_name=_('Role'), 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( u_height = tables.TemplateColumn(
template_code="{{ value }}U", template_code="{{ value }}U",
verbose_name=_('Height') verbose_name=_('Height')
@ -169,14 +182,14 @@ class RackTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
class Meta(NetBoxTable.Meta): class Meta(NetBoxTable.Meta):
model = Rack model = Rack
fields = ( fields = (
'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'tenant_group', 'role', 'serial', 'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'tenant_group', 'role',
'asset_tag', 'form_factor', 'u_height', 'starting_unit', 'width', 'outer_width', 'outer_depth', 'rack_type', 'serial', 'asset_tag', 'form_factor', 'u_height', 'starting_unit', 'width', 'outer_width',
'mounting_depth', 'airflow', 'weight', 'max_weight', 'comments', 'device_count', 'get_utilization', 'outer_depth', 'mounting_depth', 'airflow', 'weight', 'max_weight', 'comments', 'device_count',
'get_power_utilization', 'description', 'contacts', 'tags', 'created', 'last_updated', 'get_utilization', 'get_power_utilization', 'description', 'contacts', 'tags', 'created', 'last_updated',
) )
default_columns = ( default_columns = (
'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'u_height', 'device_count', 'pk', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'role', 'rack_type', 'u_height',
'get_utilization', 'device_count', 'get_utilization',
) )

View File

@ -1,4 +1,3 @@
from django.contrib.auth import get_user_model
from django.test import override_settings from django.test import override_settings
from django.urls import reverse from django.urls import reverse
from django.utils.translation import gettext as _ 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 ipam.models import ASN, RIR, VLAN, VRF
from netbox.api.serializers import GenericObjectSerializer from netbox.api.serializers import GenericObjectSerializer
from tenancy.models import Tenant from tenancy.models import Tenant
from users.models import User
from utilities.testing import APITestCase, APIViewTestCases, create_test_device from utilities.testing import APITestCase, APIViewTestCases, create_test_device
from virtualization.models import Cluster, ClusterType from virtualization.models import Cluster, ClusterType
from wireless.choices import WirelessChannelChoices from wireless.choices import WirelessChannelChoices
from wireless.models import WirelessLAN from wireless.models import WirelessLAN
User = get_user_model()
class AppTest(APITestCase): class AppTest(APITestCase):
def test_root(self): def test_root(self):
@ -276,7 +273,7 @@ class RackRoleTest(APIViewTestCases.APIViewTestCase):
class RackTypeTest(APIViewTestCases.APIViewTestCase): class RackTypeTest(APIViewTestCases.APIViewTestCase):
model = RackType model = RackType
brief_fields = ['description', 'display', 'id', 'manufacturer', 'name', 'slug', 'url'] brief_fields = ['description', 'display', 'id', 'manufacturer', 'model', 'slug', 'url']
bulk_update_data = { bulk_update_data = {
'description': 'new description', 'description': 'new description',
} }
@ -290,26 +287,26 @@ class RackTypeTest(APIViewTestCases.APIViewTestCase):
Manufacturer.objects.bulk_create(manufacturers) Manufacturer.objects.bulk_create(manufacturers)
rack_types = ( rack_types = (
RackType(manufacturer=manufacturers[0], name='Rack Type 1', slug='rack-type-1'), RackType(manufacturer=manufacturers[0], model='Rack Type 1', slug='rack-type-1'),
RackType(manufacturer=manufacturers[0], name='Rack Type 2', slug='rack-type-2'), RackType(manufacturer=manufacturers[0], model='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 3', slug='rack-type-3'),
) )
RackType.objects.bulk_create(rack_types) RackType.objects.bulk_create(rack_types)
cls.create_data = [ cls.create_data = [
{ {
'manufacturer': manufacturers[1].pk, 'manufacturer': manufacturers[1].pk,
'name': 'Rack Type 4', 'model': 'Rack Type 4',
'slug': 'rack-type-4', 'slug': 'rack-type-4',
}, },
{ {
'manufacturer': manufacturers[1].pk, 'manufacturer': manufacturers[1].pk,
'name': 'Rack Type 5', 'model': 'Rack Type 5',
'slug': 'rack-type-5', 'slug': 'rack-type-5',
}, },
{ {
'manufacturer': manufacturers[1].pk, 'manufacturer': manufacturers[1].pk,
'name': 'Rack Type 6', 'model': 'Rack Type 6',
'slug': 'rack-type-6', 'slug': 'rack-type-6',
}, },
] ]

View File

@ -1,4 +1,3 @@
from django.contrib.auth import get_user_model
from django.test import TestCase from django.test import TestCase
from circuits.models import Circuit, CircuitTermination, CircuitType, Provider 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 ipam.models import ASN, IPAddress, RIR, VRF
from netbox.choices import ColorChoices from netbox.choices import ColorChoices
from tenancy.models import Tenant, TenantGroup from tenancy.models import Tenant, TenantGroup
from users.models import User
from utilities.testing import ChangeLoggedFilterSetTests, create_test_device from utilities.testing import ChangeLoggedFilterSetTests, create_test_device
from virtualization.models import Cluster, ClusterType, ClusterGroup from virtualization.models import Cluster, ClusterType, ClusterGroup
from wireless.choices import WirelessChannelChoices, WirelessRoleChoices from wireless.choices import WirelessChannelChoices, WirelessRoleChoices
User = get_user_model()
class DeviceComponentFilterSetTests: class DeviceComponentFilterSetTests:
@ -484,7 +482,7 @@ class RackTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
racks = ( racks = (
RackType( RackType(
manufacturer=manufacturers[0], manufacturer=manufacturers[0],
name='RackType 1', model='RackType 1',
slug='rack-type-1', slug='rack-type-1',
form_factor=RackFormFactorChoices.TYPE_2POST, form_factor=RackFormFactorChoices.TYPE_2POST,
width=RackWidthChoices.WIDTH_19IN, width=RackWidthChoices.WIDTH_19IN,
@ -502,7 +500,7 @@ class RackTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
), ),
RackType( RackType(
manufacturer=manufacturers[1], manufacturer=manufacturers[1],
name='RackType 2', model='RackType 2',
slug='rack-type-2', slug='rack-type-2',
form_factor=RackFormFactorChoices.TYPE_4POST, form_factor=RackFormFactorChoices.TYPE_4POST,
width=RackWidthChoices.WIDTH_21IN, width=RackWidthChoices.WIDTH_21IN,
@ -520,7 +518,7 @@ class RackTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
), ),
RackType( RackType(
manufacturer=manufacturers[2], manufacturer=manufacturers[2],
name='RackType 3', model='RackType 3',
slug='rack-type-3', slug='rack-type-3',
form_factor=RackFormFactorChoices.TYPE_CABINET, form_factor=RackFormFactorChoices.TYPE_CABINET,
width=RackWidthChoices.WIDTH_23IN, width=RackWidthChoices.WIDTH_23IN,
@ -550,8 +548,8 @@ class RackTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'manufacturer': [manufacturers[0].slug, manufacturers[1].slug]} params = {'manufacturer': [manufacturers[0].slug, manufacturers[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_name(self): def test_model(self):
params = {'name': ['RackType 1', 'RackType 2']} params = {'model': ['RackType 1', 'RackType 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_slug(self): def test_slug(self):
@ -653,6 +651,53 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
for location in locations: for location in locations:
location.save() 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 = ( rack_roles = (
RackRole(name='Rack Role 1', slug='rack-role-1'), RackRole(name='Rack Role 1', slug='rack-role-1'),
RackRole(name='Rack Role 2', slug='rack-role-2'), RackRole(name='Rack Role 2', slug='rack-role-2'),
@ -742,6 +787,28 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
weight_unit=WeightUnitChoices.UNIT_KILOGRAM, weight_unit=WeightUnitChoices.UNIT_KILOGRAM,
description='foobar3' 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) Rack.objects.bulk_create(racks)
@ -771,15 +838,15 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
def test_width(self): def test_width(self):
params = {'width': [RackWidthChoices.WIDTH_19IN, RackWidthChoices.WIDTH_21IN]} 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): def test_u_height(self):
params = {'u_height': [42, 43]} 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): def test_starting_unit(self):
params = {'starting_unit': [1]} 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]} params = {'starting_unit': [2]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 0)
@ -787,7 +854,7 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'desc_units': 'true'} params = {'desc_units': 'true'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
params = {'desc_units': 'false'} 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): def test_outer_width(self):
params = {'outer_width': [100, 200]} params = {'outer_width': [100, 200]}
@ -798,7 +865,7 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_outer_unit(self): 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} params = {'outer_unit': RackDimensionUnitChoices.UNIT_MILLIMETER}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@ -832,7 +899,7 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
def test_status(self): def test_status(self):
params = {'status': [RackStatusChoices.STATUS_ACTIVE, RackStatusChoices.STATUS_PLANNED]} 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): def test_role(self):
roles = RackRole.objects.all()[:2] roles = RackRole.objects.all()[:2]
@ -873,6 +940,20 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'weight_unit': WeightUnitChoices.UNIT_POUND} params = {'weight_unit': WeightUnitChoices.UNIT_POUND}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) 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): class RackReservationTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = RackReservation.objects.all() queryset = RackReservation.objects.all()

View File

@ -82,7 +82,7 @@ class RackTypeTestCase(TestCase):
RackType.objects.create( RackType.objects.create(
manufacturer=manufacturer, manufacturer=manufacturer,
name='RackType 1', model='RackType 1',
slug='rack-type-1', slug='rack-type-1',
width=11, width=11,
u_height=22, u_height=22,

View File

@ -2,7 +2,6 @@ from decimal import Decimal
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
import yaml import yaml
from django.contrib.auth import get_user_model
from django.test import override_settings from django.test import override_settings
from django.urls import reverse from django.urls import reverse
from netaddr import EUI from netaddr import EUI
@ -13,11 +12,10 @@ from dcim.models import *
from ipam.models import ASN, RIR, VLAN, VRF from ipam.models import ASN, RIR, VLAN, VRF
from netbox.choices import CSVDelimiterChoices, ImportFormatChoices from netbox.choices import CSVDelimiterChoices, ImportFormatChoices
from tenancy.models import Tenant from tenancy.models import Tenant
from users.models import User
from utilities.testing import ViewTestCases, create_tags, create_test_device, post_data from utilities.testing import ViewTestCases, create_tags, create_test_device, post_data
from wireless.models import WirelessLAN from wireless.models import WirelessLAN
User = get_user_model()
class RegionTestCase(ViewTestCases.OrganizationalObjectViewTestCase): class RegionTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
model = Region model = Region
@ -348,9 +346,9 @@ class RackTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
Manufacturer.objects.bulk_create(manufacturers) Manufacturer.objects.bulk_create(manufacturers)
rack_types = ( rack_types = (
RackType(manufacturer=manufacturers[0], name='RackType 1', slug='rack-type-1',), RackType(manufacturer=manufacturers[0], model='RackType 1', slug='rack-type-1',),
RackType(manufacturer=manufacturers[0], name='RackType 2', slug='rack-type-2',), RackType(manufacturer=manufacturers[0], model='RackType 2', slug='rack-type-2',),
RackType(manufacturer=manufacturers[0], name='RackType 3', slug='rack-type-3',), RackType(manufacturer=manufacturers[0], model='RackType 3', slug='rack-type-3',),
) )
RackType.objects.bulk_create(rack_types) RackType.objects.bulk_create(rack_types)
@ -358,7 +356,7 @@ class RackTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
cls.form_data = { cls.form_data = {
'manufacturer': manufacturers[1].pk, 'manufacturer': manufacturers[1].pk,
'name': 'RackType X', 'model': 'RackType X',
'slug': 'rack-type-x', 'slug': 'rack-type-x',
'type': RackFormFactorChoices.TYPE_CABINET, 'type': RackFormFactorChoices.TYPE_CABINET,
'width': RackWidthChoices.WIDTH_19IN, 'width': RackWidthChoices.WIDTH_19IN,
@ -376,14 +374,14 @@ class RackTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
} }
cls.csv_data = ( 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 4,rack-type-4,19,42,100,2000,kg",
"Manufacturer 1,RackType 5,rack-type-5,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", "Manufacturer 1,RackType 6,rack-type-6,19,42,100,2000,kg",
) )
cls.csv_update_data = ( cls.csv_update_data = (
"id,name", "id,model",
f"{rack_types[0].pk},RackType 7", f"{rack_types[0].pk},RackType 7",
f"{rack_types[1].pk},RackType 8", f"{rack_types[1].pk},RackType 8",
f"{rack_types[2].pk},RackType 9", f"{rack_types[2].pk},RackType 9",

View File

@ -584,7 +584,9 @@ class RackRoleBulkDeleteView(generic.BulkDeleteView):
# #
class RackTypeListView(generic.ObjectListView): class RackTypeListView(generic.ObjectListView):
queryset = RackType.objects.all() queryset = RackType.objects.annotate(
instance_count=count_related(Rack, 'rack_type')
)
filterset = filtersets.RackTypeFilterSet filterset = filtersets.RackTypeFilterSet
filterset_form = forms.RackTypeFilterForm filterset_form = forms.RackTypeFilterForm
table = tables.RackTypeTable table = tables.RackTypeTable

View File

@ -1,4 +1,3 @@
from django.contrib.auth import get_user_model
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from drf_spectacular.utils import extend_schema_field from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers from rest_framework import serializers
@ -8,6 +7,7 @@ from extras.choices import *
from extras.models import JournalEntry from extras.models import JournalEntry
from netbox.api.fields import ChoiceField, ContentTypeField from netbox.api.fields import ChoiceField, ContentTypeField
from netbox.api.serializers import NetBoxModelSerializer from netbox.api.serializers import NetBoxModelSerializer
from users.models import User
from utilities.api import get_serializer_for_model from utilities.api import get_serializer_for_model
__all__ = ( __all__ = (
@ -22,7 +22,7 @@ class JournalEntrySerializer(NetBoxModelSerializer):
assigned_object = serializers.SerializerMethodField(read_only=True) assigned_object = serializers.SerializerMethodField(read_only=True)
created_by = serializers.PrimaryKeyRelatedField( created_by = serializers.PrimaryKeyRelatedField(
allow_null=True, allow_null=True,
queryset=get_user_model().objects.all(), queryset=User.objects.all(),
required=False, required=False,
default=serializers.CurrentUserDefault() default=serializers.CurrentUserDefault()
) )

View File

@ -1,9 +1,7 @@
from collections import defaultdict
import logging import logging
from collections import defaultdict from collections import defaultdict
from django.conf import settings from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.utils import timezone from django.utils import timezone
from django.utils.module_loading import import_string from django.utils.module_loading import import_string
@ -14,6 +12,7 @@ from core.events import *
from netbox.config import get_config from netbox.config import get_config
from netbox.constants import RQ_QUEUE_DEFAULT from netbox.constants import RQ_QUEUE_DEFAULT
from netbox.registry import registry from netbox.registry import registry
from users.models import User
from utilities.api import get_serializer_for_model from utilities.api import get_serializer_for_model
from utilities.rqworker import get_rq_retry from utilities.rqworker import get_rq_retry
from utilities.serialization import serialize_object 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): 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: for event_rule in event_rules:

View File

@ -1,5 +1,4 @@
import django_filters import django_filters
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db.models import Q from django.db.models import Q
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
@ -284,12 +283,12 @@ class SavedFilterFilterSet(ChangeLoggedModelFilterSet):
field_name='object_types' field_name='object_types'
) )
user_id = django_filters.ModelMultipleChoiceFilter( user_id = django_filters.ModelMultipleChoiceFilter(
queryset=get_user_model().objects.all(), queryset=User.objects.all(),
label=_('User (ID)'), label=_('User (ID)'),
) )
user = django_filters.ModelMultipleChoiceFilter( user = django_filters.ModelMultipleChoiceFilter(
field_name='user__username', field_name='user__username',
queryset=get_user_model().objects.all(), queryset=User.objects.all(),
to_field_name='username', to_field_name='username',
label=_('User (name)'), label=_('User (name)'),
) )
@ -328,12 +327,12 @@ class BookmarkFilterSet(BaseFilterSet):
object_type_id = MultiValueNumberFilter() object_type_id = MultiValueNumberFilter()
object_type = ContentTypeFilter() object_type = ContentTypeFilter()
user_id = django_filters.ModelMultipleChoiceFilter( user_id = django_filters.ModelMultipleChoiceFilter(
queryset=get_user_model().objects.all(), queryset=User.objects.all(),
label=_('User (ID)'), label=_('User (ID)'),
) )
user = django_filters.ModelMultipleChoiceFilter( user = django_filters.ModelMultipleChoiceFilter(
field_name='user__username', field_name='user__username',
queryset=get_user_model().objects.all(), queryset=User.objects.all(),
to_field_name='username', to_field_name='username',
label=_('User (name)'), label=_('User (name)'),
) )
@ -410,12 +409,12 @@ class JournalEntryFilterSet(NetBoxModelFilterSet):
queryset=ContentType.objects.all() queryset=ContentType.objects.all()
) )
created_by_id = django_filters.ModelMultipleChoiceFilter( created_by_id = django_filters.ModelMultipleChoiceFilter(
queryset=get_user_model().objects.all(), queryset=User.objects.all(),
label=_('User (ID)'), label=_('User (ID)'),
) )
created_by = django_filters.ModelMultipleChoiceFilter( created_by = django_filters.ModelMultipleChoiceFilter(
field_name='created_by__username', field_name='created_by__username',
queryset=get_user_model().objects.all(), queryset=User.objects.all(),
to_field_name='username', to_field_name='username',
label=_('User (name)'), label=_('User (name)'),
) )

View File

@ -1,5 +1,4 @@
from django import forms from django import forms
from django.contrib.auth import get_user_model
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from core.models import ObjectType, DataFile, DataSource 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.base import NetBoxModelFilterSetForm
from netbox.forms.mixins import SavedFiltersMixin from netbox.forms.mixins import SavedFiltersMixin
from tenancy.models import Tenant, TenantGroup 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 import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
from utilities.forms.fields import ( from utilities.forms.fields import (
ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, TagFilterField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, TagFilterField,
@ -453,7 +452,7 @@ class JournalEntryFilterForm(NetBoxModelFilterSetForm):
widget=DateTimePicker() widget=DateTimePicker()
) )
created_by_id = DynamicModelMultipleChoiceField( created_by_id = DynamicModelMultipleChoiceField(
queryset=get_user_model().objects.all(), queryset=User.objects.all(),
required=False, required=False,
label=_('User') label=_('User')
) )
@ -472,7 +471,7 @@ class JournalEntryFilterForm(NetBoxModelFilterSetForm):
class NotificationGroupFilterForm(SavedFiltersMixin, FilterForm): class NotificationGroupFilterForm(SavedFiltersMixin, FilterForm):
user_id = DynamicModelMultipleChoiceField( user_id = DynamicModelMultipleChoiceField(
queryset=get_user_model().objects.all(), queryset=User.objects.all(),
required=False, required=False,
label=_('User') label=_('User')
) )

View File

@ -5,11 +5,11 @@ from contextlib import nullcontext
from django.db import transaction from django.db import transaction
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from core.signals import clear_events
from extras.models import Script as ScriptModel from extras.models import Script as ScriptModel
from extras.signals import clear_events
from netbox.context_managers import event_tracking from netbox.context_managers import event_tracking
from netbox.jobs import JobRunner
from utilities.exceptions import AbortScript, AbortTransaction from utilities.exceptions import AbortScript, AbortTransaction
from utilities.jobs import JobRunner
from .utils import is_report from .utils import is_report

View File

@ -3,12 +3,11 @@ import logging
import sys import sys
import uuid import uuid
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand, CommandError from django.core.management.base import BaseCommand, CommandError
from django.utils.module_loading import import_string
from extras.jobs import ScriptJob from extras.jobs import ScriptJob
from extras.scripts import get_module_and_script from extras.scripts import get_module_and_script
from users.models import User
from utilities.request import NetBoxFakeRequest from utilities.request import NetBoxFakeRequest
@ -28,8 +27,6 @@ class Command(BaseCommand):
parser.add_argument('script', help="Script to run") parser.add_argument('script', help="Script to run")
def handle(self, *args, **options): def handle(self, *args, **options):
User = get_user_model()
# Params # Params
script = options['script'] script = options['script']
loglevel = options['loglevel'] loglevel = options['loglevel']

View File

@ -1,4 +1,3 @@
from django.contrib.auth import get_user_model
from django.db import models from django.db import models
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -11,7 +10,7 @@ __all__ = (
class Dashboard(models.Model): class Dashboard(models.Model):
user = models.OneToOneField( user = models.OneToOneField(
to=get_user_model(), to='users.User',
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name='dashboard' related_name='dashboard'
) )

View File

@ -27,7 +27,7 @@ def get_event_type_choices():
""" """
return [ return [
(name, event.text) (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. 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): class NotificationGroup(ChangeLoggedModel):

View File

@ -1,6 +1,5 @@
import logging import logging
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
from django.db import models, transaction from django.db import models, transaction
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -34,7 +33,7 @@ class Branch(ChangeLoggedModel):
blank=True blank=True
) )
user = models.ForeignKey( user = models.ForeignKey(
to=get_user_model(), to='users.User',
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
blank=True, blank=True,
null=True null=True

View File

@ -1,194 +1,18 @@
import importlib
import logging
from django.contrib.contenttypes.models import ContentType 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.db.models.signals import m2m_changed, post_save, pre_delete
from django.dispatch import receiver, Signal from django.dispatch import receiver
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.events import *
from core.models import ObjectChange, ObjectType from core.models import ObjectType
from core.signals import job_end, job_start from core.signals import job_end, job_start
from extras.events import process_event_rules from extras.events import process_event_rules
from extras.models import EventRule, Notification, Subscription from extras.models import EventRule, Notification, Subscription
from netbox.config import get_config 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.registry import registry
from netbox.signals import post_clean from netbox.signals import post_clean
from utilities.exceptions import AbortRequest from utilities.exceptions import AbortRequest
from .events import enqueue_event
from .models import CustomField, TaggedItem from .models import CustomField, TaggedItem
from .validators import CustomValidator from .utils import run_validators
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({})
# #

View File

@ -1,5 +1,19 @@
import importlib
from django.core.exceptions import ImproperlyConfigured
from taggit.managers import _TaggableManager 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): def is_taggable(obj):
""" """
@ -48,3 +62,25 @@ def is_report(obj):
return issubclass(obj, Report) and obj != Report return issubclass(obj, Report) and obj != Report
except TypeError: except TypeError:
return False 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)

View File

@ -551,14 +551,6 @@ class EventRuleListView(generic.ObjectListView):
class EventRuleView(generic.ObjectView): class EventRuleView(generic.ObjectView):
queryset = EventRule.objects.all() 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') @register_model_view(EventRule, 'edit')
class EventRuleEditView(generic.ObjectEditView): class EventRuleEditView(generic.ObjectEditView):

View File

@ -90,42 +90,45 @@ def add_available_ipaddresses(prefix, ipaddress_list, is_pool=False):
return output 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 Create fake records for all gaps between used VLANs
""" """
min_vid = int(vlan_range.lower) if vlan_range else VLAN_VID_MIN min_vid = int(vid_range.lower) if vid_range else VLAN_VID_MIN
max_vid = int(vlan_range.upper) if vlan_range else VLAN_VID_MAX max_vid = int(vid_range.upper) if vid_range else VLAN_VID_MAX
if not vlans: if not vlans:
return [{ return [{
'vid': min_vid, 'vid': min_vid,
'vlan_group': vlan_group, '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 = [] new_vlans = []
for vlan in 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: if vlan.vid - prev_vid > 1:
new_vlans.append({ new_vlans.append({
'vid': prev_vid + 1, 'vid': prev_vid + 1,
'vlan_group': vlan_group, 'vlan_group': vlan_group,
'available': vlan.vid - prev_vid - 1, 'available': vlan.vid - prev_vid - 1,
}) })
prev_vid = vlan.vid prev_vid = vlan.vid
if vlans[0].vid > min_vid: # Annotate any remaining available VLANs
new_vlans.append({
'vid': min_vid,
'vlan_group': vlan_group,
'available': vlans[0].vid - min_vid,
})
if prev_vid < max_vid: if prev_vid < max_vid:
new_vlans.append({ new_vlans.append({
'vid': prev_vid + 1, 'vid': prev_vid + 1,
'vlan_group': vlan_group, 'vlan_group': vlan_group,
'available': max_vid - prev_vid, 'available': max_vid - prev_vid - 1,
}) })
return new_vlans return new_vlans
@ -136,8 +139,8 @@ def add_available_vlans(vlans, vlan_group):
Create fake records for all gaps between used VLANs Create fake records for all gaps between used VLANs
""" """
new_vlans = [] new_vlans = []
for vlan_range in vlan_group.vid_ranges: for vid_range in vlan_group.vid_ranges:
new_vlans.extend(available_vlans_from_range(vlans, vlan_group, vlan_range)) new_vlans.extend(available_vlans_from_range(vlans, vlan_group, vid_range))
vlans = list(vlans) + new_vlans vlans = list(vlans) + new_vlans
vlans.sort(key=lambda v: v.vid if type(v) is VLAN else v['vid']) vlans.sort(key=lambda v: v.vid if type(v) is VLAN else v['vid'])

View File

@ -2,7 +2,6 @@ import logging
from collections import defaultdict from collections import defaultdict
from django.conf import settings 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.backends import ModelBackend, RemoteUserBackend as _RemoteUserBackend
from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import AnonymousUser
from django.core.exceptions import ImproperlyConfigured 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 django.utils.translation import gettext_lazy as _
from users.constants import CONSTRAINT_TOKEN_USER from users.constants import CONSTRAINT_TOKEN_USER
from users.models import Group, ObjectPermission from users.models import Group, ObjectPermission, User
from utilities.permissions import ( from utilities.permissions import (
permission_is_exempt, qs_filter_from_constraints, resolve_permission, resolve_permission_type, permission_is_exempt, qs_filter_from_constraints, resolve_permission, resolve_permission_type,
) )
from .misc import _mirror_groups from .misc import _mirror_groups
UserModel = get_user_model()
AUTH_BACKEND_ATTRS = { AUTH_BACKEND_ATTRS = {
# backend name: title, MDI icon name # backend name: title, MDI icon name
'amazon': ('Amazon AWS', 'aws'), 'amazon': ('Amazon AWS', 'aws'),
@ -218,15 +215,15 @@ class RemoteUserBackend(_RemoteUserBackend):
# instead we use get_or_create when creating unknown users since it has # instead we use get_or_create when creating unknown users since it has
# built-in safeguards for multiple threads. # built-in safeguards for multiple threads.
if self.create_unknown_user: if self.create_unknown_user:
user, created = UserModel._default_manager.get_or_create(**{ user, created = User._default_manager.get_or_create(**{
UserModel.USERNAME_FIELD: username User.USERNAME_FIELD: username
}) })
if created: if created:
user = self.configure_user(request, user) user = self.configure_user(request, user)
else: else:
try: try:
user = UserModel._default_manager.get_by_natural_key(username) user = User._default_manager.get_by_natural_key(username)
except UserModel.DoesNotExist: except User.DoesNotExist:
pass pass
if self.user_can_authenticate(user): if self.user_can_authenticate(user):
if settings.REMOTE_AUTH_GROUP_SYNC_ENABLED: if settings.REMOTE_AUTH_GROUP_SYNC_ENABLED:

View File

@ -2,41 +2,41 @@ from dataclasses import dataclass
from netbox.registry import registry from netbox.registry import registry
EVENT_TYPE_INFO = 'info' EVENT_TYPE_KIND_INFO = 'info'
EVENT_TYPE_SUCCESS = 'success' EVENT_TYPE_KIND_SUCCESS = 'success'
EVENT_TYPE_WARNING = 'warning' EVENT_TYPE_KIND_WARNING = 'warning'
EVENT_TYPE_DANGER = 'danger' EVENT_TYPE_KIND_DANGER = 'danger'
__all__ = ( __all__ = (
'EVENT_TYPE_DANGER', 'EVENT_TYPE_KIND_DANGER',
'EVENT_TYPE_INFO', 'EVENT_TYPE_KIND_INFO',
'EVENT_TYPE_SUCCESS', 'EVENT_TYPE_KIND_SUCCESS',
'EVENT_TYPE_WARNING', 'EVENT_TYPE_KIND_WARNING',
'Event', 'EventType',
'get_event', 'get_event_type',
'get_event_type_choices', 'get_event_type_choices',
'get_event_text', 'get_event_text',
) )
def get_event(name): def get_event_type(name):
return registry['events'].get(name) return registry['event_types'].get(name)
def get_event_text(name): def get_event_text(name):
if event := registry['events'].get(name): if event := registry['event_types'].get(name):
return event.text return event.text
return '' return ''
def get_event_type_choices(): def get_event_type_choices():
return [ return [
(event.name, event.text) for event in registry['events'].values() (event.name, event.text) for event in registry['event_types'].values()
] ]
@dataclass @dataclass
class Event: class EventType:
""" """
A type of event which can occur in NetBox. Event rules can be defined to automatically A type of event which can occur in NetBox. Event rules can be defined to automatically
perform some action in response to an event. perform some action in response to an event.
@ -44,32 +44,32 @@ class Event:
Args: Args:
name: The unique name under which the event is registered. name: The unique name under which the event is registered.
text: The human-friendly event name. This should support translation. 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 name: str
text: str text: str
type: str = EVENT_TYPE_INFO kind: str = EVENT_TYPE_KIND_INFO
def __str__(self): def __str__(self):
return self.text return self.text
def register(self): def register(self):
if self.name in registry['events']: if self.name in registry['event_types']:
raise Exception(f"An event named {self.name} has already been registered!") raise Exception(f"An event type named {self.name} has already been registered!")
registry['events'][self.name] = self registry['event_types'][self.name] = self
def color(self): def color(self):
return { return {
EVENT_TYPE_INFO: 'blue', EVENT_TYPE_KIND_INFO: 'blue',
EVENT_TYPE_SUCCESS: 'green', EVENT_TYPE_KIND_SUCCESS: 'green',
EVENT_TYPE_WARNING: 'orange', EVENT_TYPE_KIND_WARNING: 'orange',
EVENT_TYPE_DANGER: 'red', EVENT_TYPE_KIND_DANGER: 'red',
}.get(self.type) }.get(self.kind)
def icon(self): def icon(self):
return { return {
EVENT_TYPE_INFO: 'mdi mdi-information', EVENT_TYPE_KIND_INFO: 'mdi mdi-information',
EVENT_TYPE_SUCCESS: 'mdi mdi-check-circle', EVENT_TYPE_KIND_SUCCESS: 'mdi mdi-check-circle',
EVENT_TYPE_WARNING: 'mdi mdi-alert-box', EVENT_TYPE_KIND_WARNING: 'mdi mdi-alert-box',
EVENT_TYPE_DANGER: 'mdi mdi-alert-octagon', EVENT_TYPE_KIND_DANGER: 'mdi mdi-alert-octagon',
}.get(self.type) }.get(self.kind)

View File

@ -25,7 +25,7 @@ registry = Registry({
'counter_fields': collections.defaultdict(dict), 'counter_fields': collections.defaultdict(dict),
'data_backends': dict(), 'data_backends': dict(),
'denormalized_fields': collections.defaultdict(list), 'denormalized_fields': collections.defaultdict(list),
'events': dict(), 'event_types': dict(),
'model_features': dict(), 'model_features': dict(),
'models': collections.defaultdict(set), 'models': collections.defaultdict(set),
'plugins': dict(), 'plugins': dict(),

View File

@ -1,7 +1,6 @@
import datetime import datetime
from django.conf import settings from django.conf import settings
from django.contrib.auth import get_user_model
from django.test import Client from django.test import Client
from django.test.utils import override_settings from django.test.utils import override_settings
from django.urls import reverse from django.urls import reverse
@ -11,14 +10,11 @@ from rest_framework.test import APIClient
from core.models import ObjectType from core.models import ObjectType
from dcim.models import Site from dcim.models import Site
from ipam.models import Prefix 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 import TestCase
from utilities.testing.api import APITestCase from utilities.testing.api import APITestCase
User = get_user_model()
class TokenAuthenticationTestCase(APITestCase): class TokenAuthenticationTestCase(APITestCase):
@override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*']) @override_settings(LOGIN_REQUIRED=True, EXEMPT_VIEW_PERMISSIONS=['*'])

View File

@ -8,7 +8,7 @@ from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist, Valida
from django.db import transaction, IntegrityError from django.db import transaction, IntegrityError
from django.db.models import ManyToManyField, ProtectedError, RestrictedError from django.db.models import ManyToManyField, ProtectedError, RestrictedError
from django.db.models.fields.reverse_related import ManyToManyRel 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.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse from django.urls import reverse
@ -17,8 +17,8 @@ from django.utils.translation import gettext as _
from django_tables2.export import TableExport from django_tables2.export import TableExport
from core.models import ObjectType from core.models import ObjectType
from core.signals import clear_events
from extras.models import ExportTemplate from extras.models import ExportTemplate
from extras.signals import clear_events
from utilities.error_handlers import handle_protectederror from utilities.error_handlers import handle_protectederror
from utilities.exceptions import AbortRequest, AbortTransaction, PermissionsViolation from utilities.exceptions import AbortRequest, AbortTransaction, PermissionsViolation
from utilities.forms import BulkRenameForm, ConfirmationForm, restrict_form_fields from utilities.forms import BulkRenameForm, ConfirmationForm, restrict_form_fields

View File

@ -13,7 +13,7 @@ from django.utils.html import escape
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.translation import gettext as _ 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.error_handlers import handle_protectederror
from utilities.exceptions import AbortRequest, PermissionsViolation from utilities.exceptions import AbortRequest, PermissionsViolation
from utilities.forms import ConfirmationForm, restrict_form_fields from utilities.forms import ConfirmationForm, restrict_form_fields

View File

@ -867,13 +867,20 @@ brace-expansion@^1.1.7:
balanced-match "^1.0.0" balanced-match "^1.0.0"
concat-map "0.0.1" concat-map "0.0.1"
braces@^3.0.2, braces@~3.0.2: braces@^3.0.2:
version "3.0.2" version "3.0.2"
resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107" resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107"
integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A== integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==
dependencies: dependencies:
fill-range "^7.0.1" 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: call-bind@^1.0.2, call-bind@^1.0.5, call-bind@^1.0.6, call-bind@^1.0.7:
version "1.0.7" version "1.0.7"
resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.7.tgz#06016599c40c56498c18769d2730be242b6fa3b9" 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: dependencies:
flat-cache "^3.0.4" flat-cache "^3.0.4"
fill-range@^7.0.1: fill-range@^7.0.1, fill-range@^7.1.1:
version "7.0.1" version "7.1.1"
resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40" resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.1.1.tgz#44265d3cac07e3ea7dc247516380643754a05292"
integrity sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ== integrity sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==
dependencies: dependencies:
to-regex-range "^5.0.1" to-regex-range "^5.0.1"
@ -1816,9 +1823,9 @@ ignore@^5.2.0:
integrity sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw== integrity sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==
immutable@^4.0.0: immutable@^4.0.0:
version "4.3.6" version "4.3.7"
resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.3.6.tgz#6a05f7858213238e587fb83586ffa3b4b27f0447" resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.3.7.tgz#c70145fc90d89fb02021e65c84eb0226e4e5a381"
integrity sha512-Ju0+lEMyzMVZarkTn/gqRpdqd5dOPaz1mCZ0SH3JV6iFw81PldE/PEB1hWVEA288HPt4WXW8O7AWxB10M+03QQ== integrity sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==
import-fresh@^3.2.1: import-fresh@^3.2.1:
version "3.3.0" version "3.3.0"

View File

@ -24,7 +24,12 @@
</div> </div>
{% endblock page-header %} {% 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 %} {% block controls %}{% endblock %}

View File

@ -87,7 +87,7 @@
<tr> <tr>
<th scope="row">{% trans "Device Type" %}</th> <th scope="row">{% trans "Device Type" %}</th>
<td> <td>
{{ 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)
</td> </td>
</tr> </tr>
<tr> <tr>

View File

@ -63,7 +63,7 @@
</tr> </tr>
<tr> <tr>
<th scope="row">{% trans "Module Type" %}</th> <th scope="row">{% trans "Module Type" %}</th>
<td>{{ object.module_type|linkify }}</td> <td>{{ object.module_type|linkify:"full_name" }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">{% trans "Status" %}</th> <th scope="row">{% trans "Status" %}</th>

View File

@ -43,7 +43,7 @@
</tr> </tr>
<tr> <tr>
<th scope="row">{% trans "Rack Type" %}</th> <th scope="row">{% trans "Rack Type" %}</th>
<td>{{ object.rack_type|linkify|placeholder }}</td> <td>{{ object.rack_type|linkify:"full_name"|placeholder }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">{% trans "Role" %}</th> <th scope="row">{% trans "Role" %}</th>

View File

@ -17,8 +17,8 @@
<td>{{ object.manufacturer|linkify }}</td> <td>{{ object.manufacturer|linkify }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">{% trans "Name" %}</th> <th scope="row">{% trans "Model" %}</th>
<td>{{ object.name }}</td> <td>{{ object.model }}</td>
</tr> </tr>
<tr> <tr>
<th scope="row">{% trans "Description" %}</th> <th scope="row">{% trans "Description" %}</th>

View File

@ -36,7 +36,7 @@
<div class="card"> <div class="card">
<h5 class="card-header">{% trans "Event Types" %}</h5> <h5 class="card-header">{% trans "Event Types" %}</h5>
<ul class="list-group list-group-flush"> <ul class="list-group list-group-flush">
{% for name, event in registry.events.items %} {% for name, event in registry.event_types.items %}
<li class="list-group-item"> <li class="list-group-item">
<div class="row align-items-center"> <div class="row align-items-center">
<div class="col-auto"> <div class="col-auto">

View File

@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-07-20 05:02+0000\n" "POT-Creation-Date: 2024-07-27 05:02+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@ -6955,7 +6955,7 @@ msgstr ""
#: netbox/extras/forms/model_forms.py:163 #: netbox/extras/forms/model_forms.py:163
#: netbox/extras/forms/model_forms.py:204 #: netbox/extras/forms/model_forms.py:204
#: netbox/extras/forms/model_forms.py:261 #: netbox/extras/forms/model_forms.py:261
#: netbox/extras/forms/model_forms.py:365 netbox/users/forms/model_forms.py:273 #: netbox/extras/forms/model_forms.py:365 netbox/users/forms/model_forms.py:277
msgid "Object types" msgid "Object types"
msgstr "" msgstr ""
@ -7321,7 +7321,7 @@ msgstr ""
#: netbox/templates/extras/configcontext.html:60 #: netbox/templates/extras/configcontext.html:60
#: netbox/templates/ipam/ipaddress.html:59 #: netbox/templates/ipam/ipaddress.html:59
#: netbox/templates/ipam/vlan_edit.html:30 #: netbox/templates/ipam/vlan_edit.html:30
#: netbox/tenancy/forms/filtersets.py:87 netbox/users/forms/model_forms.py:311 #: netbox/tenancy/forms/filtersets.py:87 netbox/users/forms/model_forms.py:315
msgid "Assignment" msgid "Assignment"
msgstr "" msgstr ""
@ -10299,13 +10299,13 @@ msgid "Admin"
msgstr "" msgstr ""
#: netbox/netbox/navigation/menu.py:374 netbox/templates/users/group.html:29 #: netbox/netbox/navigation/menu.py:374 netbox/templates/users/group.html:29
#: netbox/users/forms/model_forms.py:233 netbox/users/forms/model_forms.py:245 #: netbox/users/forms/model_forms.py:237 netbox/users/forms/model_forms.py:249
#: netbox/users/forms/model_forms.py:297 netbox/users/tables.py:102 #: netbox/users/forms/model_forms.py:301 netbox/users/tables.py:102
msgid "Users" msgid "Users"
msgstr "" msgstr ""
#: netbox/netbox/navigation/menu.py:394 netbox/users/forms/model_forms.py:182 #: netbox/netbox/navigation/menu.py:394 netbox/users/forms/model_forms.py:182
#: netbox/users/forms/model_forms.py:194 netbox/users/forms/model_forms.py:302 #: netbox/users/forms/model_forms.py:194 netbox/users/forms/model_forms.py:306
#: netbox/users/tables.py:35 netbox/users/tables.py:106 #: netbox/users/tables.py:35 netbox/users/tables.py:106
msgid "Groups" msgid "Groups"
msgstr "" msgstr ""
@ -10316,8 +10316,8 @@ msgid "API Tokens"
msgstr "" msgstr ""
#: netbox/netbox/navigation/menu.py:421 netbox/users/forms/model_forms.py:188 #: netbox/netbox/navigation/menu.py:421 netbox/users/forms/model_forms.py:188
#: netbox/users/forms/model_forms.py:196 netbox/users/forms/model_forms.py:239 #: netbox/users/forms/model_forms.py:196 netbox/users/forms/model_forms.py:243
#: netbox/users/forms/model_forms.py:246 #: netbox/users/forms/model_forms.py:250
msgid "Permissions" msgid "Permissions"
msgstr "" msgstr ""
@ -12009,7 +12009,7 @@ msgstr ""
#: netbox/templates/dcim/virtualchassis_add_member.html:27 #: netbox/templates/dcim/virtualchassis_add_member.html:27
#: netbox/templates/generic/object_edit.html:78 #: netbox/templates/generic/object_edit.html:78
#: netbox/templates/users/objectpermission.html:31 #: netbox/templates/users/objectpermission.html:31
#: netbox/users/forms/filtersets.py:68 netbox/users/forms/model_forms.py:309 #: netbox/users/forms/filtersets.py:68 netbox/users/forms/model_forms.py:313
msgid "Actions" msgid "Actions"
msgstr "" msgstr ""
@ -13104,7 +13104,7 @@ msgid "View"
msgstr "" msgstr ""
#: netbox/templates/users/objectpermission.html:52 #: netbox/templates/users/objectpermission.html:52
#: netbox/users/forms/model_forms.py:312 #: netbox/users/forms/model_forms.py:316
msgid "Constraints" msgid "Constraints"
msgstr "" msgstr ""
@ -13623,30 +13623,30 @@ msgstr ""
msgid "Passwords do not match! Please check your input and try again." msgid "Passwords do not match! Please check your input and try again."
msgstr "" msgstr ""
#: netbox/users/forms/model_forms.py:291 #: netbox/users/forms/model_forms.py:295
msgid "Additional actions" msgid "Additional actions"
msgstr "" msgstr ""
#: netbox/users/forms/model_forms.py:294 #: netbox/users/forms/model_forms.py:298
msgid "Actions granted in addition to those listed above" msgid "Actions granted in addition to those listed above"
msgstr "" msgstr ""
#: netbox/users/forms/model_forms.py:310 #: netbox/users/forms/model_forms.py:314
msgid "Objects" msgid "Objects"
msgstr "" msgstr ""
#: netbox/users/forms/model_forms.py:322 #: netbox/users/forms/model_forms.py:326
msgid "" msgid ""
"JSON expression of a queryset filter that will return only permitted " "JSON expression of a queryset filter that will return only permitted "
"objects. Leave null to match all objects of this type. A list of multiple " "objects. Leave null to match all objects of this type. A list of multiple "
"objects will result in a logical OR operation." "objects will result in a logical OR operation."
msgstr "" msgstr ""
#: netbox/users/forms/model_forms.py:361 #: netbox/users/forms/model_forms.py:365
msgid "At least one action must be selected." msgid "At least one action must be selected."
msgstr "" msgstr ""
#: netbox/users/forms/model_forms.py:379 #: netbox/users/forms/model_forms.py:383
#, python-brace-format #, python-brace-format
msgid "Invalid filter for {model}: {error}" msgid "Invalid filter for {model}: {error}"
msgstr "" msgstr ""

View File

@ -1,12 +1,11 @@
from django.contrib.auth import get_user_model
from drf_spectacular.utils import extend_schema_field
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers from rest_framework import serializers
from core.models import ObjectType from core.models import ObjectType
from netbox.api.fields import ContentTypeField from netbox.api.fields import ContentTypeField
from netbox.api.serializers import WritableNestedSerializer from netbox.api.serializers import WritableNestedSerializer
from users.models import Group, ObjectPermission, Token from users.models import Group, ObjectPermission, Token, User
__all__ = [ __all__ = [
'NestedGroupSerializer', 'NestedGroupSerializer',
@ -26,7 +25,7 @@ class NestedGroupSerializer(WritableNestedSerializer):
class NestedUserSerializer(WritableNestedSerializer): class NestedUserSerializer(WritableNestedSerializer):
class Meta: class Meta:
model = get_user_model() model = User
fields = ['id', 'url', 'display_url', 'display', 'username'] fields = ['id', 'url', 'display_url', 'display', 'username']
@extend_schema_field(OpenApiTypes.STR) @extend_schema_field(OpenApiTypes.STR)

View File

@ -1,11 +1,11 @@
from django.contrib.auth import get_user_model, password_validation from django.contrib.auth import password_validation
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers from rest_framework import serializers
from netbox.api.fields import SerializedPKRelatedField from netbox.api.fields import SerializedPKRelatedField
from netbox.api.serializers import ValidatedModelSerializer from netbox.api.serializers import ValidatedModelSerializer
from users.models import Group, ObjectPermission from users.models import Group, ObjectPermission, User
from .permissions import ObjectPermissionSerializer from .permissions import ObjectPermissionSerializer
__all__ = ( __all__ = (
@ -49,7 +49,7 @@ class UserSerializer(ValidatedModelSerializer):
) )
class Meta: class Meta:
model = get_user_model() model = User
fields = ( fields = (
'id', 'url', 'display_url', 'display', 'username', 'password', 'first_name', 'last_name', 'email', 'id', 'url', 'display_url', 'display', 'username', 'password', 'first_name', 'last_name', 'email',
'is_staff', 'is_active', 'date_joined', 'last_login', 'groups', 'permissions', 'is_staff', 'is_active', 'date_joined', 'last_login', 'groups', 'permissions',

View File

@ -1,6 +1,5 @@
import logging import logging
from django.contrib.auth import get_user_model
from django.db.models import Count from django.db.models import Count
from drf_spectacular.types import OpenApiTypes from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema from drf_spectacular.utils import extend_schema
@ -13,7 +12,7 @@ from rest_framework.viewsets import ViewSet
from netbox.api.viewsets import NetBoxModelViewSet from netbox.api.viewsets import NetBoxModelViewSet
from users import filtersets from users import filtersets
from users.models import Group, ObjectPermission, Token, UserConfig from users.models import Group, ObjectPermission, Token, User, UserConfig
from utilities.data import deepmerge from utilities.data import deepmerge
from utilities.querysets import RestrictedQuerySet from utilities.querysets import RestrictedQuerySet
from . import serializers from . import serializers
@ -32,7 +31,7 @@ class UsersRootView(APIRootView):
# #
class UserViewSet(NetBoxModelViewSet): class UserViewSet(NetBoxModelViewSet):
queryset = RestrictedQuerySet(model=get_user_model()).order_by('username') queryset = RestrictedQuerySet(model=User).order_by('username')
serializer_class = serializers.UserSerializer serializer_class = serializers.UserSerializer
filterset_class = filtersets.UserFilterSet filterset_class = filtersets.UserFilterSet

View File

@ -1,13 +1,12 @@
import django_filters import django_filters
from django.contrib.auth import get_user_model
from django.db.models import Q from django.db.models import Q
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from core.models import ObjectType from core.models import ObjectType
from extras.models import NotificationGroup from extras.models import NotificationGroup
from netbox.filtersets import BaseFilterSet from netbox.filtersets import BaseFilterSet
from users.models import Group, ObjectPermission, Token from users.models import Group, ObjectPermission, Token, User
from utilities.filters import ContentTypeFilter from utilities.filters import ContentTypeFilter
__all__ = ( __all__ = (
@ -25,7 +24,7 @@ class GroupFilterSet(BaseFilterSet):
) )
user_id = django_filters.ModelMultipleChoiceFilter( user_id = django_filters.ModelMultipleChoiceFilter(
field_name='user', field_name='user',
queryset=get_user_model().objects.all(), queryset=User.objects.all(),
label=_('User (ID)'), label=_('User (ID)'),
) )
permission_id = django_filters.ModelMultipleChoiceFilter( permission_id = django_filters.ModelMultipleChoiceFilter(
@ -80,7 +79,7 @@ class UserFilterSet(BaseFilterSet):
) )
class Meta: class Meta:
model = get_user_model() model = User
fields = ( fields = (
'id', 'username', 'first_name', 'last_name', 'email', 'date_joined', 'last_login', 'is_staff', 'is_active', 'id', 'username', 'first_name', 'last_name', 'email', 'date_joined', 'last_login', 'is_staff', 'is_active',
'is_superuser', 'is_superuser',
@ -104,12 +103,12 @@ class TokenFilterSet(BaseFilterSet):
) )
user_id = django_filters.ModelMultipleChoiceFilter( user_id = django_filters.ModelMultipleChoiceFilter(
field_name='user', field_name='user',
queryset=get_user_model().objects.all(), queryset=User.objects.all(),
label=_('User'), label=_('User'),
) )
user = django_filters.ModelMultipleChoiceFilter( user = django_filters.ModelMultipleChoiceFilter(
field_name='user__username', field_name='user__username',
queryset=get_user_model().objects.all(), queryset=User.objects.all(),
to_field_name='username', to_field_name='username',
label=_('User (name)'), label=_('User (name)'),
) )
@ -171,12 +170,12 @@ class ObjectPermissionFilterSet(BaseFilterSet):
) )
user_id = django_filters.ModelMultipleChoiceFilter( user_id = django_filters.ModelMultipleChoiceFilter(
field_name='users', field_name='users',
queryset=get_user_model().objects.all(), queryset=User.objects.all(),
label=_('User'), label=_('User'),
) )
user = django_filters.ModelMultipleChoiceFilter( user = django_filters.ModelMultipleChoiceFilter(
field_name='users__username', field_name='users__username',
queryset=get_user_model().objects.all(), queryset=User.objects.all(),
to_field_name='username', to_field_name='username',
label=_('User (name)'), label=_('User (name)'),
) )

View File

@ -1,5 +1,4 @@
from django import forms from django import forms
from django.contrib.auth import get_user_model
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from netbox.forms import NetBoxModelFilterSetForm from netbox.forms import NetBoxModelFilterSetForm
@ -80,7 +79,7 @@ class ObjectPermissionFilterForm(NetBoxModelFilterSetForm):
label=_('Group') label=_('Group')
) )
user_id = DynamicModelMultipleChoiceField( user_id = DynamicModelMultipleChoiceField(
queryset=get_user_model().objects.all(), queryset=User.objects.all(),
required=False, required=False,
label=_('User') label=_('User')
) )
@ -121,7 +120,7 @@ class TokenFilterForm(SavedFiltersMixin, FilterForm):
FieldSet('user_id', 'write_enabled', 'expires', 'last_used', name=_('Token')), FieldSet('user_id', 'write_enabled', 'expires', 'last_used', name=_('Token')),
) )
user_id = DynamicModelMultipleChoiceField( user_id = DynamicModelMultipleChoiceField(
queryset=get_user_model().objects.all(), queryset=User.objects.all(),
required=False, required=False,
label=_('User') label=_('User')
) )

View File

@ -1,6 +1,6 @@
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.contrib.auth import get_user_model, password_validation from django.contrib.auth import password_validation
from django.contrib.postgres.forms import SimpleArrayField from django.contrib.postgres.forms import SimpleArrayField
from django.core.exceptions import FieldError from django.core.exceptions import FieldError
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
@ -152,7 +152,7 @@ class UserTokenForm(forms.ModelForm):
class TokenForm(UserTokenForm): class TokenForm(UserTokenForm):
user = forms.ModelChoiceField( user = forms.ModelChoiceField(
queryset=get_user_model().objects.order_by('username'), queryset=User.objects.order_by('username'),
label=_('User') label=_('User')
) )
@ -236,7 +236,7 @@ class GroupForm(forms.ModelForm):
users = DynamicModelMultipleChoiceField( users = DynamicModelMultipleChoiceField(
label=_('Users'), label=_('Users'),
required=False, required=False,
queryset=get_user_model().objects.all() queryset=User.objects.all()
) )
object_permissions = DynamicModelMultipleChoiceField( object_permissions = DynamicModelMultipleChoiceField(
required=False, required=False,
@ -300,7 +300,7 @@ class ObjectPermissionForm(forms.ModelForm):
users = DynamicModelMultipleChoiceField( users = DynamicModelMultipleChoiceField(
label=_('Users'), label=_('Users'),
required=False, required=False,
queryset=get_user_model().objects.all() queryset=User.objects.all()
) )
groups = DynamicModelMultipleChoiceField( groups = DynamicModelMultipleChoiceField(
label=_('Groups'), label=_('Groups'),

View File

@ -1,9 +1,7 @@
import strawberry
import strawberry_django import strawberry_django
from django.contrib.auth import get_user_model
from users import filtersets, models
from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin
from users import filtersets, models
__all__ = ( __all__ = (
'GroupFilter', 'GroupFilter',
@ -17,7 +15,7 @@ class GroupFilter(BaseFilterMixin):
pass pass
@strawberry_django.filter(get_user_model(), lookups=True) @strawberry_django.filter(models.User, lookups=True)
@autotype_decorator(filtersets.UserFilterSet) @autotype_decorator(filtersets.UserFilterSet)
class UserFilter(BaseFilterMixin): class UserFilter(BaseFilterMixin):
pass pass

View File

@ -1,9 +1,8 @@
from typing import List from typing import List
import strawberry import strawberry
import strawberry_django import strawberry_django
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Group
from users import models from users import models
from .types import * from .types import *

View File

@ -1,10 +1,9 @@
from typing import List from typing import List
import strawberry_django import strawberry_django
from django.contrib.auth import get_user_model
from netbox.graphql.types import BaseObjectType from netbox.graphql.types import BaseObjectType
from users.models import Group from users.models import Group, User
from .filters import * from .filters import *
__all__ = ( __all__ = (
@ -23,7 +22,7 @@ class GroupType(BaseObjectType):
@strawberry_django.type( @strawberry_django.type(
get_user_model(), User,
fields=[ fields=[
'id', 'username', 'first_name', 'last_name', 'email', 'is_staff', 'is_active', 'date_joined', 'groups', 'id', 'username', 'first_name', 'last_name', 'email', 'is_staff', 'is_active', 'date_joined', 'groups',
], ],

View File

@ -1,4 +1,3 @@
from django.contrib.auth import get_user_model
from django.db import models from django.db import models
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
@ -15,7 +14,7 @@ class UserConfig(models.Model):
This model stores arbitrary user-specific preferences in a JSON data structure. This model stores arbitrary user-specific preferences in a JSON data structure.
""" """
user = models.OneToOneField( user = models.OneToOneField(
to=get_user_model(), to='users.User',
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name='config' related_name='config'
) )

View File

@ -2,7 +2,6 @@ import binascii
import os import os
from django.conf import settings from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.postgres.fields import ArrayField from django.contrib.postgres.fields import ArrayField
from django.core.validators import MinLengthValidator from django.core.validators import MinLengthValidator
from django.db import models from django.db import models
@ -25,7 +24,7 @@ class Token(models.Model):
It also supports setting an expiration time and toggling write ability. It also supports setting an expiration time and toggling write ability.
""" """
user = models.ForeignKey( user = models.ForeignKey(
to=get_user_model(), to='users.User',
on_delete=models.CASCADE, on_delete=models.CASCADE,
related_name='tokens' related_name='tokens'
) )

View File

@ -1,16 +1,12 @@
from django.contrib.auth import get_user_model
from django.test import override_settings from django.test import override_settings
from django.urls import reverse from django.urls import reverse
from core.models import ObjectType from core.models import ObjectType
from users.models import Group, ObjectPermission, Token from users.models import Group, ObjectPermission, Token, User
from utilities.data import deepmerge from utilities.data import deepmerge
from utilities.testing import APIViewTestCases, APITestCase, create_test_user from utilities.testing import APIViewTestCases, APITestCase, create_test_user
User = get_user_model()
class AppTest(APITestCase): class AppTest(APITestCase):
def test_root(self): def test_root(self):

View File

@ -1,16 +1,13 @@
import datetime import datetime
from django.contrib.auth import get_user_model
from django.test import TestCase from django.test import TestCase
from django.utils.timezone import make_aware from django.utils.timezone import make_aware
from core.models import ObjectType from core.models import ObjectType
from users import filtersets from users import filtersets
from users.models import Group, ObjectPermission, Token from users.models import Group, ObjectPermission, Token, User
from utilities.testing import BaseFilterSetTests from utilities.testing import BaseFilterSetTests
User = get_user_model()
class UserTestCase(TestCase, BaseFilterSetTests): class UserTestCase(TestCase, BaseFilterSetTests):
queryset = User.objects.all() queryset = User.objects.all()

View File

@ -1,8 +1,6 @@
from django.contrib.auth import get_user_model
from django.test import TestCase from django.test import TestCase
from users.models import User
User = get_user_model()
class UserConfigTest(TestCase): class UserConfigTest(TestCase):

View File

@ -1,10 +1,10 @@
from django.contrib.auth import get_user_model
from django.test import override_settings from django.test import override_settings
from django.test.client import RequestFactory from django.test.client import RequestFactory
from django.urls import reverse from django.urls import reverse
from dcim.models import Site from dcim.models import Site
from dcim.tables import SiteTable from dcim.tables import SiteTable
from users.models import User
from users.preferences import UserPreference from users.preferences import UserPreference
from utilities.testing import TestCase from utilities.testing import TestCase
@ -16,9 +16,6 @@ DEFAULT_USER_PREFERENCES = {
} }
User = get_user_model()
class UserPreferencesTest(TestCase): class UserPreferencesTest(TestCase):
user_permissions = ['dcim.view_site'] user_permissions = ['dcim.view_site']

View File

@ -1,7 +1,7 @@
import datetime import datetime
import os import os
import yaml import yaml
from dataclasses import dataclass, field from dataclasses import asdict, dataclass, field
from typing import List, Union from typing import List, Union
from django.core.exceptions import ImproperlyConfigured from django.core.exceptions import ImproperlyConfigured
@ -42,6 +42,9 @@ class ReleaseInfo:
def name(self): def name(self):
return f"NetBox {self.edition} v{self.full_version}" return f"NetBox {self.edition} v{self.full_version}"
def asdict(self):
return asdict(self)
def load_release_data(): def load_release_data():
""" """

View File

@ -3,7 +3,6 @@ import json
import strawberry_django import strawberry_django
from django.conf import settings from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.test import override_settings from django.test import override_settings
from django.urls import reverse from django.urls import reverse
@ -16,7 +15,7 @@ from strawberry.types.union import StrawberryUnion
from core.choices import ObjectChangeActionChoices from core.choices import ObjectChangeActionChoices
from core.models import ObjectChange, ObjectType from core.models import ObjectChange, ObjectType
from ipam.graphql.types import IPAddressFamilyType from ipam.graphql.types import IPAddressFamilyType
from users.models import ObjectPermission, Token from users.models import ObjectPermission, Token, User
from utilities.api import get_graphql_type_for_model from utilities.api import get_graphql_type_for_model
from .base import ModelTestCase from .base import ModelTestCase
from .utils import disable_warnings from .utils import disable_warnings
@ -27,9 +26,6 @@ __all__ = (
) )
User = get_user_model()
# #
# REST/GraphQL API Tests # REST/GraphQL API Tests
# #

View File

@ -1,6 +1,5 @@
import json import json
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.fields import ArrayField, RangeField from django.contrib.postgres.fields import ArrayField, RangeField
from django.core.exceptions import FieldDoesNotExist from django.core.exceptions import FieldDoesNotExist
@ -11,7 +10,7 @@ from netaddr import IPNetwork
from taggit.managers import TaggableManager from taggit.managers import TaggableManager
from core.models import ObjectType from core.models import ObjectType
from users.models import ObjectPermission from users.models import ObjectPermission, User
from utilities.data import ranges_to_string from utilities.data import ranges_to_string
from utilities.object_types import object_type_identifier from utilities.object_types import object_type_identifier
from utilities.permissions import resolve_permission_type from utilities.permissions import resolve_permission_type
@ -29,7 +28,7 @@ class TestCase(_TestCase):
def setUp(self): def setUp(self):
# Create the test user and assign permissions # Create the test user and assign permissions
self.user = get_user_model().objects.create_user(username='testuser') self.user = User.objects.create_user(username='testuser')
self.add_permissions(*self.user_permissions) self.add_permissions(*self.user_permissions)
# Initialize the test client # Initialize the test client

View File

@ -3,7 +3,6 @@ import logging
import re import re
from contextlib import contextmanager from contextlib import contextmanager
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission from django.contrib.auth.models import Permission
from django.utils.text import slugify from django.utils.text import slugify
@ -11,6 +10,7 @@ from core.models import ObjectType
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Site
from extras.choices import CustomFieldTypeChoices from extras.choices import CustomFieldTypeChoices
from extras.models import CustomField, Tag from extras.models import CustomField, Tag
from users.models import User
from virtualization.models import Cluster, ClusterType, VirtualMachine from virtualization.models import Cluster, ClusterType, VirtualMachine
@ -67,7 +67,7 @@ def create_test_user(username='testuser', permissions=None):
""" """
Create a User with the given permissions. Create a User with the given permissions.
""" """
user = get_user_model().objects.create_user(username=username) user = User.objects.create_user(username=username)
if permissions is None: if permissions is None:
permissions = () permissions = ()
for perm_name in permissions: for perm_name in permissions: