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
!!! info "This feature was introduced in NetBox v4.0."
The attributes of a related object can be referenced by specifying a dotted path. For example, to reference the name of a region to which a site is assigned, use `region.name`:
```python
@ -104,8 +102,6 @@ CUSTOM_VALIDATORS = {
#### Validating Request Parameters
!!! info "This feature was introduced in NetBox v4.0."
In addition to validating object attributes, custom validators can also match against parameters of the current request (where available). For example, the following rule will permit only the user named "admin" to modify an object:
```json

View File

@ -1,9 +1,10 @@
# Event Rules
NetBox includes the ability to execute certain functions in response to internal object changes. These include:
NetBox includes the ability to automatically perform certain functions in response to internal events. These include:
* [Scripts](../customization/custom-scripts.md) execution
* [Webhooks](../integrations/webhooks.md) execution
* Executing a [custom script](../customization/custom-scripts.md)
* Sending a [webhook](../integrations/webhooks.md)
* Generating [user notifications](../features/notifications.md)
For example, suppose you want to automatically configure a monitoring system to start monitoring a device when its operational status is changed to active, and remove it from monitoring for any other status. You can create a webhook in NetBox for the device model and craft its content and destination URL to effect the desired change on the receiving system. You can then associate an event rule with this webhook and the webhook will be sent automatically by NetBox whenever the configured constraints are met.

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
!!! info "This feature was introduced in NetBox v4.1."
[Circuits](./circuit.md) can be arranged into administrative groups for organization. The assignment of a circuit to a group is optional.
## Fields

View File

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

View File

@ -1,5 +1,7 @@
# Rack Types
!!! info "This feature was introduced in NetBox v4.1."
A rack type defines the physical characteristics of a particular model of [rack](./rack.md).
## Fields
@ -8,9 +10,13 @@ A rack type defines the physical characteristics of a particular model of [rack]
The [manufacturer](./manufacturer.md) which produces this type of rack.
### Name
### Model
The unique name of the rack type.
The model number assigned to this rack type by its manufacturer. Must be unique to the manufacturer.
### Slug
A unique URL-friendly representation of the model identifier. (This value can be used for filtering.)
### Form Factor

View File

@ -44,6 +44,8 @@ For object and multiple-object fields only. Designates the type of NetBox object
### Related Object Filter
!!! info "This field was introduced in NetBox v4.1."
For object and multi-object custom fields, a filter may be defined to limit the available objects when populating a field value. This filter maps object attributes to values. For example, `{"status": "active"}` will include only objects with a status of "active."
!!! warning

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.
### Events
### Events Types
The events which will trigger the rule. At least one event type must be selected.
The event types which will trigger the rule. At least one event type must be selected.
| Name | Description |
|------------|--------------------------------------|
| Creations | A new object has been created |
| Updates | An existing object has been modified |
| Deletions | An object has been deleted |
| Job starts | A job for an object starts |
| Job ends | A job for an object terminates |
| Name | Description |
|----------------|---------------------------------------------|
| Object created | A new object has been created |
| Object updated | An existing object has been modified |
| Object deleted | An object has been deleted |
| Job started | A background job is initiated |
| Job completed | A background job completes successfully |
| Job failed | A background job fails |
| Job errored | A background job is aborted due to an error |
!!! tip "Custom Event Types"
The above list includes only built-in event types. NetBox plugins can also register their own custom event types.
### Conditions

View File

@ -16,6 +16,8 @@ A unique URL-friendly identifier. (This value can be used for filtering.)
### VLAN ID Ranges
!!! info "This field replaced the legacy `min_vid` and `max_vid` fields in NetBox v4.1."
The set of VLAN IDs which are encompassed by the group. By default, this will be the entire range of valid IEEE 802.1Q VLAN IDs (1 to 4094, inclusive). VLANs created within a group must have a VID that falls within one of these ranges. Ranges may not overlap.
### Scope

View File

@ -50,9 +50,13 @@ The amount of running memory provisioned, in megabytes.
### Disk
The amount of disk storage provisioned, in gigabytes.
The amount of disk storage provisioned, in megabytes.
!!! warning
This field may be directly modified only on virtual machines which do not define discrete [virtual disks](./virtualdisk.md). Otherwise, it will report the sum of all attached disks.
### Serial Number
Optional serial number assigned to this VM.
!!! info "This field was introduced in NetBox v4.1."
Optional serial number assigned to this virtual machine. Unlike devices, uniqueness is not enforced for virtual machine serial numbers.

View File

@ -20,6 +20,12 @@ The operational status of the link. Options include:
The service set identifier (SSID) for the wireless link (optional).
### Distance
!!! info "This field was introduced in NetBox v4.1."
The distance between the link's two endpoints, including a unit designation (e.g. 100 meters or 25 feet).
### Authentication Type
The type of wireless authentication in use. Options include:
@ -40,7 +46,3 @@ The security cipher used to apply wireless authentication. Options include:
### Pre-Shared Key
The security key configured on each client to grant access to the secured wireless LAN. This applies only to certain authentication types.
### Distance
The numeric distance of the link, including a unit designation (e.g. 100 meters or 25 feet).

View File

@ -1,5 +1,7 @@
# Background Jobs
!!! info "This feature was introduced in NetBox v4.1."
NetBox plugins can defer certain operations by enqueuing [background jobs](../../features/background-jobs.md), which are executed asynchronously by background workers. This is helpful for decoupling long-running processes from the user-facing request-response cycle.
For example, your plugin might need to fetch data from a remote system. Depending on the amount of data and the responsiveness of the remote server, this could take a few minutes. Deferring this task to a queued job ensures that it can be completed in the background, without interrupting the user. The data it fetches can be made available once the job has completed.
@ -8,12 +10,12 @@ For example, your plugin might need to fetch data from a remote system. Dependin
A background job implements a basic [Job](../../models/core/job.md) executor for all kinds of tasks. It has logic implemented to handle the management of the associated job object, rescheduling of periodic jobs in the given interval and error handling. Adding custom jobs is done by subclassing NetBox's `JobRunner` class.
::: utilities.jobs.JobRunner
::: netbox.jobs.JobRunner
#### Example
```python title="jobs.py"
from utilities.jobs import JobRunner
from netbox.jobs import JobRunner
class MyTestJob(JobRunner):
@ -45,7 +47,7 @@ As described above, jobs can be scheduled for immediate execution or at any late
#### Example
```python title="jobs.py"
from utilities.jobs import JobRunner
from netbox.jobs import JobRunner
class MyHousekeepingJob(JobRunner):

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

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

View File

@ -5,8 +5,9 @@
### Breaking Changes
* Several filters deprecated in v4.0 have been removed (see [#15410](https://github.com/netbox-community/netbox/issues/15410)).
* The unit size for virtual disk size has been changed from 1 gigabyte to 1 megabyte. Existing values have been updated accordingly.
* The unit size for `VirtualMachine.disk` and `VirtualDisk.size` been changed from 1 gigabyte to 1 megabyte. Existing values have been updated accordingly.
* The `min_vid` and `max_vid` fields on the VLAN group model have been replaced with `vid_ranges`, an array of starting and ending integer pairs.
* The five individual event type fields on the EventRule model have been replaced by a single `event_types` array field, indicating each assigned event type by name.
* The `validate()` method on CustomValidator subclasses now **must** accept the request argument (deprecated in v4.0 by #14279).
### New Features
@ -72,6 +73,9 @@ NetBox now includes a user notification system. Users can subscribe to individua
* Added the optional `airflow` choice field
* extras.CustomField
* Added the `related_object_filter` JSON field for object and multi-object custom fields
* extras.EventRule
* Removed the `type_create`, `type_update`, `type_delete`, `type_job_start`, and `type_job_end` boolean fields
* Added the `event_types` array field
* ipam.VLANGroup
* Removed the `min_vid` and `max_vid` fields
* Added the `vid_ranges` field, and array of starting & ending VLAN IDs

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -214,7 +214,7 @@ class Job(models.Model):
management commands only.
"""
if schedule_at and immediate:
raise ValueError("enqueue() cannot be called with values for both schedule_at and immediate.")
raise ValueError(_("enqueue() cannot be called with values for both schedule_at and immediate."))
if instance:
object_type = ObjectType.objects.get_for_model(instance, for_concrete_model=False)

View File

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

View File

@ -1,9 +1,26 @@
from django.db.models.signals import post_save
from django.dispatch import Signal, receiver
import logging
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ValidationError
from django.db.models.fields.reverse_related import ManyToManyRel
from django.db.models.signals import m2m_changed, post_save, pre_delete
from django.dispatch import receiver, Signal
from django.utils.translation import gettext_lazy as _
from django_prometheus.models import model_deletes, model_inserts, model_updates
from core.choices import ObjectChangeActionChoices
from core.events import *
from core.models import ObjectChange
from extras.events import enqueue_event
from extras.utils import run_validators
from netbox.config import get_config
from netbox.context import current_request, events_queue
from netbox.models.features import ChangeLoggingMixin
from utilities.exceptions import AbortRequest
from .models import ConfigRevision
__all__ = (
'clear_events',
'job_end',
'job_start',
'post_sync',
@ -18,6 +35,152 @@ job_end = Signal()
pre_sync = Signal()
post_sync = Signal()
# Event signals
clear_events = Signal()
#
# Change logging & event handling
#
@receiver((post_save, m2m_changed))
def handle_changed_object(sender, instance, **kwargs):
"""
Fires when an object is created or updated.
"""
m2m_changed = False
if not hasattr(instance, 'to_objectchange'):
return
# Get the current request, or bail if not set
request = current_request.get()
if request is None:
return
# Determine the type of change being made
if kwargs.get('created'):
event_type = OBJECT_CREATED
elif 'created' in kwargs:
event_type = OBJECT_UPDATED
elif kwargs.get('action') in ['post_add', 'post_remove'] and kwargs['pk_set']:
# m2m_changed with objects added or removed
m2m_changed = True
event_type = OBJECT_UPDATED
else:
return
# Create/update an ObjectChange record for this change
action = {
OBJECT_CREATED: ObjectChangeActionChoices.ACTION_CREATE,
OBJECT_UPDATED: ObjectChangeActionChoices.ACTION_UPDATE,
OBJECT_DELETED: ObjectChangeActionChoices.ACTION_DELETE,
}[event_type]
objectchange = instance.to_objectchange(action)
# If this is a many-to-many field change, check for a previous ObjectChange instance recorded
# for this object by this request and update it
if m2m_changed and (
prev_change := ObjectChange.objects.filter(
changed_object_type=ContentType.objects.get_for_model(instance),
changed_object_id=instance.pk,
request_id=request.id
).first()
):
prev_change.postchange_data = objectchange.postchange_data
prev_change.save()
elif objectchange and objectchange.has_changes:
objectchange.user = request.user
objectchange.request_id = request.id
objectchange.save()
# Ensure that we're working with fresh M2M assignments
if m2m_changed:
instance.refresh_from_db()
# Enqueue the object for event processing
queue = events_queue.get()
enqueue_event(queue, instance, request.user, request.id, event_type)
events_queue.set(queue)
# Increment metric counters
if event_type == OBJECT_CREATED:
model_inserts.labels(instance._meta.model_name).inc()
elif event_type == OBJECT_UPDATED:
model_updates.labels(instance._meta.model_name).inc()
@receiver(pre_delete)
def handle_deleted_object(sender, instance, **kwargs):
"""
Fires when an object is deleted.
"""
# Run any deletion protection rules for the object. Note that this must occur prior
# to queueing any events for the object being deleted, in case a validation error is
# raised, causing the deletion to fail.
model_name = f'{sender._meta.app_label}.{sender._meta.model_name}'
validators = get_config().PROTECTION_RULES.get(model_name, [])
try:
run_validators(instance, validators)
except ValidationError as e:
raise AbortRequest(
_("Deletion is prevented by a protection rule: {message}").format(message=e)
)
# Get the current request, or bail if not set
request = current_request.get()
if request is None:
return
# Record an ObjectChange if applicable
if hasattr(instance, 'to_objectchange'):
if hasattr(instance, 'snapshot') and not getattr(instance, '_prechange_snapshot', None):
instance.snapshot()
objectchange = instance.to_objectchange(ObjectChangeActionChoices.ACTION_DELETE)
objectchange.user = request.user
objectchange.request_id = request.id
objectchange.save()
# Django does not automatically send an m2m_changed signal for the reverse direction of a
# many-to-many relationship (see https://code.djangoproject.com/ticket/17688), so we need to
# trigger one manually. We do this by checking for any reverse M2M relationships on the
# instance being deleted, and explicitly call .remove() on the remote M2M field to delete
# the association. This triggers an m2m_changed signal with the `post_remove` action type
# for the forward direction of the relationship, ensuring that the change is recorded.
for relation in instance._meta.related_objects:
if type(relation) is not ManyToManyRel:
continue
related_model = relation.related_model
related_field_name = relation.remote_field.name
if not issubclass(related_model, ChangeLoggingMixin):
# We only care about triggering the m2m_changed signal for models which support
# change logging
continue
for obj in related_model.objects.filter(**{related_field_name: instance.pk}):
obj.snapshot() # Ensure the change record includes the "before" state
getattr(obj, related_field_name).remove(instance)
# Enqueue the object for event processing
queue = events_queue.get()
enqueue_event(queue, instance, request.user, request.id, OBJECT_DELETED)
events_queue.set(queue)
# Increment metric counters
model_deletes.labels(instance._meta.model_name).inc()
@receiver(clear_events)
def clear_events_queue(sender, **kwargs):
"""
Delete any queued events (e.g. because of an aborted bulk transaction)
"""
logger = logging.getLogger('events')
logger.info(f"Clearing {len(events_queue.get())} queued events ({sender})")
events_queue.set({})
#
# DataSource handlers
#
@receiver(post_sync)
def auto_sync(instance, **kwargs):

View File

@ -2,6 +2,7 @@ import json
import platform
from django import __version__ as DJANGO_VERSION
from django.apps import apps
from django.conf import settings
from django.contrib import messages
from django.contrib.auth.mixins import UserPassesTestMixin
@ -37,7 +38,7 @@ from . import filtersets, forms, tables
from .choices import DataSourceStatusChoices
from .jobs import SyncDataSourceJob
from .models import *
from .plugins import get_plugins
from .plugins import get_catalog_plugins, get_local_plugins
from .tables import CatalogPluginTable, PluginVersionTable
@ -630,10 +631,13 @@ class SystemView(UserPassesTestMixin, View):
# Raw data export
if 'export' in request.GET:
stats['netbox_release'] = stats['netbox_release'].asdict()
params = [param.name for param in PARAMS]
data = {
**stats,
'plugins': settings.PLUGINS,
'config': {
k: config.data[k] for k in sorted(config.data)
k: getattr(config, k) for k in sorted(params)
},
}
response = HttpResponse(json.dumps(data, indent=4), content_type='text/json')
@ -650,15 +654,31 @@ class SystemView(UserPassesTestMixin, View):
# Plugins
#
class PluginListView(UserPassesTestMixin, View):
class BasePluginView(UserPassesTestMixin, View):
CACHE_KEY_CATALOG_ERROR = 'plugins-catalog-error'
def test_func(self):
return self.request.user.is_staff
def get_cached_plugins(self, request):
catalog_plugins = {}
catalog_plugins_error = cache.get(self.CACHE_KEY_CATALOG_ERROR, default=False)
if not catalog_plugins_error:
catalog_plugins = get_catalog_plugins()
if not catalog_plugins:
# Cache for 5 minutes to avoid spamming connection
cache.set(self.CACHE_KEY_CATALOG_ERROR, True, 300)
messages.warning(request, _("Plugins catalog could not be loaded"))
return get_local_plugins(catalog_plugins)
class PluginListView(BasePluginView):
def get(self, request):
q = request.GET.get('q', None)
plugins = get_plugins().values()
plugins = self.get_cached_plugins(request).values()
if q:
plugins = [obj for obj in plugins if q.casefold() in obj.title_short.casefold()]
@ -676,14 +696,11 @@ class PluginListView(UserPassesTestMixin, View):
})
class PluginView(UserPassesTestMixin, View):
def test_func(self):
return self.request.user.is_staff
class PluginView(BasePluginView):
def get(self, request, name):
plugins = get_plugins()
plugins = self.get_cached_plugins(request)
if name not in plugins:
raise Http404(_("Plugin {name} not found").format(name=name))
plugin = plugins[name]

View File

@ -73,12 +73,12 @@ class RackTypeSerializer(RackBaseSerializer):
class Meta:
model = RackType
fields = [
'id', 'url', 'display_url', 'display', 'manufacturer', 'name', 'slug', 'description', 'form_factor',
'id', 'url', 'display_url', 'display', 'manufacturer', 'model', 'slug', 'description', 'form_factor',
'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'weight',
'max_weight', 'weight_unit', 'mounting_depth', 'airflow', 'description', 'comments', 'tags',
'custom_fields', 'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'manufacturer', 'name', 'slug', 'description')
brief_fields = ('id', 'url', 'display', 'manufacturer', 'model', 'slug', 'description')
class RackSerializer(RackBaseSerializer):

View File

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

View File

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

View File

@ -222,7 +222,7 @@ class RackTypeImportForm(NetBoxModelImportForm):
class Meta:
model = RackType
fields = (
'manufacturer', 'name', 'slug', 'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units',
'manufacturer', 'model', 'slug', 'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units',
'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'airflow', 'weight', 'max_weight',
'weight_unit', 'description', 'comments', 'tags',
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -136,15 +136,10 @@ class RackType(RackBase):
on_delete=models.PROTECT,
related_name='rack_types'
)
name = models.CharField(
verbose_name=_('name'),
model = models.CharField(
verbose_name=_('model'),
max_length=100
)
_name = NaturalOrderingField(
target_field='name',
max_length=100,
blank=True
)
slug = models.SlugField(
verbose_name=_('slug'),
max_length=100,
@ -152,24 +147,38 @@ class RackType(RackBase):
)
clone_fields = (
'manufacturer', 'form_factor', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit',
'mounting_depth', 'weight', 'max_weight', 'weight_unit',
'manufacturer', 'form_factor', 'width', 'u_height', 'airflow', 'desc_units', 'outer_width', 'outer_depth',
'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit',
)
prerequisite_models = (
'dcim.Manufacturer',
)
class Meta:
ordering = ('_name', 'pk') # (site, location, name) may be non-unique
ordering = ('manufacturer', 'model')
constraints = (
models.UniqueConstraint(
fields=('manufacturer', 'model'),
name='%(app_label)s_%(class)s_unique_manufacturer_model'
),
models.UniqueConstraint(
fields=('manufacturer', 'slug'),
name='%(app_label)s_%(class)s_unique_manufacturer_slug'
),
)
verbose_name = _('rack type')
verbose_name_plural = _('rack types')
def __str__(self):
return self.name
return self.model
def get_absolute_url(self):
return reverse('dcim:racktype', args=[self.pk])
@property
def full_name(self):
return f"{self.manufacturer} {self.model}"
def clean(self):
super().clean()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,9 +1,7 @@
from collections import defaultdict
import logging
from collections import defaultdict
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.utils import timezone
from django.utils.module_loading import import_string
@ -14,6 +12,7 @@ from core.events import *
from netbox.config import get_config
from netbox.constants import RQ_QUEUE_DEFAULT
from netbox.registry import registry
from users.models import User
from utilities.api import get_serializer_for_model
from utilities.rqworker import get_rq_retry
from utilities.serialization import serialize_object
@ -83,7 +82,7 @@ def enqueue_event(queue, instance, user, request_id, event_type):
def process_event_rules(event_rules, object_type, event_type, data, username=None, snapshots=None, request_id=None):
user = get_user_model().objects.get(username=username) if username else None
user = User.objects.get(username=username) if username else None
for event_rule in event_rules:

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -27,7 +27,7 @@ def get_event_type_choices():
"""
return [
(name, event.text)
for name, event in registry['events'].items()
for name, event in registry['event_types'].items()
]
@ -102,7 +102,7 @@ class Notification(models.Model):
"""
Returns the registered Event which triggered this Notification.
"""
return registry['events'].get(self.event_type)
return registry['event_types'].get(self.event_type)
class NotificationGroup(ChangeLoggedModel):

View File

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

View File

@ -1,194 +1,18 @@
import importlib
import logging
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ImproperlyConfigured, ValidationError
from django.db.models.fields.reverse_related import ManyToManyRel
from django.db.models.signals import m2m_changed, post_save, pre_delete
from django.dispatch import receiver, Signal
from django.utils.translation import gettext_lazy as _
from django_prometheus.models import model_deletes, model_inserts, model_updates
from django.dispatch import receiver
from core.choices import ObjectChangeActionChoices
from core.events import *
from core.models import ObjectChange, ObjectType
from core.models import ObjectType
from core.signals import job_end, job_start
from extras.events import process_event_rules
from extras.models import EventRule, Notification, Subscription
from netbox.config import get_config
from netbox.context import current_request, events_queue
from netbox.models.features import ChangeLoggingMixin
from netbox.registry import registry
from netbox.signals import post_clean
from utilities.exceptions import AbortRequest
from .events import enqueue_event
from .models import CustomField, TaggedItem
from .validators import CustomValidator
def run_validators(instance, validators):
"""
Run the provided iterable of validators for the instance.
"""
request = current_request.get()
for validator in validators:
# Loading a validator class by dotted path
if type(validator) is str:
module, cls = validator.rsplit('.', 1)
validator = getattr(importlib.import_module(module), cls)()
# Constructing a new instance on the fly from a ruleset
elif type(validator) is dict:
validator = CustomValidator(validator)
elif not issubclass(validator.__class__, CustomValidator):
raise ImproperlyConfigured(f"Invalid value for custom validator: {validator}")
validator(instance, request)
#
# Change logging/webhooks
#
# Define a custom signal that can be sent to clear any queued events
clear_events = Signal()
@receiver((post_save, m2m_changed))
def handle_changed_object(sender, instance, **kwargs):
"""
Fires when an object is created or updated.
"""
m2m_changed = False
if not hasattr(instance, 'to_objectchange'):
return
# Get the current request, or bail if not set
request = current_request.get()
if request is None:
return
# Determine the type of change being made
if kwargs.get('created'):
event_type = OBJECT_CREATED
elif 'created' in kwargs:
event_type = OBJECT_UPDATED
elif kwargs.get('action') in ['post_add', 'post_remove'] and kwargs['pk_set']:
# m2m_changed with objects added or removed
m2m_changed = True
event_type = OBJECT_UPDATED
else:
return
# Create/update an ObjectChange record for this change
action = {
OBJECT_CREATED: ObjectChangeActionChoices.ACTION_CREATE,
OBJECT_UPDATED: ObjectChangeActionChoices.ACTION_UPDATE,
OBJECT_DELETED: ObjectChangeActionChoices.ACTION_DELETE,
}[event_type]
objectchange = instance.to_objectchange(action)
# If this is a many-to-many field change, check for a previous ObjectChange instance recorded
# for this object by this request and update it
if m2m_changed and (
prev_change := ObjectChange.objects.filter(
changed_object_type=ContentType.objects.get_for_model(instance),
changed_object_id=instance.pk,
request_id=request.id
).first()
):
prev_change.postchange_data = objectchange.postchange_data
prev_change.save()
elif objectchange and objectchange.has_changes:
objectchange.user = request.user
objectchange.request_id = request.id
objectchange.save()
# Ensure that we're working with fresh M2M assignments
if m2m_changed:
instance.refresh_from_db()
# Enqueue the object for event processing
queue = events_queue.get()
enqueue_event(queue, instance, request.user, request.id, event_type)
events_queue.set(queue)
# Increment metric counters
if event_type == OBJECT_CREATED:
model_inserts.labels(instance._meta.model_name).inc()
elif event_type == OBJECT_UPDATED:
model_updates.labels(instance._meta.model_name).inc()
@receiver(pre_delete)
def handle_deleted_object(sender, instance, **kwargs):
"""
Fires when an object is deleted.
"""
# Run any deletion protection rules for the object. Note that this must occur prior
# to queueing any events for the object being deleted, in case a validation error is
# raised, causing the deletion to fail.
model_name = f'{sender._meta.app_label}.{sender._meta.model_name}'
validators = get_config().PROTECTION_RULES.get(model_name, [])
try:
run_validators(instance, validators)
except ValidationError as e:
raise AbortRequest(
_("Deletion is prevented by a protection rule: {message}").format(message=e)
)
# Get the current request, or bail if not set
request = current_request.get()
if request is None:
return
# Record an ObjectChange if applicable
if hasattr(instance, 'to_objectchange'):
if hasattr(instance, 'snapshot') and not getattr(instance, '_prechange_snapshot', None):
instance.snapshot()
objectchange = instance.to_objectchange(ObjectChangeActionChoices.ACTION_DELETE)
objectchange.user = request.user
objectchange.request_id = request.id
objectchange.save()
# Django does not automatically send an m2m_changed signal for the reverse direction of a
# many-to-many relationship (see https://code.djangoproject.com/ticket/17688), so we need to
# trigger one manually. We do this by checking for any reverse M2M relationships on the
# instance being deleted, and explicitly call .remove() on the remote M2M field to delete
# the association. This triggers an m2m_changed signal with the `post_remove` action type
# for the forward direction of the relationship, ensuring that the change is recorded.
for relation in instance._meta.related_objects:
if type(relation) is not ManyToManyRel:
continue
related_model = relation.related_model
related_field_name = relation.remote_field.name
if not issubclass(related_model, ChangeLoggingMixin):
# We only care about triggering the m2m_changed signal for models which support
# change logging
continue
for obj in related_model.objects.filter(**{related_field_name: instance.pk}):
obj.snapshot() # Ensure the change record includes the "before" state
getattr(obj, related_field_name).remove(instance)
# Enqueue the object for event processing
queue = events_queue.get()
enqueue_event(queue, instance, request.user, request.id, OBJECT_DELETED)
events_queue.set(queue)
# Increment metric counters
model_deletes.labels(instance._meta.model_name).inc()
@receiver(clear_events)
def clear_events_queue(sender, **kwargs):
"""
Delete any queued events (e.g. because of an aborted bulk transaction)
"""
logger = logging.getLogger('events')
logger.info(f"Clearing {len(events_queue.get())} queued events ({sender})")
events_queue.set({})
from .utils import run_validators
#

View File

@ -1,5 +1,19 @@
import importlib
from django.core.exceptions import ImproperlyConfigured
from taggit.managers import _TaggableManager
from netbox.context import current_request
from .validators import CustomValidator
__all__ = (
'image_upload',
'is_report',
'is_script',
'is_taggable',
'run_validators',
)
def is_taggable(obj):
"""
@ -48,3 +62,25 @@ def is_report(obj):
return issubclass(obj, Report) and obj != Report
except TypeError:
return False
def run_validators(instance, validators):
"""
Run the provided iterable of CustomValidators for the instance.
"""
request = current_request.get()
for validator in validators:
# Loading a validator class by dotted path
if type(validator) is str:
module, cls = validator.rsplit('.', 1)
validator = getattr(importlib.import_module(module), cls)()
# Constructing a new instance on the fly from a ruleset
elif type(validator) is dict:
validator = CustomValidator(validator)
elif not issubclass(validator.__class__, CustomValidator):
raise ImproperlyConfigured(f"Invalid value for custom validator: {validator}")
validator(instance, request)

View File

@ -551,14 +551,6 @@ class EventRuleListView(generic.ObjectListView):
class EventRuleView(generic.ObjectView):
queryset = EventRule.objects.all()
def get_extra_context(self, request, instance):
return {
'event_types': [
event for name, event in registry['events'].items()
if name in instance.event_types
]
}
@register_model_view(EventRule, 'edit')
class EventRuleEditView(generic.ObjectEditView):

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -13,7 +13,7 @@ from django.utils.html import escape
from django.utils.safestring import mark_safe
from django.utils.translation import gettext as _
from extras.signals import clear_events
from core.signals import clear_events
from utilities.error_handlers import handle_protectederror
from utilities.exceptions import AbortRequest, PermissionsViolation
from utilities.forms import ConfirmationForm, restrict_form_fields

View File

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

View File

@ -24,7 +24,12 @@
</div>
{% 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 %}

View File

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

View File

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

View File

@ -43,7 +43,7 @@
</tr>
<tr>
<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>
<th scope="row">{% trans "Role" %}</th>

View File

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

View File

@ -36,7 +36,7 @@
<div class="card">
<h5 class="card-header">{% trans "Event Types" %}</h5>
<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">
<div class="row align-items-center">
<div class="col-auto">

View File

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\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"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\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:204
#: 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"
msgstr ""
@ -7321,7 +7321,7 @@ msgstr ""
#: netbox/templates/extras/configcontext.html:60
#: netbox/templates/ipam/ipaddress.html:59
#: 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"
msgstr ""
@ -10299,13 +10299,13 @@ msgid "Admin"
msgstr ""
#: 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:297 netbox/users/tables.py:102
#: netbox/users/forms/model_forms.py:237 netbox/users/forms/model_forms.py:249
#: netbox/users/forms/model_forms.py:301 netbox/users/tables.py:102
msgid "Users"
msgstr ""
#: 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
msgid "Groups"
msgstr ""
@ -10316,8 +10316,8 @@ msgid "API Tokens"
msgstr ""
#: 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:246
#: netbox/users/forms/model_forms.py:196 netbox/users/forms/model_forms.py:243
#: netbox/users/forms/model_forms.py:250
msgid "Permissions"
msgstr ""
@ -12009,7 +12009,7 @@ msgstr ""
#: netbox/templates/dcim/virtualchassis_add_member.html:27
#: netbox/templates/generic/object_edit.html:78
#: 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"
msgstr ""
@ -13104,7 +13104,7 @@ msgid "View"
msgstr ""
#: netbox/templates/users/objectpermission.html:52
#: netbox/users/forms/model_forms.py:312
#: netbox/users/forms/model_forms.py:316
msgid "Constraints"
msgstr ""
@ -13623,30 +13623,30 @@ msgstr ""
msgid "Passwords do not match! Please check your input and try again."
msgstr ""
#: netbox/users/forms/model_forms.py:291
#: netbox/users/forms/model_forms.py:295
msgid "Additional actions"
msgstr ""
#: netbox/users/forms/model_forms.py:294
#: netbox/users/forms/model_forms.py:298
msgid "Actions granted in addition to those listed above"
msgstr ""
#: netbox/users/forms/model_forms.py:310
#: netbox/users/forms/model_forms.py:314
msgid "Objects"
msgstr ""
#: netbox/users/forms/model_forms.py:322
#: netbox/users/forms/model_forms.py:326
msgid ""
"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 will result in a logical OR operation."
msgstr ""
#: netbox/users/forms/model_forms.py:361
#: netbox/users/forms/model_forms.py:365
msgid "At least one action must be selected."
msgstr ""
#: netbox/users/forms/model_forms.py:379
#: netbox/users/forms/model_forms.py:383
#, python-brace-format
msgid "Invalid filter for {model}: {error}"
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.utils import extend_schema_field
from rest_framework import serializers
from core.models import ObjectType
from netbox.api.fields import ContentTypeField
from netbox.api.serializers import WritableNestedSerializer
from users.models import Group, ObjectPermission, Token
from users.models import Group, ObjectPermission, Token, User
__all__ = [
'NestedGroupSerializer',
@ -26,7 +25,7 @@ class NestedGroupSerializer(WritableNestedSerializer):
class NestedUserSerializer(WritableNestedSerializer):
class Meta:
model = get_user_model()
model = User
fields = ['id', 'url', 'display_url', 'display', 'username']
@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.utils import extend_schema_field
from rest_framework import serializers
from netbox.api.fields import SerializedPKRelatedField
from netbox.api.serializers import ValidatedModelSerializer
from users.models import Group, ObjectPermission
from users.models import Group, ObjectPermission, User
from .permissions import ObjectPermissionSerializer
__all__ = (
@ -49,7 +49,7 @@ class UserSerializer(ValidatedModelSerializer):
)
class Meta:
model = get_user_model()
model = User
fields = (
'id', 'url', 'display_url', 'display', 'username', 'password', 'first_name', 'last_name', 'email',
'is_staff', 'is_active', 'date_joined', 'last_login', 'groups', 'permissions',

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
from django import forms
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.core.exceptions import FieldError
from django.utils.safestring import mark_safe
@ -152,7 +152,7 @@ class UserTokenForm(forms.ModelForm):
class TokenForm(UserTokenForm):
user = forms.ModelChoiceField(
queryset=get_user_model().objects.order_by('username'),
queryset=User.objects.order_by('username'),
label=_('User')
)
@ -236,7 +236,7 @@ class GroupForm(forms.ModelForm):
users = DynamicModelMultipleChoiceField(
label=_('Users'),
required=False,
queryset=get_user_model().objects.all()
queryset=User.objects.all()
)
object_permissions = DynamicModelMultipleChoiceField(
required=False,
@ -300,7 +300,7 @@ class ObjectPermissionForm(forms.ModelForm):
users = DynamicModelMultipleChoiceField(
label=_('Users'),
required=False,
queryset=get_user_model().objects.all()
queryset=User.objects.all()
)
groups = DynamicModelMultipleChoiceField(
label=_('Groups'),

View File

@ -1,9 +1,7 @@
import strawberry
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 users import filtersets, models
__all__ = (
'GroupFilter',
@ -17,7 +15,7 @@ class GroupFilter(BaseFilterMixin):
pass
@strawberry_django.filter(get_user_model(), lookups=True)
@strawberry_django.filter(models.User, lookups=True)
@autotype_decorator(filtersets.UserFilterSet)
class UserFilter(BaseFilterMixin):
pass

View File

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

View File

@ -1,10 +1,9 @@
from typing import List
import strawberry_django
from django.contrib.auth import get_user_model
from netbox.graphql.types import BaseObjectType
from users.models import Group
from users.models import Group, User
from .filters import *
__all__ = (
@ -23,7 +22,7 @@ class GroupType(BaseObjectType):
@strawberry_django.type(
get_user_model(),
User,
fields=[
'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.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.
"""
user = models.OneToOneField(
to=get_user_model(),
to='users.User',
on_delete=models.CASCADE,
related_name='config'
)

View File

@ -2,7 +2,6 @@ import binascii
import os
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.postgres.fields import ArrayField
from django.core.validators import MinLengthValidator
from django.db import models
@ -25,7 +24,7 @@ class Token(models.Model):
It also supports setting an expiration time and toggling write ability.
"""
user = models.ForeignKey(
to=get_user_model(),
to='users.User',
on_delete=models.CASCADE,
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.urls import reverse
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.testing import APIViewTestCases, APITestCase, create_test_user
User = get_user_model()
class AppTest(APITestCase):
def test_root(self):

View File

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

View File

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

View File

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

View File

@ -3,7 +3,6 @@ import json
import strawberry_django
from django.conf import settings
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.test import override_settings
from django.urls import reverse
@ -16,7 +15,7 @@ from strawberry.types.union import StrawberryUnion
from core.choices import ObjectChangeActionChoices
from core.models import ObjectChange, ObjectType
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 .base import ModelTestCase
from .utils import disable_warnings
@ -27,9 +26,6 @@ __all__ = (
)
User = get_user_model()
#
# REST/GraphQL API Tests
#

View File

@ -1,6 +1,5 @@
import json
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.fields import ArrayField, RangeField
from django.core.exceptions import FieldDoesNotExist
@ -11,7 +10,7 @@ from netaddr import IPNetwork
from taggit.managers import TaggableManager
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.object_types import object_type_identifier
from utilities.permissions import resolve_permission_type
@ -29,7 +28,7 @@ class TestCase(_TestCase):
def setUp(self):
# 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)
# Initialize the test client

View File

@ -3,7 +3,6 @@ import logging
import re
from contextlib import contextmanager
from django.contrib.auth import get_user_model
from django.contrib.auth.models import Permission
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 extras.choices import CustomFieldTypeChoices
from extras.models import CustomField, Tag
from users.models import User
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.
"""
user = get_user_model().objects.create_user(username=username)
user = User.objects.create_user(username=username)
if permissions is None:
permissions = ()
for perm_name in permissions: