Merge branch 'feature-ui' into 16394-distinguish-product-edition

This commit is contained in:
Jeremy Stretch 2024-07-18 12:42:30 -04:00
commit 853fa99ea4
176 changed files with 16140 additions and 11704 deletions

View File

@ -26,7 +26,7 @@ body:
attributes:
label: NetBox Version
description: What version of NetBox are you currently running?
placeholder: v4.0.6
placeholder: v4.0.7
validations:
required: true
- type: dropdown

View File

@ -14,7 +14,7 @@ body:
attributes:
label: NetBox version
description: What version of NetBox are you currently running?
placeholder: v4.0.6
placeholder: v4.0.7
validations:
required: true
- type: dropdown

View File

@ -31,6 +31,17 @@ The sampling rate for errors. Must be a value between 0 (disabled) and 1.0 (repo
---
## SENTRY_SEND_DEFAULT_PII
Default: False
Maps to the Sentry SDK's [`send_default_pii`](https://docs.sentry.io/platforms/python/configuration/options/#send-default-pii) parameter. If enabled, certain personally identifiable information (PII) is added.
!!! warning "Sensitive data"
If you enable this option, be aware that sensitive data such as cookies and authentication tokens will be logged.
---
## SENTRY_TAGS
An optional dictionary of tag names and values to apply to Sentry error reports.For example:

View File

@ -177,7 +177,7 @@ The dotted path to the desired search backend class. `CachedValueSearchBackend`
Default: None (local storage)
The backend storage engine for handling uploaded files (e.g. image attachments). NetBox supports integration with the [`django-storages`](https://django-storages.readthedocs.io/en/stable/) package, which provides backends for several popular file storage services. If not configured, local filesystem storage will be used.
The backend storage engine for handling uploaded files (e.g. image attachments). NetBox supports integration with the [`django-storages`](https://django-storages.readthedocs.io/en/stable/) and [`django-storage-swift`](https://github.com/dennisv/django-storage-swift) packages, which provide backends for several popular file storage services. If not configured, local filesystem storage will be used.
The configuration parameters for the specified storage backend are defined under the `STORAGE_CONFIG` setting.
@ -187,7 +187,7 @@ The configuration parameters for the specified storage backend are defined under
Default: Empty
A dictionary of configuration parameters for the storage backend configured as `STORAGE_BACKEND`. The specific parameters to be used here are specific to each backend; see the [`django-storages` documentation](https://django-storages.readthedocs.io/en/stable/) for more detail.
A dictionary of configuration parameters for the storage backend configured as `STORAGE_BACKEND`. The specific parameters to be used here are specific to each backend; see the documentation for your selected backend ([`django-storages`](https://django-storages.readthedocs.io/en/stable/) or [`django-storage-swift`](https://github.com/dennisv/django-storage-swift)) for more detail.
If `STORAGE_BACKEND` is not defined, this setting will be ignored.

View File

@ -135,4 +135,6 @@ First, run the `build-site` action, by navigating to Actions > build-site > Run
Once the documentation files have been compiled, they must be published by running the `deploy-kinsta` action. Select the desired deployment environment (staging or production) and specify `latest` as the deploy tag.
Clear the CDN cache from the [Kinsta](https://my.kinsta.com/) portal. Navigate to _Sites_ / _NetBox Labs_ / _Live_, select _CDN_ in the left-nav, click the _Clear CDN cache_ button, and confirm the clear operation.
Finally, verify that the documentation at <https://netboxlabs.com/docs/netbox/en/stable/> has been updated.

View File

@ -56,6 +56,10 @@ A site typically represents a building within a region and/or site group. Each s
A location can be any logical subdivision within a building, such as a floor or room. Like regions and site groups, locations can be nested into a self-recursive hierarchy for maximum flexibility. And like sites, each location has an operational status assigned to it.
## Rack Types
A rack type represents a unique specification of a rack which exists in the real world. Each rack type can be setup with weight, height, and unit ordering. New racks of this type can then be created in NetBox, and any associated specifications will be automatically replicated from the device type.
## Racks
Finally, NetBox models each equipment rack as a discrete object within a site and location. These are physical objects into which devices are installed. Each rack can be assigned an operational status, type, facility ID, and other attributes related to inventory tracking. Each rack also must define a height (in rack units) and width, and may optionally specify its physical dimensions.

View File

@ -20,6 +20,10 @@ The [location](./location.md) within a site where the rack has been installed (o
The rack's name or identifier. Must be unique to the rack's location, if assigned.
### Rack Type
The [physical type](./racktype.md) of this rack. The rack type defines physical attributes such as height and weight.
### Status
Operational status.
@ -43,44 +47,5 @@ The unique physical serial number assigned to this rack.
A unique, locally-administered label used to identify hardware resources.
### Type
A rack can be designated as one of the following types:
* 2-post frame
* 4-post frame
* 4-post cabinet
* Wall-mounted frame
* Wall-mounted cabinet
### Width
The canonical distance between the two vertical rails on a face. (This is typically 19 inches, however other standard widths exist.)
### Height
The height of the rack, measured in units.
### Starting Unit
The number of the numerically lowest unit in the rack. This value defaults to one, but may be higher in certain situations. For example, you may want to model only a select range of units within a shared physical rack (e.g. U13 through U24).
### Outer Dimensions
The external width and depth of the rack can be tracked to aid in floorplan calculations. These measurements must be designated in either millimeters or inches.
### Mounting Depth
The maximum depth of a mounted device that the rack can accommodate, in millimeters. For four-post frames or cabinets, this is the horizontal distance between the front and rear vertical rails. (Note that this measurement does _not_ include space between the rails and the cabinet doors.)
### Weight
The numeric weight of the rack, including a unit designation (e.g. 10 kilograms or 20 pounds).
### Maximum Weight
The maximum total weight capacity for all installed devices, inclusive of the rack itself.
### Descending Units
If selected, the rack's elevation will display unit 1 at the top of the rack. (Most racks use ascending numbering, with unit 1 assigned to the bottommost position.)
!!! note
Some additional fields pertaining to physical attributes such as height and weight can also be defined on each rack, but should generally be defined instead on the [rack type](./racktype.md).

View File

@ -0,0 +1,57 @@
# Rack Types
A rack type defines the physical characteristics of a particular model of [rack](./rack.md).
## Fields
### Manufacturer
The [manufacturer](./manufacturer.md) which produces this type of rack.
### Name
The unique name of the rack type.
### Form Factor
A rack can be designated as one of the following form factors:
* 2-post frame
* 4-post frame
* 4-post cabinet
* Wall-mounted frame
* Wall-mounted cabinet
### Width
The canonical distance between the two vertical rails on a face. (This is typically 19 inches, however other standard widths exist.)
### Height
The height of the rack, measured in units.
### Starting Unit
The number of the numerically lowest unit in the rack. This value defaults to one, but may be higher in certain situations. For example, you may want to model only a select range of units within a shared physical rack (e.g. U13 through U24).
### Outer Dimensions
The external width and depth of the rack can be tracked to aid in floorplan calculations. These measurements must be designated in either millimeters or inches.
### Mounting Depth
The maximum depth of a mounted device that the rack can accommodate, in millimeters. For four-post frames or cabinets, this is the horizontal distance between the front and rear vertical rails. (Note that this measurement does _not_ include space between the rails and the cabinet doors.)
### Weight
The numeric weight of the rack, including a unit designation (e.g. 10 kilograms or 20 pounds).
### Maximum Weight
The maximum total weight capacity for all installed devices, inclusive of the rack itself.
### Descending Units
If selected, the rack's elevation will display unit 1 at the top of the rack. (Most racks use ascending numbering, with unit 1 assigned to the bottommost position.)

View File

@ -0,0 +1,17 @@
# Notification
A notification alerts a user that a specific action has taken place in NetBox, such as an object being modified or a background job completing. A notification may be generated via a user's [subscription](./subscription.md) to a particular object, or by an event rule targeting a [notification group](./notificationgroup.md) of which the user is a member.
## Fields
### User
The recipient of the notification.
### Object
The object to which the notification relates.
### Event Type
The type of event indicated by the notification.

View File

@ -0,0 +1,17 @@
# Notification Group
A set of NetBox users and/or groups of users identified as recipients for certain [notifications](./notification.md).
## Fields
### Name
The name of the notification group.
### Users
One or more users directly designated as members of the notification group.
### Groups
All users of any selected groups are considered as members of the notification group.

View File

@ -0,0 +1,15 @@
# Subscription
A record indicating that a user is to be notified of any changes to a particular NetBox object. A notification maps exactly one user to exactly one object.
When an object to which a user is subscribed changes, a [notification](./notification.md) is generated for the user.
## Fields
### User
The subscribed user.
### Object
The object to which the user is subscribed.

View File

@ -14,9 +14,9 @@ A unique human-friendly name.
A unique URL-friendly identifier. (This value can be used for filtering.)
### Minimum & Maximum VLAN IDs
### VLAN ID Ranges
A minimum and maximum child VLAN ID must be set for each group. (These default to 1 and 4094 respectively.) VLANs created within a group must have a VID that falls between these values (inclusive).
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

@ -191,22 +191,25 @@ class MyView(generic.ObjectView):
### Extra Template Content
Plugins can inject custom content into certain areas of core NetBox views. This is accomplished by subclassing `PluginTemplateExtension`, designating a particular NetBox model, and defining the desired method(s) to render custom content. Five methods are available:
Plugins can inject custom content into certain areas of core NetBox views. This is accomplished by subclassing `PluginTemplateExtension`, optionally designating one or more particular NetBox models, and defining the desired method(s) to render custom content. Five methods are available:
| Method | View | Description |
|---------------------|-------------|-----------------------------------------------------|
| `navbar()` | All | Inject content inside the top navigation bar |
| `list_buttons()` | List view | Add buttons to the top of the page |
| `buttons()` | Object view | Add buttons to the top of the page |
| `alerts()` | Object view | Inject content at the top of the page |
| `left_page()` | Object view | Inject content on the left side of the page |
| `right_page()` | Object view | Inject content on the right side of the page |
| `full_width_page()` | Object view | Inject content across the entire bottom of the page |
| `buttons()` | Object view | Add buttons to the top of the page |
| `list_buttons()` | List view | Add buttons to the top of the page |
!!! info "The `navbar()` method was 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.
When a PluginTemplateExtension is instantiated, context data is assigned to `self.context`. Available data include:
To control where the custom content is injected, plugin authors can specify an iterable of models by overriding the `models` attribute on the subclass. Extensions which do not specify a set of models will be invoked on every view, where supported.
When a PluginTemplateExtension is instantiated, context data is assigned to `self.context`. Available data includes:
* `object` - The object being viewed (object views only)
* `model` - The model of the list view (list views only)
@ -223,7 +226,7 @@ from netbox.plugins import PluginTemplateExtension
from .models import Animal
class SiteAnimalCount(PluginTemplateExtension):
model = 'dcim.site'
models = ['dcim.site']
def right_page(self):
return self.render('netbox_animal_sounds/inc/animal_count.html', extra_context={

View File

@ -70,3 +70,19 @@ DROP TABLE
netbox=> DROP TABLE pluginname_bar;
DROP TABLE
```
### Remove the Django Migration Records
After removing the tables created by a plugin, the migrations that created the tables need to be removed from Django's migration history as well. This is necessary to make it possible to reinstall the plugin at a later time. If the migration history were left in place, Django would skip all migrations that were executed in the course of a previous installation, which would cause the plugin to fail after reinstallation.
```no-highlight
netbox=> SELECT * FROM django_migrations WHERE app='pluginname';
id | app | name | applied
-----+------------+------------------------+-------------------------------
492 | pluginname | 0001_initial | 2023-12-21 11:59:59.325995+00
493 | pluginname | 0002_add_foo | 2023-12-21 11:59:59.330026+00
netbox=> DELETE FROM django_migrations WHERE app='pluginname';
```
!!! warning
Exercise extreme caution when altering Django system tables. Users are strongly encouraged to perform a backup of their database immediately before taking these actions.

View File

@ -1,6 +1,42 @@
# NetBox v4.0
## v4.0.7 (FUTURE)
## v4.0.8 (FUTURE)
---
## v4.0.7 (2024-07-09)
### Enhancements
* [#14554](https://github.com/netbox-community/netbox/issues/14554) - Add support for [django-storage-swift](https://github.com/dennisv/django-storage-swift) storage backend
* [#16424](https://github.com/netbox-community/netbox/issues/16424) - Enable filtering of devices by cluster and cluster group
* [#16716](https://github.com/netbox-community/netbox/issues/16716) - Display NAT address (if any) for OOB IP address under device view
* [#16725](https://github.com/netbox-community/netbox/issues/16725) - Always position the admin section last in the navigation menu
* [#16791](https://github.com/netbox-community/netbox/issues/16791) - Add 200 & 400 Gbps selections for circuit termination port speed
* [#16802](https://github.com/netbox-community/netbox/issues/16802) - Introduce `SENTRY_SEND_DEFAULT_PII` configuration parameter and disable PII export by default
* [#16817](https://github.com/netbox-community/netbox/issues/16817) - Add 200 & 400 Gbps selections for circuit commit rate
### Bug Fixes
* [#16523](https://github.com/netbox-community/netbox/issues/16523) - Restore highlighting of current device in virtual chassis members panel
* [#16654](https://github.com/netbox-community/netbox/issues/16654) - Fix parent item assignment for inventory item bulk import
* [#16657](https://github.com/netbox-community/netbox/issues/16657) - Fix translation of object types in global search
* [#16679](https://github.com/netbox-community/netbox/issues/16679) - Avoid overwriting custom JSON fields during bulk edit
* [#16689](https://github.com/netbox-community/netbox/issues/16689) - System configuration view should reflect static parameters when no config revisions exist
* [#16714](https://github.com/netbox-community/netbox/issues/16714) - Fix cloning of device types with 0U height
* [#16721](https://github.com/netbox-community/netbox/issues/16721) - Fix errant API request after deselecting a rack in device edit form
* [#16723](https://github.com/netbox-community/netbox/issues/16723) - Fix escaping of path to virtual environment in `upgrade.sh`
* [#16735](https://github.com/netbox-community/netbox/issues/16735) - Object list "results" tab should show a count of zero when empty
* [#16747](https://github.com/netbox-community/netbox/issues/16747) - Avoid clearing entire search cache when manually reindexing specific apps/models
* [#16758](https://github.com/netbox-community/netbox/issues/16758) - Ensure manually selected lagnuage persists across browser sessions
* [#16779](https://github.com/netbox-community/netbox/issues/16779) - Fix saved filter selection for child object lists
* [#16780](https://github.com/netbox-community/netbox/issues/16780) - IKE proposal created via REST API should not require authentication_algorithm
* [#16796](https://github.com/netbox-community/netbox/issues/16796) - Allow assignment of VM with no site to a cluster with a site
* [#16806](https://github.com/netbox-community/netbox/issues/16806) - Fix redirect URL when creating contact assignments with "add another" button
* [#16807](https://github.com/netbox-community/netbox/issues/16807) - Fix layout of VLAN edit form when custom fields are present
* [#16808](https://github.com/netbox-community/netbox/issues/16808) - Fix event rule triggering in scenario where objects are updated immediately prior to deletion
* [#16813](https://github.com/netbox-community/netbox/issues/16813) - Fix AttributeError exception when filtering bookmarks in dashboard widget by object type
* [#16843](https://github.com/netbox-community/netbox/issues/16843) - Permit creation of IKE policies via REST API without specifying an IKE mode
---

View File

@ -12,7 +12,14 @@
### Enhancements
* [#7537](https://github.com/netbox-community/netbox/issues/7537) - Add a serial number field for virtual machines
* [#8984](https://github.com/netbox-community/netbox/issues/8984) - Enable filtering of custom script output by log level
* [#15156](https://github.com/netbox-community/netbox/issues/15156) - Add `display_url` field to all REST API serializers
* [#16359](https://github.com/netbox-community/netbox/issues/16359) - Enable plugins to embed content in the top navigation bar
* [#16580](https://github.com/netbox-community/netbox/issues/16580) - Enable individual views to enforce `LOGIN_REQUIRED` selectively (remove `AUTH_EXEMPT_PATHS`)
### Plugins
* [#16726](https://github.com/netbox-community/netbox/issues/16726) - Extend `PluginTemplateExtension` to enable registering multiple models
### Other Changes

View File

@ -206,6 +206,7 @@ nav:
- Rack: 'models/dcim/rack.md'
- RackReservation: 'models/dcim/rackreservation.md'
- RackRole: 'models/dcim/rackrole.md'
- RackType: 'models/dcim/racktype.md'
- RearPort: 'models/dcim/rearport.md'
- RearPortTemplate: 'models/dcim/rearporttemplate.md'
- Region: 'models/dcim/region.md'
@ -225,8 +226,11 @@ nav:
- ExportTemplate: 'models/extras/exporttemplate.md'
- ImageAttachment: 'models/extras/imageattachment.md'
- JournalEntry: 'models/extras/journalentry.md'
- Notification: 'models/extras/notification.md'
- NotificationGroup: 'models/extras/notificationgroup.md'
- SavedFilter: 'models/extras/savedfilter.md'
- StagedChange: 'models/extras/stagedchange.md'
- Subscription: 'models/extras/subscription.md'
- Tag: 'models/extras/tag.md'
- Webhook: 'models/extras/webhook.md'
- IPAM:

View File

@ -9,6 +9,8 @@ urlpatterns = [
# Account views
path('profile/', views.ProfileView.as_view(), name='profile'),
path('bookmarks/', views.BookmarkListView.as_view(), name='bookmarks'),
path('notifications/', views.NotificationListView.as_view(), name='notifications'),
path('subscriptions/', views.SubscriptionListView.as_view(), name='subscriptions'),
path('preferences/', views.UserConfigView.as_view(), name='preferences'),
path('password/', views.ChangePasswordView.as_view(), name='change_password'),
path('api-tokens/', views.UserTokenListView.as_view(), name='usertoken_list'),

View File

@ -22,7 +22,7 @@ from account.models import UserToken
from core.models import ObjectChange
from core.tables import ObjectChangeTable
from extras.models import Bookmark
from extras.tables import BookmarkTable
from extras.tables import BookmarkTable, NotificationTable, SubscriptionTable
from netbox.authentication import get_auth_backend_display, get_saml_idps
from netbox.config import get_config
from netbox.views import generic
@ -113,7 +113,7 @@ class LoginView(View):
# Set the user's preferred language (if any)
if language := request.user.config.get('locale.language'):
response.set_cookie(settings.LANGUAGE_COOKIE_NAME, language)
response.set_cookie(settings.LANGUAGE_COOKIE_NAME, language, max_age=request.session.get_expiry_age())
return response
@ -208,7 +208,7 @@ class UserConfigView(LoginRequiredMixin, View):
# Set/clear language cookie
if language := form.cleaned_data['locale.language']:
response.set_cookie(settings.LANGUAGE_COOKIE_NAME, language)
response.set_cookie(settings.LANGUAGE_COOKIE_NAME, language, max_age=request.session.get_expiry_age())
else:
response.delete_cookie(settings.LANGUAGE_COOKIE_NAME)
@ -267,6 +267,36 @@ class BookmarkListView(LoginRequiredMixin, generic.ObjectListView):
}
#
# Notifications & subscriptions
#
class NotificationListView(LoginRequiredMixin, generic.ObjectListView):
table = NotificationTable
template_name = 'account/notifications.html'
def get_queryset(self, request):
return request.user.notifications.all()
def get_extra_context(self, request):
return {
'active_tab': 'notifications',
}
class SubscriptionListView(LoginRequiredMixin, generic.ObjectListView):
table = SubscriptionTable
template_name = 'account/subscriptions.html'
def get_queryset(self, request):
return request.user.subscriptions.all()
def get_extra_context(self, request):
return {
'active_tab': 'subscriptions',
}
#
# User views for token management
#

View File

@ -38,6 +38,8 @@ class CircuitCommitRateChoices(ChoiceSet):
(25000000, '25 Gbps'),
(40000000, '40 Gbps'),
(100000000, '100 Gbps'),
(200000000, '200 Gbps'),
(400000000, '400 Gbps'),
(1544, 'T1 (1.544 Mbps)'),
(2048, 'E1 (2.048 Mbps)'),
]
@ -69,6 +71,8 @@ class CircuitTerminationPortSpeedChoices(ChoiceSet):
(25000000, '25 Gbps'),
(40000000, '40 Gbps'),
(100000000, '100 Gbps'),
(200000000, '200 Gbps'),
(400000000, '400 Gbps'),
(1544, 'T1 (1.544 Mbps)'),
(2048, 'E1 (2.048 Mbps)'),
]

View File

@ -66,9 +66,6 @@ class CircuitTypeImportForm(NetBoxModelImportForm):
class Meta:
model = CircuitType
fields = ('name', 'slug', 'color', 'description', 'tags')
help_texts = {
'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' <code>00ff00</code>'),
}
class CircuitImportForm(NetBoxModelImportForm):

View File

@ -18,7 +18,7 @@ class CoreConfig(AppConfig):
def ready(self):
from core.api import schema # noqa
from netbox.models.features import register_models
from . import data_backends, search
from . import data_backends, events, search
# Register models
register_models(*self.get_models())

33
netbox/core/events.py Normal file
View File

@ -0,0 +1,33 @@
from django.utils.translation import gettext as _
from netbox.events import *
__all__ = (
'JOB_COMPLETED',
'JOB_ERRORED',
'JOB_FAILED',
'JOB_STARTED',
'OBJECT_CREATED',
'OBJECT_DELETED',
'OBJECT_UPDATED',
)
# Object events
OBJECT_CREATED = 'object_created'
OBJECT_UPDATED = 'object_updated'
OBJECT_DELETED = 'object_deleted'
# Job events
JOB_STARTED = 'job_started'
JOB_COMPLETED = 'job_completed'
JOB_FAILED = 'job_failed'
JOB_ERRORED = 'job_errored'
# Register core events
Event(name=OBJECT_CREATED, text=_('Object created')).register()
Event(name=OBJECT_UPDATED, text=_('Object updated')).register()
Event(name=OBJECT_DELETED, text=_('Object deleted')).register()
Event(name=JOB_STARTED, text=_('Job started')).register()
Event(name=JOB_COMPLETED, text=_('Job completed'), type=EVENT_TYPE_SUCCESS).register()
Event(name=JOB_FAILED, text=_('Job failed'), type=EVENT_TYPE_WARNING).register()
Event(name=JOB_ERRORED, text=_('Job errored'), type=EVENT_TYPE_DANGER).register()

View File

@ -13,7 +13,6 @@ from django.utils.translation import gettext as _
from core.choices import JobStatusChoices
from core.models import ObjectType
from core.signals import job_end, job_start
from extras.constants import EVENT_JOB_END, EVENT_JOB_START
from netbox.config import get_config
from netbox.constants import RQ_QUEUE_DEFAULT
from utilities.querysets import RestrictedQuerySet

View File

@ -625,7 +625,7 @@ class SystemView(UserPassesTestMixin, View):
config = ConfigRevision.objects.get(pk=cache.get('config_version'))
except ConfigRevision.DoesNotExist:
# Fall back to using the active config data if no record is found
config = ConfigRevision(data=get_config().defaults)
config = get_config()
# Raw data export
if 'export' in request.GET:

View File

@ -3,12 +3,13 @@ from rest_framework import serializers
from dcim.choices import *
from dcim.constants import *
from dcim.models import Rack, RackReservation, RackRole
from dcim.models import Rack, RackReservation, RackRole, RackType
from netbox.api.fields import ChoiceField, RelatedObjectCountField
from netbox.api.serializers import NetBoxModelSerializer
from netbox.config import ConfigItem
from tenancy.api.serializers_.tenants import TenantSerializer
from users.api.serializers_.users import UserSerializer
from .manufacturers import ManufacturerSerializer
from .sites import LocationSerializer, SiteSerializer
__all__ = (
@ -16,6 +17,7 @@ __all__ = (
'RackReservationSerializer',
'RackRoleSerializer',
'RackSerializer',
'RackTypeSerializer',
)
@ -33,18 +35,84 @@ class RackRoleSerializer(NetBoxModelSerializer):
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'rack_count')
class RackSerializer(NetBoxModelSerializer):
site = SiteSerializer(nested=True)
location = LocationSerializer(nested=True, required=False, allow_null=True, default=None)
tenant = TenantSerializer(nested=True, required=False, allow_null=True)
status = ChoiceField(choices=RackStatusChoices, required=False)
role = RackRoleSerializer(nested=True, required=False, allow_null=True)
type = ChoiceField(choices=RackTypeChoices, allow_blank=True, required=False, allow_null=True)
facility_id = serializers.CharField(max_length=50, allow_blank=True, allow_null=True, label=_('Facility ID'),
default=None)
width = ChoiceField(choices=RackWidthChoices, required=False)
outer_unit = ChoiceField(choices=RackDimensionUnitChoices, allow_blank=True, required=False, allow_null=True)
weight_unit = ChoiceField(choices=WeightUnitChoices, allow_blank=True, required=False, allow_null=True)
class RackBaseSerializer(NetBoxModelSerializer):
form_factor = ChoiceField(
choices=RackFormFactorChoices,
allow_blank=True,
required=False,
allow_null=True
)
width = ChoiceField(
choices=RackWidthChoices,
required=False
)
outer_unit = ChoiceField(
choices=RackDimensionUnitChoices,
allow_blank=True,
required=False,
allow_null=True
)
weight_unit = ChoiceField(
choices=WeightUnitChoices,
allow_blank=True,
required=False,
allow_null=True
)
class RackTypeSerializer(RackBaseSerializer):
manufacturer = ManufacturerSerializer(
nested=True
)
class Meta:
model = RackType
fields = [
'id', 'url', 'display_url', 'display', 'manufacturer', 'name', 'slug', 'description', 'form_factor',
'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'weight',
'max_weight', 'weight_unit', 'mounting_depth', 'description', 'comments', 'tags', 'custom_fields',
'created', 'last_updated',
]
brief_fields = ('id', 'url', 'display', 'manufacturer', 'name', 'slug', 'description')
class RackSerializer(RackBaseSerializer):
site = SiteSerializer(
nested=True
)
location = LocationSerializer(
nested=True,
required=False,
allow_null=True,
default=None
)
tenant = TenantSerializer(
nested=True,
required=False,
allow_null=True
)
status = ChoiceField(
choices=RackStatusChoices,
required=False
)
role = RackRoleSerializer(
nested=True,
required=False,
allow_null=True
)
facility_id = serializers.CharField(
max_length=50,
allow_blank=True,
allow_null=True,
label=_('Facility ID'),
default=None
)
rack_type = RackTypeSerializer(
nested=True,
required=False,
allow_null=True,
default=None
)
# Related object counts
device_count = RelatedObjectCountField('devices')
@ -54,9 +122,10 @@ class RackSerializer(NetBoxModelSerializer):
model = Rack
fields = [
'id', 'url', 'display_url', 'display', 'name', 'facility_id', 'site', 'location', 'tenant', 'status',
'role', 'serial', 'asset_tag', 'type', 'width', 'u_height', 'starting_unit', 'weight', 'max_weight',
'weight_unit', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'description',
'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count', 'powerfeed_count',
'role', 'serial', 'asset_tag', 'rack_type', 'form_factor', 'width', 'u_height', 'starting_unit', 'weight',
'max_weight', 'weight_unit', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth',
'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated', 'device_count',
'powerfeed_count',
]
brief_fields = ('id', 'url', 'display', 'name', 'description', 'device_count')

View File

@ -12,6 +12,7 @@ router.register('sites', views.SiteViewSet)
# Racks
router.register('locations', views.LocationViewSet)
router.register('rack-types', views.RackTypeViewSet)
router.register('rack-roles', views.RackRoleViewSet)
router.register('racks', views.RackViewSet)
router.register('rack-reservations', views.RackReservationViewSet)

View File

@ -161,6 +161,16 @@ class RackRoleViewSet(NetBoxModelViewSet):
filterset_class = filtersets.RackRoleFilterSet
#
# Rack Types
#
class RackTypeViewSet(NetBoxModelViewSet):
queryset = RackType.objects.all()
serializer_class = serializers.RackTypeSerializer
filterset_class = filtersets.RackTypeFilterSet
#
# Racks
#

View File

@ -51,7 +51,7 @@ class LocationStatusChoices(ChoiceSet):
# Racks
#
class RackTypeChoices(ChoiceSet):
class RackFormFactorChoices(ChoiceSet):
TYPE_2POST = '2-post-frame'
TYPE_4POST = '4-post-frame'

View File

@ -20,7 +20,7 @@ from utilities.filters import (
ContentTypeFilter, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, MultiValueWWNFilter,
NumericArrayFilter, TreeNodeMultipleChoiceFilter,
)
from virtualization.models import Cluster
from virtualization.models import Cluster, ClusterGroup
from vpn.models import L2VPN
from wireless.choices import WirelessRoleChoices, WirelessChannelChoices
from wireless.models import WirelessLAN, WirelessLink
@ -69,6 +69,7 @@ __all__ = (
'RackFilterSet',
'RackReservationFilterSet',
'RackRoleFilterSet',
'RackTypeFilterSet',
'RearPortFilterSet',
'RearPortTemplateFilterSet',
'RegionFilterSet',
@ -289,6 +290,41 @@ class RackRoleFilterSet(OrganizationalModelFilterSet):
fields = ('id', 'name', 'slug', 'color', 'description')
class RackTypeFilterSet(NetBoxModelFilterSet):
manufacturer_id = django_filters.ModelMultipleChoiceFilter(
queryset=Manufacturer.objects.all(),
label=_('Manufacturer (ID)'),
)
manufacturer = django_filters.ModelMultipleChoiceFilter(
field_name='manufacturer__slug',
queryset=Manufacturer.objects.all(),
to_field_name='slug',
label=_('Manufacturer (slug)'),
)
form_factor = django_filters.MultipleChoiceFilter(
choices=RackFormFactorChoices
)
width = django_filters.MultipleChoiceFilter(
choices=RackWidthChoices
)
class Meta:
model = RackType
fields = (
'id', 'name', 'slug', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit',
'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'description',
)
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(description__icontains=value) |
Q(comments__icontains=value)
)
class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSet):
region_id = TreeNodeMultipleChoiceFilter(
queryset=Region.objects.all(),
@ -339,12 +375,22 @@ class RackFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFilterSe
to_field_name='slug',
label=_('Location (slug)'),
)
rack_type = django_filters.ModelMultipleChoiceFilter(
field_name='rack_type__slug',
queryset=RackType.objects.all(),
to_field_name='slug',
label=_('Rack type (slug)'),
)
rack_type_id = django_filters.ModelMultipleChoiceFilter(
queryset=RackType.objects.all(),
label=_('Rack type (ID)'),
)
status = django_filters.MultipleChoiceFilter(
choices=RackStatusChoices,
null_value=None
)
type = django_filters.MultipleChoiceFilter(
choices=RackTypeChoices
form_factor = django_filters.MultipleChoiceFilter(
choices=RackFormFactorChoices
)
width = django_filters.MultipleChoiceFilter(
choices=RackWidthChoices
@ -1012,6 +1058,17 @@ class DeviceFilterSet(
queryset=Cluster.objects.all(),
label=_('VM cluster (ID)'),
)
cluster_group = django_filters.ModelMultipleChoiceFilter(
field_name='cluster__group__slug',
queryset=ClusterGroup.objects.all(),
to_field_name='slug',
label=_('Cluster group (slug)'),
)
cluster_group_id = django_filters.ModelMultipleChoiceFilter(
field_name='cluster__group',
queryset=ClusterGroup.objects.all(),
label=_('Cluster group (ID)'),
)
model = django_filters.ModelMultipleChoiceFilter(
field_name='device_type__slug',
queryset=DeviceType.objects.all(),

View File

@ -13,7 +13,7 @@ from netbox.forms import NetBoxModelBulkEditForm
from tenancy.models import Tenant
from utilities.forms import BulkEditForm, add_blank_choice, form_from_model
from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField
from utilities.forms.rendering import FieldSet
from utilities.forms.rendering import FieldSet, InlineFields
from utilities.forms.widgets import BulkEditNullBooleanSelect, NumberWithOptions
from wireless.models import WirelessLAN, WirelessLANGroup
from wireless.choices import WirelessRoleChoices
@ -52,6 +52,7 @@ __all__ = (
'RackBulkEditForm',
'RackReservationBulkEditForm',
'RackRoleBulkEditForm',
'RackTypeBulkEditForm',
'RearPortBulkEditForm',
'RearPortTemplateBulkEditForm',
'RegionBulkEditForm',
@ -218,6 +219,97 @@ class RackRoleBulkEditForm(NetBoxModelBulkEditForm):
nullable_fields = ('color', 'description')
class RackTypeBulkEditForm(NetBoxModelBulkEditForm):
manufacturer = DynamicModelChoiceField(
label=_('Manufacturer'),
queryset=Manufacturer.objects.all(),
required=False
)
form_factor = forms.ChoiceField(
label=_('Form factor'),
choices=add_blank_choice(RackFormFactorChoices),
required=False
)
width = forms.ChoiceField(
label=_('Width'),
choices=add_blank_choice(RackWidthChoices),
required=False
)
u_height = forms.IntegerField(
required=False,
label=_('Height (U)')
)
starting_unit = forms.IntegerField(
required=False,
min_value=1
)
desc_units = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect,
label=_('Descending units')
)
outer_width = forms.IntegerField(
label=_('Outer width'),
required=False,
min_value=1
)
outer_depth = forms.IntegerField(
label=_('Outer depth'),
required=False,
min_value=1
)
outer_unit = forms.ChoiceField(
label=_('Outer unit'),
choices=add_blank_choice(RackDimensionUnitChoices),
required=False
)
mounting_depth = forms.IntegerField(
label=_('Mounting depth'),
required=False,
min_value=1
)
weight = forms.DecimalField(
label=_('Weight'),
min_value=0,
required=False
)
max_weight = forms.IntegerField(
label=_('Max weight'),
min_value=0,
required=False
)
weight_unit = forms.ChoiceField(
label=_('Weight unit'),
choices=add_blank_choice(WeightUnitChoices),
required=False,
initial=''
)
description = forms.CharField(
label=_('Description'),
max_length=200,
required=False
)
comments = CommentField()
model = RackType
fieldsets = (
FieldSet('manufacturer', 'description', 'form_factor', name=_('Rack Type')),
FieldSet(
'width',
'u_height',
InlineFields('outer_width', 'outer_depth', 'outer_unit', label=_('Outer Dimensions')),
InlineFields('weight', 'max_weight', 'weight_unit', label=_('Weight')),
'mounting_depth',
name=_('Dimensions')
),
FieldSet('starting_unit', 'desc_units', name=_('Numbering')),
)
nullable_fields = (
'outer_width', 'outer_depth', 'outer_unit', 'weight',
'max_weight', 'weight_unit', 'description', 'comments',
)
class RackBulkEditForm(NetBoxModelBulkEditForm):
region = DynamicModelChoiceField(
label=_('Region'),
@ -278,9 +370,9 @@ class RackBulkEditForm(NetBoxModelBulkEditForm):
max_length=50,
required=False
)
type = forms.ChoiceField(
label=_('Type'),
choices=add_blank_choice(RackTypeChoices),
form_factor = forms.ChoiceField(
label=_('Form factor'),
choices=add_blank_choice(RackFormFactorChoices),
required=False
)
width = forms.ChoiceField(
@ -345,8 +437,8 @@ class RackBulkEditForm(NetBoxModelBulkEditForm):
FieldSet('status', 'role', 'tenant', 'serial', 'asset_tag', 'description', name=_('Rack')),
FieldSet('region', 'site_group', 'site', 'location', name=_('Location')),
FieldSet(
'type', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth',
name=_('Hardware')
'form_factor', 'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit',
'mounting_depth', name=_('Hardware')
),
FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')),
)

View File

@ -45,6 +45,7 @@ __all__ = (
'RackImportForm',
'RackReservationImportForm',
'RackRoleImportForm',
'RackTypeImportForm',
'RearPortImportForm',
'RegionImportForm',
'SiteImportForm',
@ -174,9 +175,54 @@ class RackRoleImportForm(NetBoxModelImportForm):
class Meta:
model = RackRole
fields = ('name', 'slug', 'color', 'description', 'tags')
help_texts = {
'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' <code>00ff00</code>'),
}
class RackTypeImportForm(NetBoxModelImportForm):
manufacturer = forms.ModelChoiceField(
label=_('Manufacturer'),
queryset=Manufacturer.objects.all(),
to_field_name='name',
help_text=_('The manufacturer of this rack type')
)
form_factor = CSVChoiceField(
label=_('Type'),
choices=RackFormFactorChoices,
required=False,
help_text=_('Form factor')
)
starting_unit = forms.IntegerField(
required=False,
min_value=1,
help_text=_('The lowest-numbered position in the rack')
)
width = forms.ChoiceField(
label=_('Width'),
choices=RackWidthChoices,
help_text=_('Rail-to-rail width (in inches)')
)
outer_unit = CSVChoiceField(
label=_('Outer unit'),
choices=RackDimensionUnitChoices,
required=False,
help_text=_('Unit for outer dimensions')
)
weight_unit = CSVChoiceField(
label=_('Weight unit'),
choices=WeightUnitChoices,
required=False,
help_text=_('Unit for rack weights')
)
class Meta:
model = RackType
fields = (
'manufacturer', 'name', 'slug', 'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units',
'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit',
'description', 'comments', 'tags',
)
def __init__(self, data=None, *args, **kwargs):
super().__init__(data, *args, **kwargs)
class RackImportForm(NetBoxModelImportForm):
@ -210,11 +256,11 @@ class RackImportForm(NetBoxModelImportForm):
to_field_name='name',
help_text=_('Name of assigned role')
)
type = CSVChoiceField(
form_factor = CSVChoiceField(
label=_('Type'),
choices=RackTypeChoices,
choices=RackFormFactorChoices,
required=False,
help_text=_('Rack type')
help_text=_('Form factor')
)
width = forms.ChoiceField(
label=_('Width'),
@ -237,7 +283,7 @@ class RackImportForm(NetBoxModelImportForm):
class Meta:
model = Rack
fields = (
'site', 'location', 'name', 'facility_id', 'tenant', 'status', 'role', 'type', 'serial', 'asset_tag',
'site', 'location', 'name', 'facility_id', 'tenant', 'status', 'role', 'form_factor', 'serial', 'asset_tag',
'width', 'u_height', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'weight',
'max_weight', 'weight_unit', 'description', 'comments', 'tags',
)
@ -384,9 +430,6 @@ class DeviceRoleImportForm(NetBoxModelImportForm):
class Meta:
model = DeviceRole
fields = ('name', 'slug', 'color', 'vm_role', 'config_template', 'description', 'tags')
help_texts = {
'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' <code>00ff00</code>'),
}
class PlatformImportForm(NetBoxModelImportForm):
@ -1052,7 +1095,7 @@ class InventoryItemImportForm(NetBoxModelImportForm):
class Meta:
model = InventoryItem
fields = (
'device', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered',
'device', 'name', 'label', 'role', 'manufacturer', 'parent', 'part_id', 'serial', 'asset_tag', 'discovered',
'description', 'tags', 'component_type', 'component_name',
)
@ -1104,9 +1147,6 @@ class InventoryItemRoleImportForm(NetBoxModelImportForm):
class Meta:
model = InventoryItemRole
fields = ('name', 'slug', 'color', 'description')
help_texts = {
'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' <code>00ff00</code>'),
}
#
@ -1183,9 +1223,6 @@ class CableImportForm(NetBoxModelImportForm):
'side_a_device', 'side_a_type', 'side_a_name', 'side_b_device', 'side_b_type', 'side_b_name', 'type',
'status', 'tenant', 'label', 'color', 'length', 'length_unit', 'description', 'comments', 'tags',
]
help_texts = {
'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' <code>00ff00</code>'),
}
def _clean_side(self, side):
"""

View File

@ -14,6 +14,7 @@ from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_ch
from utilities.forms.fields import ColorField, DynamicModelMultipleChoiceField, TagFilterField
from utilities.forms.rendering import FieldSet
from utilities.forms.widgets import NumberWithOptions
from virtualization.models import Cluster, ClusterGroup
from vpn.models import L2VPN
from wireless.choices import *
@ -47,6 +48,7 @@ __all__ = (
'RackElevationFilterForm',
'RackReservationFilterForm',
'RackRoleFilterForm',
'RackTypeFilterForm',
'RearPortFilterForm',
'RegionFilterForm',
'SiteFilterForm',
@ -239,16 +241,77 @@ class RackRoleFilterForm(NetBoxModelFilterSetForm):
tag = TagFilterField(model)
class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm):
class RackBaseFilterForm(NetBoxModelFilterSetForm):
form_factor = forms.MultipleChoiceField(
label=_('Form factor'),
choices=RackFormFactorChoices,
required=False
)
width = forms.MultipleChoiceField(
label=_('Width'),
choices=RackWidthChoices,
required=False
)
u_height = forms.IntegerField(
required=False,
min_value=1
)
starting_unit = forms.IntegerField(
required=False,
min_value=1
)
desc_units = forms.NullBooleanField(
required=False,
label=_('Descending units'),
widget=forms.Select(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
weight = forms.DecimalField(
label=_('Weight'),
required=False,
min_value=1
)
max_weight = forms.IntegerField(
label=_('Max weight'),
required=False,
min_value=1
)
weight_unit = forms.ChoiceField(
label=_('Weight unit'),
choices=add_blank_choice(WeightUnitChoices),
required=False
)
class RackTypeFilterForm(RackBaseFilterForm):
model = RackType
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('form_factor', 'width', 'u_height', name=_('Rack Type')),
FieldSet('starting_unit', 'desc_units', name=_('Numbering')),
FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')),
)
selector_fields = ('filter_id', 'q', 'manufacturer_id')
manufacturer_id = DynamicModelMultipleChoiceField(
queryset=Manufacturer.objects.all(),
required=False,
label=_('Manufacturer')
)
tag = TagFilterField(model)
class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, RackBaseFilterForm):
model = Rack
fieldsets = (
FieldSet('q', 'filter_id', 'tag'),
FieldSet('region_id', 'site_group_id', 'site_id', 'location_id', name=_('Location')),
FieldSet('status', 'role_id', name=_('Function')),
FieldSet('type', 'width', 'serial', 'asset_tag', name=_('Hardware')),
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
FieldSet('status', 'role_id', 'serial', 'asset_tag', name=_('Rack')),
FieldSet('form_factor', 'width', 'u_height', name=_('Rack Type')),
FieldSet('starting_unit', 'desc_units', name=_('Numbering')),
FieldSet('weight', 'max_weight', 'weight_unit', name=_('Weight')),
FieldSet('contact', 'contact_role', 'contact_group', name=_('Contacts')),
)
selector_fields = ('filter_id', 'q', 'region_id', 'site_group_id', 'site_id', 'location_id')
region_id = DynamicModelMultipleChoiceField(
@ -283,16 +346,6 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
choices=RackStatusChoices,
required=False
)
type = forms.MultipleChoiceField(
label=_('Type'),
choices=RackTypeChoices,
required=False
)
width = forms.MultipleChoiceField(
label=_('Width'),
choices=RackWidthChoices,
required=False
)
role_id = DynamicModelMultipleChoiceField(
queryset=RackRole.objects.all(),
required=False,
@ -308,21 +361,6 @@ class RackFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilte
required=False
)
tag = TagFilterField(model)
weight = forms.DecimalField(
label=_('Weight'),
required=False,
min_value=1
)
max_weight = forms.IntegerField(
label=_('Max weight'),
required=False,
min_value=1
)
weight_unit = forms.ChoiceField(
label=_('Weight unit'),
choices=add_blank_choice(WeightUnitChoices),
required=False
)
class RackElevationFilterForm(RackFilterForm):
@ -655,6 +693,7 @@ class DeviceFilterForm(
'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports',
name=_('Components')
),
FieldSet('cluster_group_id', 'cluster_id', name=_('Cluster')),
FieldSet(
'has_primary_ip', 'has_oob_ip', 'virtual_chassis_member', 'config_template_id', 'local_context_data',
'has_virtual_device_context',
@ -821,6 +860,16 @@ class DeviceFilterForm(
choices=BOOLEAN_WITH_BLANK_CHOICES
)
)
cluster_id = DynamicModelMultipleChoiceField(
queryset=Cluster.objects.all(),
required=False,
label=_('Cluster')
)
cluster_group_id = DynamicModelMultipleChoiceField(
queryset=ClusterGroup.objects.all(),
required=False,
label=_('Cluster group')
)
tag = TagFilterField(model)

View File

@ -11,7 +11,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 utilities.forms import add_blank_choice
from utilities.forms import add_blank_choice, get_field_value
from utilities.forms.fields import (
CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField, NumericArrayField, SlugField,
)
@ -57,6 +57,7 @@ __all__ = (
'RackForm',
'RackReservationForm',
'RackRoleForm',
'RackTypeForm',
'RearPortForm',
'RearPortTemplateForm',
'RegionForm',
@ -201,6 +202,34 @@ class RackRoleForm(NetBoxModelForm):
]
class RackTypeForm(NetBoxModelForm):
manufacturer = DynamicModelChoiceField(
label=_('Manufacturer'),
queryset=Manufacturer.objects.all()
)
comments = CommentField()
slug = SlugField()
fieldsets = (
FieldSet('manufacturer', 'name', 'slug', 'description', 'form_factor', 'tags', name=_('Rack Type')),
FieldSet(
'width', 'u_height',
InlineFields('outer_width', 'outer_depth', 'outer_unit', label=_('Outer Dimensions')),
InlineFields('weight', 'max_weight', 'weight_unit', label=_('Weight')),
'mounting_depth', name=_('Dimensions')
),
FieldSet('starting_unit', 'desc_units', name=_('Numbering')),
)
class Meta:
model = RackType
fields = [
'manufacturer', 'name', 'slug', 'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units',
'outer_width', 'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit',
'description', 'comments', 'tags',
]
class RackForm(TenancyForm, NetBoxModelForm):
site = DynamicModelChoiceField(
label=_('Site'),
@ -220,28 +249,54 @@ class RackForm(TenancyForm, NetBoxModelForm):
queryset=RackRole.objects.all(),
required=False
)
rack_type = DynamicModelChoiceField(
label=_('Rack Type'),
queryset=RackType.objects.all(),
required=False,
help_text=_("Select a pre-defined rack type, or set physical characteristics below.")
)
comments = CommentField()
fieldsets = (
FieldSet('site', 'location', 'name', 'status', 'role', 'description', 'tags', name=_('Rack')),
FieldSet('site', 'location', 'name', 'status', 'role', 'rack_type', 'description', 'tags', name=_('Rack')),
FieldSet('facility_id', 'serial', 'asset_tag', name=_('Inventory Control')),
FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
FieldSet(
'type', 'width', 'starting_unit', 'u_height',
InlineFields('outer_width', 'outer_depth', 'outer_unit', label=_('Outer Dimensions')),
InlineFields('weight', 'max_weight', 'weight_unit', label=_('Weight')),
'mounting_depth', 'desc_units', name=_('Dimensions')
),
)
class Meta:
model = Rack
fields = [
'site', 'location', 'name', 'facility_id', 'tenant_group', 'tenant', 'status', 'role', 'serial',
'asset_tag', 'type', 'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_depth',
'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'description', 'comments', 'tags',
'asset_tag', 'rack_type', 'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width',
'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit', 'description',
'comments', 'tags',
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# Mimic HTMXSelect()
self.fields['rack_type'].widget.attrs.update({
'hx-get': '.',
'hx-include': '#form_fields',
'hx-target': '#form_fields',
})
# Omit RackType-defined fields if rack_type is set
if get_field_value(self, 'rack_type'):
for field_name in Rack.RACKTYPE_FIELDS:
del self.fields[field_name]
else:
self.fieldsets = (
*self.fieldsets,
FieldSet(
'form_factor', 'width', 'starting_unit', 'u_height',
InlineFields('outer_width', 'outer_depth', 'outer_unit', label=_('Outer Dimensions')),
InlineFields('weight', 'max_weight', 'weight_unit', label=_('Weight')),
'mounting_depth', 'desc_units', name=_('Dimensions')
),
)
class RackReservationForm(TenancyForm, NetBoxModelForm):
rack = DynamicModelChoiceField(

View File

@ -38,6 +38,7 @@ __all__ = (
'RackFilter',
'RackReservationFilter',
'RackRoleFilter',
'RackTypeFilter',
'RearPortFilter',
'RearPortTemplateFilter',
'RegionFilter',
@ -234,6 +235,12 @@ class PowerPortTemplateFilter(BaseFilterMixin):
pass
@strawberry_django.filter(models.RackType, lookups=True)
@autotype_decorator(filtersets.RackTypeFilterSet)
class RackTypeFilter(BaseFilterMixin):
pass
@strawberry_django.filter(models.Rack, lookups=True)
@autotype_decorator(filtersets.RackFilterSet)
class RackFilter(BaseFilterMixin):

View File

@ -159,6 +159,11 @@ class DCIMQuery:
return models.PowerPortTemplate.objects.get(id=id)
power_port_template_list: List[PowerPortTemplateType] = strawberry_django.field()
@strawberry.field
def rack_type(self, id: int) -> RackTypeType:
return models.RackType.objects.get(id=id)
rack_type_list: List[RackTypeType] = strawberry_django.field()
@strawberry.field
def rack(self, id: int) -> RackType:
return models.Rack.objects.get(id=id)

View File

@ -50,6 +50,7 @@ __all__ = (
'RackType',
'RackReservationType',
'RackRoleType',
'RackTypeType',
'RearPortType',
'RearPortTemplateType',
'RegionType',
@ -606,6 +607,16 @@ class PowerPortTemplateType(ModularComponentTemplateType):
poweroutlet_templates: List[Annotated["PowerOutletTemplateType", strawberry.lazy('dcim.graphql.types')]]
@strawberry_django.type(
models.RackType,
fields='__all__',
filters=RackTypeFilter
)
class RackTypeType(NetBoxObjectType):
_name: str
manufacturer: Annotated["ManufacturerType", strawberry.lazy('dcim.graphql.types')]
@strawberry_django.type(
models.Rack,
fields='__all__',
@ -618,6 +629,7 @@ class RackType(VLANGroupsMixin, ImageAttachmentsMixin, ContactsMixin, NetBoxObje
tenant: Annotated["TenantType", strawberry.lazy('tenancy.graphql.types')] | None
role: Annotated["RackRoleType", strawberry.lazy('dcim.graphql.types')] | None
rack_type: Annotated["RackTypeType", strawberry.lazy('dcim.graphql.types')] | None
reservations: List[Annotated["RackReservationType", strawberry.lazy('dcim.graphql.types')]]
devices: List[Annotated["DeviceType", strawberry.lazy('dcim.graphql.types')]]
powerfeeds: List[Annotated["PowerFeedType", strawberry.lazy('dcim.graphql.types')]]

View File

@ -0,0 +1,93 @@
import django.core.validators
import django.db.models.deletion
import taggit.managers
from django.db import migrations, models
import utilities.fields
import utilities.json
import utilities.ordering
class Migration(migrations.Migration):
dependencies = [
('extras', '0117_customfield_uniqueness'),
('dcim', '0187_alter_device_vc_position'),
]
operations = [
migrations.CreateModel(
name='RackType',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('created', models.DateTimeField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)),
('custom_field_data', models.JSONField(
blank=True,
default=dict,
encoder=utilities.json.CustomFieldJSONEncoder
)),
('description', models.CharField(blank=True, max_length=200)),
('comments', models.TextField(blank=True)),
('weight', models.DecimalField(blank=True, decimal_places=2, max_digits=8, null=True)),
('weight_unit', models.CharField(blank=True, max_length=50)),
('_abs_weight', models.PositiveBigIntegerField(blank=True, null=True)),
('manufacturer', models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
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
),
),
('slug', models.SlugField(max_length=100, unique=True)),
('form_factor', models.CharField(blank=True, max_length=50)),
('width', models.PositiveSmallIntegerField(default=19)),
('u_height', models.PositiveSmallIntegerField(
default=42,
validators=[
django.core.validators.MinValueValidator(1),
django.core.validators.MaxValueValidator(100),
]
)),
('starting_unit', models.PositiveSmallIntegerField(
default=1,
validators=[django.core.validators.MinValueValidator(1)]
)),
('desc_units', models.BooleanField(default=False)),
('outer_width', models.PositiveSmallIntegerField(blank=True, null=True)),
('outer_depth', models.PositiveSmallIntegerField(blank=True, null=True)),
('outer_unit', models.CharField(blank=True, max_length=50)),
('max_weight', models.PositiveIntegerField(blank=True, null=True)),
('_abs_max_weight', models.PositiveBigIntegerField(blank=True, null=True)),
('mounting_depth', models.PositiveSmallIntegerField(blank=True, null=True)),
('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
],
options={
'verbose_name': 'racktype',
'verbose_name_plural': 'racktypes',
'ordering': ('_name', 'pk'),
},
),
migrations.RenameField(
model_name='rack',
old_name='type',
new_name='form_factor',
),
migrations.AddField(
model_name='rack',
name='rack_type',
field=models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.PROTECT,
related_name='racks',
to='dcim.racktype',
),
),
]

View File

@ -29,9 +29,181 @@ __all__ = (
'Rack',
'RackReservation',
'RackRole',
'RackType',
)
#
# Rack Types
#
class RackBase(WeightMixin, PrimaryModel):
"""
Base class for RackType & Rack. Holds
"""
form_factor = models.CharField(
choices=RackFormFactorChoices,
max_length=50,
blank=True,
verbose_name=_('form factor')
)
width = models.PositiveSmallIntegerField(
choices=RackWidthChoices,
default=RackWidthChoices.WIDTH_19IN,
verbose_name=_('width'),
help_text=_('Rail-to-rail width')
)
# Numbering
u_height = models.PositiveSmallIntegerField(
default=RACK_U_HEIGHT_DEFAULT,
verbose_name=_('height (U)'),
validators=[MinValueValidator(1), MaxValueValidator(RACK_U_HEIGHT_MAX)],
help_text=_('Height in rack units')
)
starting_unit = models.PositiveSmallIntegerField(
default=RACK_STARTING_UNIT_DEFAULT,
verbose_name=_('starting unit'),
validators=[MinValueValidator(1)],
help_text=_('Starting unit for rack')
)
desc_units = models.BooleanField(
default=False,
verbose_name=_('descending units'),
help_text=_('Units are numbered top-to-bottom')
)
# Dimensions
outer_width = models.PositiveSmallIntegerField(
verbose_name=_('outer width'),
blank=True,
null=True,
help_text=_('Outer dimension of rack (width)')
)
outer_depth = models.PositiveSmallIntegerField(
verbose_name=_('outer depth'),
blank=True,
null=True,
help_text=_('Outer dimension of rack (depth)')
)
outer_unit = models.CharField(
verbose_name=_('outer unit'),
max_length=50,
choices=RackDimensionUnitChoices,
blank=True
)
mounting_depth = models.PositiveSmallIntegerField(
verbose_name=_('mounting depth'),
blank=True,
null=True,
help_text=(_(
'Maximum depth of a mounted device, in millimeters. For four-post racks, this is the distance between the '
'front and rear rails.'
))
)
# Weight
# WeightMixin provides weight, weight_unit, and _abs_weight
max_weight = models.PositiveIntegerField(
verbose_name=_('max weight'),
blank=True,
null=True,
help_text=_('Maximum load capacity for the rack')
)
# Stores the normalized max weight (in grams) for database ordering
_abs_max_weight = models.PositiveBigIntegerField(
blank=True,
null=True
)
class Meta:
abstract = True
class RackType(RackBase):
"""
Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face.
Each Rack is assigned to a Site and (optionally) a Location.
"""
manufacturer = models.ForeignKey(
to='dcim.Manufacturer',
on_delete=models.PROTECT,
related_name='rack_types'
)
name = models.CharField(
verbose_name=_('name'),
max_length=100
)
_name = NaturalOrderingField(
target_field='name',
max_length=100,
blank=True
)
slug = models.SlugField(
verbose_name=_('slug'),
max_length=100,
unique=True
)
clone_fields = (
'manufacturer', 'form_factor', 'width', 'u_height', '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
verbose_name = _('rack type')
verbose_name_plural = _('rack types')
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse('dcim:racktype', args=[self.pk])
def clean(self):
super().clean()
# Validate outer dimensions and unit
if (self.outer_width is not None or self.outer_depth is not None) and not self.outer_unit:
raise ValidationError(_("Must specify a unit when setting an outer width/depth"))
# Validate max_weight and weight_unit
if self.max_weight and not self.weight_unit:
raise ValidationError(_("Must specify a unit when setting a maximum weight"))
def save(self, *args, **kwargs):
# Store the given max weight (if any) in grams for use in database ordering
if self.max_weight and self.weight_unit:
self._abs_max_weight = to_grams(self.max_weight, self.weight_unit)
else:
self._abs_max_weight = None
# Clear unit if outer width & depth are not set
if self.outer_width is None and self.outer_depth is None:
self.outer_unit = ''
super().save(*args, **kwargs)
# Update all Racks associated with this RackType
for rack in self.racks.all():
rack.snapshot()
rack.copy_racktype_attrs()
rack.save()
@property
def units(self):
"""
Return a list of unit numbers, top to bottom.
"""
if self.desc_units:
return drange(decimal.Decimal(self.starting_unit), self.u_height + self.starting_unit, 0.5)
return drange(self.u_height + decimal.Decimal(0.5) + self.starting_unit - 1, 0.5 + self.starting_unit - 1, -0.5)
#
# Racks
#
@ -54,11 +226,24 @@ class RackRole(OrganizationalModel):
return reverse('dcim:rackrole', args=[self.pk])
class Rack(ContactsMixin, ImageAttachmentsMixin, PrimaryModel, WeightMixin):
class Rack(ContactsMixin, ImageAttachmentsMixin, RackBase):
"""
Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face.
Each Rack is assigned to a Site and (optionally) a Location.
"""
# Fields which cannot be set locally if a RackType is assigned
RACKTYPE_FIELDS = [
'form_factor', 'width', 'u_height', 'starting_unit', 'desc_units', 'outer_width', 'outer_depth', 'outer_unit',
'mounting_depth', 'weight', 'weight_unit', 'max_weight'
]
rack_type = models.ForeignKey(
to='dcim.RackType',
on_delete=models.PROTECT,
related_name='racks',
blank=True,
null=True,
)
name = models.CharField(
verbose_name=_('name'),
max_length=100
@ -121,73 +306,6 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, PrimaryModel, WeightMixin):
verbose_name=_('asset tag'),
help_text=_('A unique tag used to identify this rack')
)
type = models.CharField(
choices=RackTypeChoices,
max_length=50,
blank=True,
verbose_name=_('type')
)
width = models.PositiveSmallIntegerField(
choices=RackWidthChoices,
default=RackWidthChoices.WIDTH_19IN,
verbose_name=_('width'),
help_text=_('Rail-to-rail width')
)
u_height = models.PositiveSmallIntegerField(
default=RACK_U_HEIGHT_DEFAULT,
verbose_name=_('height (U)'),
validators=[MinValueValidator(1), MaxValueValidator(RACK_U_HEIGHT_MAX)],
help_text=_('Height in rack units')
)
starting_unit = models.PositiveSmallIntegerField(
default=RACK_STARTING_UNIT_DEFAULT,
verbose_name=_('starting unit'),
validators=[MinValueValidator(1),],
help_text=_('Starting unit for rack')
)
desc_units = models.BooleanField(
default=False,
verbose_name=_('descending units'),
help_text=_('Units are numbered top-to-bottom')
)
outer_width = models.PositiveSmallIntegerField(
verbose_name=_('outer width'),
blank=True,
null=True,
help_text=_('Outer dimension of rack (width)')
)
outer_depth = models.PositiveSmallIntegerField(
verbose_name=_('outer depth'),
blank=True,
null=True,
help_text=_('Outer dimension of rack (depth)')
)
outer_unit = models.CharField(
verbose_name=_('outer unit'),
max_length=50,
choices=RackDimensionUnitChoices,
blank=True,
)
max_weight = models.PositiveIntegerField(
verbose_name=_('max weight'),
blank=True,
null=True,
help_text=_('Maximum load capacity for the rack')
)
# Stores the normalized max weight (in grams) for database ordering
_abs_max_weight = models.PositiveBigIntegerField(
blank=True,
null=True
)
mounting_depth = models.PositiveSmallIntegerField(
verbose_name=_('mounting depth'),
blank=True,
null=True,
help_text=(
_('Maximum depth of a mounted device, in millimeters. For four-post racks, this is the '
'distance between the front and rear rails.')
)
)
# Generic relations
vlan_groups = GenericRelation(
@ -198,7 +316,7 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, PrimaryModel, WeightMixin):
)
clone_fields = (
'site', 'location', 'tenant', 'status', 'role', 'type', 'width', 'u_height', 'desc_units', 'outer_width',
'site', 'location', 'tenant', 'status', 'role', 'form_factor', 'width', 'u_height', 'desc_units', 'outer_width',
'outer_depth', 'outer_unit', 'mounting_depth', 'weight', 'max_weight', 'weight_unit',
)
prerequisite_models = (
@ -271,6 +389,7 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, PrimaryModel, WeightMixin):
})
def save(self, *args, **kwargs):
self.copy_racktype_attrs()
# Store the given max weight (if any) in grams for use in database ordering
if self.max_weight and self.weight_unit:
@ -284,6 +403,14 @@ class Rack(ContactsMixin, ImageAttachmentsMixin, PrimaryModel, WeightMixin):
super().save(*args, **kwargs)
def copy_racktype_attrs(self):
"""
Copy physical attributes from the assigned RackType (if any).
"""
if self.rack_type:
for field_name in self.RACKTYPE_FIELDS:
setattr(self, field_name, getattr(self.rack_type, field_name))
@property
def units(self):
"""

View File

@ -242,6 +242,17 @@ class PowerPortIndex(SearchIndex):
display_attrs = ('device', 'label', 'type', 'description')
@register_search
class RackTypeIndex(SearchIndex):
model = models.RackType
fields = (
('name', 100),
('description', 500),
('comments', 5000),
)
display_attrs = ('type', 'description')
@register_search
class RackIndex(SearchIndex):
model = models.Rack

View File

@ -2,7 +2,7 @@ from django.utils.translation import gettext_lazy as _
import django_tables2 as tables
from django_tables2.utils import Accessor
from dcim.models import Rack, RackReservation, RackRole
from dcim.models import Rack, RackReservation, RackRole, RackType
from netbox.tables import NetBoxTable, columns
from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
from .template_code import WEIGHT
@ -11,6 +11,7 @@ __all__ = (
'RackTable',
'RackReservationTable',
'RackRoleTable',
'RackTypeTable',
)
@ -44,6 +45,61 @@ class RackRoleTable(NetBoxTable):
default_columns = ('pk', 'name', 'rack_count', 'color', 'description')
#
# Rack Types
#
class RackTypeTable(NetBoxTable):
name = tables.Column(
verbose_name=_('Name'),
order_by=('_name',),
linkify=True
)
manufacturer = tables.Column(
verbose_name=_('Manufacturer'),
linkify=True
)
u_height = tables.TemplateColumn(
template_code="{{ value }}U",
verbose_name=_('Height')
)
outer_width = tables.TemplateColumn(
template_code="{{ record.outer_width }} {{ record.outer_unit }}",
verbose_name=_('Outer Width')
)
outer_depth = tables.TemplateColumn(
template_code="{{ record.outer_depth }} {{ record.outer_unit }}",
verbose_name=_('Outer Depth')
)
weight = columns.TemplateColumn(
verbose_name=_('Weight'),
template_code=WEIGHT,
order_by=('_abs_weight', 'weight_unit')
)
max_weight = columns.TemplateColumn(
verbose_name=_('Max Weight'),
template_code=WEIGHT,
order_by=('_abs_max_weight', 'weight_unit')
)
comments = columns.MarkdownColumn(
verbose_name=_('Comments'),
)
tags = columns.TagColumn(
url_name='dcim:rack_list'
)
class Meta(NetBoxTable.Meta):
model = RackType
fields = (
'pk', 'id', 'name', 'manufacturer', 'form_factor', 'u_height', 'starting_unit', 'width', 'outer_width',
'outer_depth', 'mounting_depth', 'weight', 'max_weight', 'description', 'comments', 'tags', 'created',
'last_updated',
)
default_columns = (
'pk', 'name', 'manufacturer', 'type', 'u_height', 'description',
)
#
# Racks
#
@ -114,9 +170,9 @@ class RackTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
model = Rack
fields = (
'pk', 'id', 'name', 'site', 'location', 'status', 'facility_id', 'tenant', 'tenant_group', 'role', 'serial',
'asset_tag', 'type', 'u_height', 'starting_unit', 'width', 'outer_width', 'outer_depth', 'mounting_depth',
'weight', 'max_weight', 'comments', 'device_count', 'get_utilization', 'get_power_utilization',
'description', 'contacts', 'tags', 'created', 'last_updated',
'asset_tag', 'form_factor', 'u_height', 'starting_unit', 'width', 'outer_width', 'outer_depth',
'mounting_depth', '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',

View File

@ -274,6 +274,47 @@ class RackRoleTest(APIViewTestCases.APIViewTestCase):
RackRole.objects.bulk_create(rack_roles)
class RackTypeTest(APIViewTestCases.APIViewTestCase):
model = RackType
brief_fields = ['description', 'display', 'id', 'manufacturer', 'name', 'slug', 'url']
bulk_update_data = {
'description': 'new description',
}
@classmethod
def setUpTestData(cls):
manufacturers = (
Manufacturer(name='Manufacturer 1', slug='manufacturer-1'),
Manufacturer(name='Manufacturer 2', slug='manufacturer-2'),
)
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.objects.bulk_create(rack_types)
cls.create_data = [
{
'manufacturer': manufacturers[1].pk,
'name': 'Rack Type 4',
'slug': 'rack-type-4',
},
{
'manufacturer': manufacturers[1].pk,
'name': 'Rack Type 5',
'slug': 'rack-type-5',
},
{
'manufacturer': manufacturers[1].pk,
'name': 'Rack Type 6',
'slug': 'rack-type-6',
},
]
class RackTest(APIViewTestCases.APIViewTestCase):
model = Rack
brief_fields = ['description', 'device_count', 'display', 'id', 'name', 'url']

View File

@ -9,7 +9,7 @@ from ipam.models import ASN, IPAddress, RIR, VRF
from netbox.choices import ColorChoices
from tenancy.models import Tenant, TenantGroup
from utilities.testing import ChangeLoggedFilterSetTests, create_test_device
from virtualization.models import Cluster, ClusterType
from virtualization.models import Cluster, ClusterType, ClusterGroup
from wireless.choices import WirelessChannelChoices, WirelessRoleChoices
User = get_user_model()
@ -468,6 +468,152 @@ class RackRoleTestCase(TestCase, ChangeLoggedFilterSetTests):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class RackTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = RackType.objects.all()
filterset = RackTypeFilterSet
@classmethod
def setUpTestData(cls):
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)
racks = (
RackType(
manufacturer=manufacturers[0],
name='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],
name='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(
manufacturer=manufacturers[2],
name='RackType 3',
slug='rack-type-3',
form_factor=RackFormFactorChoices.TYPE_CABINET,
width=RackWidthChoices.WIDTH_23IN,
u_height=44,
starting_unit=3,
desc_units=True,
outer_width=300,
outer_depth=300,
outer_unit=RackDimensionUnitChoices.UNIT_INCH,
mounting_depth=300,
weight=30,
max_weight=3000,
weight_unit=WeightUnitChoices.UNIT_KILOGRAM,
description='foobar3'
),
)
RackType.objects.bulk_create(racks)
def test_q(self):
params = {'q': 'foobar1'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
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_name(self):
params = {'name': ['RackType 1', 'RackType 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_slug(self):
params = {'slug': ['rack-type-1', 'rack-type-2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_description(self):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_form_factor(self):
params = {'form_factor': [RackFormFactorChoices.TYPE_2POST, RackFormFactorChoices.TYPE_4POST]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_width(self):
params = {'width': [RackWidthChoices.WIDTH_19IN, RackWidthChoices.WIDTH_21IN]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_u_height(self):
params = {'u_height': [42, 43]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_starting_unit(self):
params = {'starting_unit': [1, 2]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_desc_units(self):
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)
def test_outer_width(self):
params = {'outer_width': [100, 200]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_outer_depth(self):
params = {'outer_depth': [100, 200]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_outer_unit(self):
self.assertEqual(RackType.objects.filter(outer_unit__isnull=False).count(), 3)
params = {'outer_unit': RackDimensionUnitChoices.UNIT_MILLIMETER}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_mounting_depth(self):
params = {'mounting_depth': [100, 200]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_weight(self):
params = {'weight': [10, 20]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_max_weight(self):
params = {'max_weight': [1000, 2000]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_weight_unit(self):
params = {'weight_unit': WeightUnitChoices.UNIT_POUND}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = Rack.objects.all()
filterset = RackFilterSet
@ -540,7 +686,7 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
role=rack_roles[0],
serial='ABC',
asset_tag='1001',
type=RackTypeChoices.TYPE_2POST,
form_factor=RackFormFactorChoices.TYPE_2POST,
width=RackWidthChoices.WIDTH_19IN,
u_height=42,
desc_units=False,
@ -562,7 +708,7 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
role=rack_roles[1],
serial='DEF',
asset_tag='1002',
type=RackTypeChoices.TYPE_4POST,
form_factor=RackFormFactorChoices.TYPE_4POST,
width=RackWidthChoices.WIDTH_21IN,
u_height=43,
desc_units=False,
@ -584,7 +730,7 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
role=rack_roles[2],
serial='GHI',
asset_tag='1003',
type=RackTypeChoices.TYPE_CABINET,
form_factor=RackFormFactorChoices.TYPE_CABINET,
width=RackWidthChoices.WIDTH_23IN,
u_height=44,
desc_units=True,
@ -619,8 +765,8 @@ class RackTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_type(self):
params = {'type': [RackTypeChoices.TYPE_2POST, RackTypeChoices.TYPE_4POST]}
def test_form_factor(self):
params = {'form_factor': [RackFormFactorChoices.TYPE_2POST, RackFormFactorChoices.TYPE_4POST]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_width(self):
@ -1959,10 +2105,16 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
Rack.objects.bulk_create(racks)
cluster_type = ClusterType.objects.create(name='Cluster Type 1', slug='cluster-type-1')
cluster_groups = (
ClusterGroup(name='Cluster Group 1', slug='cluster-group-1'),
ClusterGroup(name='Cluster Group 2', slug='cluster-group-2'),
ClusterGroup(name='Cluster Group 3', slug='cluster-group-3'),
)
ClusterGroup.objects.bulk_create(cluster_groups)
clusters = (
Cluster(name='Cluster 1', type=cluster_type),
Cluster(name='Cluster 2', type=cluster_type),
Cluster(name='Cluster 3', type=cluster_type),
Cluster(name='Cluster 1', type=cluster_type, group=cluster_groups[0]),
Cluster(name='Cluster 2', type=cluster_type, group=cluster_groups[1]),
Cluster(name='Cluster 3', type=cluster_type, group=cluster_groups[2]),
)
Cluster.objects.bulk_create(clusters)
@ -2213,6 +2365,13 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'cluster_id': [clusters[0].pk, clusters[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_cluster_group(self):
cluster_groups = ClusterGroup.objects.all()[:2]
params = {'cluster_group_id': [cluster_groups[0].pk, cluster_groups[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'cluster_group': [cluster_groups[0].slug, cluster_groups[1].slug]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_model(self):
params = {'model': ['model-1', 'model-2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

View File

@ -74,6 +74,61 @@ class LocationTestCase(TestCase):
self.assertEqual(PowerPanel.objects.get(pk=powerpanel1.pk).site, site_b)
class RackTypeTestCase(TestCase):
@classmethod
def setUpTestData(cls):
manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
RackType.objects.create(
manufacturer=manufacturer,
name='RackType 1',
slug='rack-type-1',
width=11,
u_height=22,
starting_unit=3,
desc_units=True,
outer_width=444,
outer_depth=5,
outer_unit=RackDimensionUnitChoices.UNIT_MILLIMETER,
weight=66,
weight_unit=WeightUnitChoices.UNIT_POUND,
max_weight=7777,
mounting_depth=8,
)
def test_rack_creation(self):
rack_type = RackType.objects.first()
sites = (
Site(name='Site 1', slug='site-1'),
)
Site.objects.bulk_create(sites)
locations = (
Location(name='Location 1', slug='location-1', site=sites[0]),
)
for location in locations:
location.save()
rack = Rack.objects.create(
name='Rack 1',
facility_id='A101',
site=sites[0],
location=locations[0],
rack_type=rack_type
)
self.assertEqual(rack.width, rack_type.width)
self.assertEqual(rack.u_height, rack_type.u_height)
self.assertEqual(rack.starting_unit, rack_type.starting_unit)
self.assertEqual(rack.desc_units, rack_type.desc_units)
self.assertEqual(rack.outer_width, rack_type.outer_width)
self.assertEqual(rack.outer_depth, rack_type.outer_depth)
self.assertEqual(rack.outer_unit, rack_type.outer_unit)
self.assertEqual(rack.weight, rack_type.weight)
self.assertEqual(rack.weight_unit, rack_type.weight_unit)
self.assertEqual(rack.max_weight, rack_type.max_weight)
self.assertEqual(rack.mounting_depth, rack_type.mounting_depth)
class RackTestCase(TestCase):
@classmethod

View File

@ -336,6 +336,75 @@ class RackReservationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
}
class RackTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = RackType
@classmethod
def setUpTestData(cls):
manufacturers = (
Manufacturer(name='Manufacturer 1', slug='manufacturer-1'),
Manufacturer(name='Manufacturer 2', slug='manufacturer-2'),
)
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.objects.bulk_create(rack_types)
tags = create_tags('Alpha', 'Bravo', 'Charlie')
cls.form_data = {
'manufacturer': manufacturers[1].pk,
'name': 'RackType X',
'slug': 'rack-type-x',
'type': RackFormFactorChoices.TYPE_CABINET,
'width': RackWidthChoices.WIDTH_19IN,
'u_height': 48,
'desc_units': False,
'outer_width': 500,
'outer_depth': 500,
'outer_unit': RackDimensionUnitChoices.UNIT_MILLIMETER,
'starting_unit': 1,
'weight': 100,
'max_weight': 2000,
'weight_unit': WeightUnitChoices.UNIT_POUND,
'comments': 'Some comments',
'tags': [t.pk for t in tags],
}
cls.csv_data = (
"manufacturer,name,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",
f"{rack_types[0].pk},RackType 7",
f"{rack_types[1].pk},RackType 8",
f"{rack_types[2].pk},RackType 9",
)
cls.bulk_edit_data = {
'manufacturer': manufacturers[1].pk,
'type': RackFormFactorChoices.TYPE_4POST,
'width': RackWidthChoices.WIDTH_23IN,
'u_height': 49,
'desc_units': True,
'outer_width': 30,
'outer_depth': 30,
'outer_unit': RackDimensionUnitChoices.UNIT_INCH,
'weight': 200,
'max_weight': 4000,
'weight_unit': WeightUnitChoices.UNIT_POUND,
'comments': 'New comments',
}
class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = Rack
@ -380,7 +449,7 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'role': rackroles[1].pk,
'serial': '123456',
'asset_tag': 'ABCDEF',
'type': RackTypeChoices.TYPE_CABINET,
'form_factor': RackFormFactorChoices.TYPE_CABINET,
'width': RackWidthChoices.WIDTH_19IN,
'u_height': 48,
'desc_units': False,
@ -416,7 +485,7 @@ class RackTestCase(ViewTestCases.PrimaryObjectViewTestCase):
'status': RackStatusChoices.STATUS_DEPRECATED,
'role': rackroles[1].pk,
'serial': '654321',
'type': RackTypeChoices.TYPE_4POST,
'form_factor': RackFormFactorChoices.TYPE_4POST,
'width': RackWidthChoices.WIDTH_23IN,
'u_height': 49,
'desc_units': True,

View File

@ -63,6 +63,14 @@ urlpatterns = [
path('racks/delete/', views.RackBulkDeleteView.as_view(), name='rack_bulk_delete'),
path('racks/<int:pk>/', include(get_model_urls('dcim', 'rack'))),
# Rack Types
path('rack-types/', views.RackTypeListView.as_view(), name='racktype_list'),
path('rack-types/add/', views.RackTypeEditView.as_view(), name='racktype_add'),
path('rack-types/import/', views.RackTypeBulkImportView.as_view(), name='racktype_import'),
path('rack-types/edit/', views.RackTypeBulkEditView.as_view(), name='racktype_bulk_edit'),
path('rack-types/delete/', views.RackTypeBulkDeleteView.as_view(), name='racktype_bulk_delete'),
path('rack-types/<int:pk>/', include(get_model_urls('dcim', 'racktype'))),
# Manufacturers
path('manufacturers/', views.ManufacturerListView.as_view(), name='manufacturer_list'),
path('manufacturers/add/', views.ManufacturerEditView.as_view(), name='manufacturer_add'),

View File

@ -31,6 +31,7 @@ from utilities.views import (
GetRelatedModelsMixin, GetReturnURLMixin, ObjectPermissionRequiredMixin, ViewTab, register_model_view
)
from virtualization.filtersets import VirtualMachineFilterSet
from virtualization.forms import VirtualMachineFilterForm
from virtualization.models import VirtualMachine
from virtualization.tables import VirtualMachineTable
from . import filtersets, forms, tables
@ -578,6 +579,56 @@ class RackRoleBulkDeleteView(generic.BulkDeleteView):
table = tables.RackRoleTable
#
# RackTypes
#
class RackTypeListView(generic.ObjectListView):
queryset = RackType.objects.all()
filterset = filtersets.RackTypeFilterSet
filterset_form = forms.RackTypeFilterForm
table = tables.RackTypeTable
@register_model_view(RackType)
class RackTypeView(GetRelatedModelsMixin, generic.ObjectView):
queryset = RackType.objects.all()
def get_extra_context(self, request, instance):
return {
'related_models': self.get_related_models(request, instance),
}
@register_model_view(RackType, 'edit')
class RackTypeEditView(generic.ObjectEditView):
queryset = RackType.objects.all()
form = forms.RackTypeForm
@register_model_view(RackType, 'delete')
class RackTypeDeleteView(generic.ObjectDeleteView):
queryset = RackType.objects.all()
class RackTypeBulkImportView(generic.BulkImportView):
queryset = RackType.objects.all()
model_form = forms.RackTypeImportForm
class RackTypeBulkEditView(generic.BulkEditView):
queryset = RackType.objects.all()
filterset = filtersets.RackTypeFilterSet
table = tables.RackTypeTable
form = forms.RackTypeBulkEditForm
class RackTypeBulkDeleteView(generic.BulkDeleteView):
queryset = RackType.objects.all()
filterset = filtersets.RackTypeFilterSet
table = tables.RackTypeTable
#
# Racks
#
@ -679,6 +730,7 @@ class RackRackReservationsView(generic.ObjectChildrenView):
child_model = RackReservation
table = tables.RackReservationTable
filterset = filtersets.RackReservationFilterSet
filterset_form = forms.RackReservationFilterForm
template_name = 'dcim/rack/reservations.html'
tab = ViewTab(
label=_('Reservations'),
@ -697,6 +749,7 @@ class RackNonRackedView(generic.ObjectChildrenView):
child_model = Device
table = tables.DeviceTable
filterset = filtersets.DeviceFilterSet
filterset_form = forms.DeviceFilterForm
template_name = 'dcim/rack/non_racked_devices.html'
tab = ViewTab(
label=_('Non-Racked Devices'),
@ -1835,6 +1888,7 @@ class DeviceConsolePortsView(DeviceComponentsView):
child_model = ConsolePort
table = tables.DeviceConsolePortTable
filterset = filtersets.ConsolePortFilterSet
filterset_form = forms.ConsolePortFilterForm
template_name = 'dcim/device/consoleports.html',
tab = ViewTab(
label=_('Console Ports'),
@ -1850,6 +1904,7 @@ class DeviceConsoleServerPortsView(DeviceComponentsView):
child_model = ConsoleServerPort
table = tables.DeviceConsoleServerPortTable
filterset = filtersets.ConsoleServerPortFilterSet
filterset_form = forms.ConsoleServerPortFilterForm
template_name = 'dcim/device/consoleserverports.html'
tab = ViewTab(
label=_('Console Server Ports'),
@ -1865,6 +1920,7 @@ class DevicePowerPortsView(DeviceComponentsView):
child_model = PowerPort
table = tables.DevicePowerPortTable
filterset = filtersets.PowerPortFilterSet
filterset_form = forms.PowerPortFilterForm
template_name = 'dcim/device/powerports.html'
tab = ViewTab(
label=_('Power Ports'),
@ -1880,6 +1936,7 @@ class DevicePowerOutletsView(DeviceComponentsView):
child_model = PowerOutlet
table = tables.DevicePowerOutletTable
filterset = filtersets.PowerOutletFilterSet
filterset_form = forms.PowerOutletFilterForm
template_name = 'dcim/device/poweroutlets.html'
tab = ViewTab(
label=_('Power Outlets'),
@ -1895,6 +1952,7 @@ class DeviceInterfacesView(DeviceComponentsView):
child_model = Interface
table = tables.DeviceInterfaceTable
filterset = filtersets.InterfaceFilterSet
filterset_form = forms.InterfaceFilterForm
template_name = 'dcim/device/interfaces.html'
tab = ViewTab(
label=_('Interfaces'),
@ -1916,6 +1974,7 @@ class DeviceFrontPortsView(DeviceComponentsView):
child_model = FrontPort
table = tables.DeviceFrontPortTable
filterset = filtersets.FrontPortFilterSet
filterset_form = forms.FrontPortFilterForm
template_name = 'dcim/device/frontports.html'
tab = ViewTab(
label=_('Front Ports'),
@ -1931,6 +1990,7 @@ class DeviceRearPortsView(DeviceComponentsView):
child_model = RearPort
table = tables.DeviceRearPortTable
filterset = filtersets.RearPortFilterSet
filterset_form = forms.RearPortFilterForm
template_name = 'dcim/device/rearports.html'
tab = ViewTab(
label=_('Rear Ports'),
@ -1946,6 +2006,7 @@ class DeviceModuleBaysView(DeviceComponentsView):
child_model = ModuleBay
table = tables.DeviceModuleBayTable
filterset = filtersets.ModuleBayFilterSet
filterset_form = forms.ModuleBayFilterForm
template_name = 'dcim/device/modulebays.html'
actions = {
**DEFAULT_ACTION_PERMISSIONS,
@ -1965,6 +2026,7 @@ class DeviceDeviceBaysView(DeviceComponentsView):
child_model = DeviceBay
table = tables.DeviceDeviceBayTable
filterset = filtersets.DeviceBayFilterSet
filterset_form = forms.DeviceBayFilterForm
template_name = 'dcim/device/devicebays.html'
actions = {
**DEFAULT_ACTION_PERMISSIONS,
@ -1984,6 +2046,7 @@ class DeviceInventoryView(DeviceComponentsView):
child_model = InventoryItem
table = tables.DeviceInventoryItemTable
filterset = filtersets.InventoryItemFilterSet
filterset_form = forms.InventoryItemFilterForm
template_name = 'dcim/device/inventory.html'
actions = {
**DEFAULT_ACTION_PERMISSIONS,
@ -2062,6 +2125,7 @@ class DeviceVirtualMachinesView(generic.ObjectChildrenView):
child_model = VirtualMachine
table = VirtualMachineTable
filterset = VirtualMachineFilterSet
filterset_form = VirtualMachineFilterForm
tab = ViewTab(
label=_('Virtual Machines'),
badge=lambda obj: VirtualMachine.objects.filter(cluster=obj.cluster, device=obj).count(),
@ -2944,6 +3008,7 @@ class InventoryItemChildrenView(generic.ObjectChildrenView):
child_model = InventoryItem
table = tables.InventoryItemTable
filterset = filtersets.InventoryItemFilterSet
filterset_form = forms.InventoryItemFilterForm
tab = ViewTab(
label=_('Children'),
badge=lambda obj: obj.child_items.count(),

View File

@ -7,6 +7,7 @@ from .serializers_.dashboard import *
from .serializers_.events import *
from .serializers_.exporttemplates import *
from .serializers_.journaling import *
from .serializers_.notifications import *
from .serializers_.configcontexts import *
from .serializers_.configtemplates import *
from .serializers_.savedfilters import *

View File

@ -0,0 +1,82 @@
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from core.models import ObjectType
from extras.models import Notification, NotificationGroup, Subscription
from netbox.api.fields import ContentTypeField, SerializedPKRelatedField
from netbox.api.serializers import ValidatedModelSerializer
from users.api.serializers_.users import GroupSerializer, UserSerializer
from users.models import Group, User
from utilities.api import get_serializer_for_model
__all__ = (
'NotificationSerializer',
'NotificationGroupSerializer',
'SubscriptionSerializer',
)
class NotificationSerializer(ValidatedModelSerializer):
object_type = ContentTypeField(
queryset=ObjectType.objects.with_feature('notifications'),
)
object = serializers.SerializerMethodField(read_only=True)
user = UserSerializer(nested=True)
class Meta:
model = Notification
fields = [
'id', 'url', 'display', 'object_type', 'object_id', 'object', 'user', 'created', 'read', 'event_type',
]
brief_fields = ('id', 'url', 'display', 'object_type', 'object_id', 'user', 'read', 'event_type')
@extend_schema_field(serializers.JSONField(allow_null=True))
def get_object(self, instance):
serializer = get_serializer_for_model(instance.object)
context = {'request': self.context['request']}
return serializer(instance.object, nested=True, context=context).data
class NotificationGroupSerializer(ValidatedModelSerializer):
groups = SerializedPKRelatedField(
queryset=Group.objects.all(),
serializer=GroupSerializer,
nested=True,
required=False,
many=True
)
users = SerializedPKRelatedField(
queryset=User.objects.all(),
serializer=UserSerializer,
nested=True,
required=False,
many=True
)
class Meta:
model = NotificationGroup
fields = [
'id', 'url', 'display', 'display_url', 'name', 'description', 'groups', 'users',
]
brief_fields = ('id', 'url', 'display', 'name', 'description')
class SubscriptionSerializer(ValidatedModelSerializer):
object_type = ContentTypeField(
queryset=ObjectType.objects.with_feature('notifications'),
)
object = serializers.SerializerMethodField(read_only=True)
user = UserSerializer(nested=True)
class Meta:
model = Subscription
fields = [
'id', 'url', 'display', 'object_type', 'object_id', 'object', 'user', 'created',
]
brief_fields = ('id', 'url', 'display', 'object_type', 'object_id', 'user')
@extend_schema_field(serializers.JSONField(allow_null=True))
def get_object(self, instance):
serializer = get_serializer_for_model(instance.object)
context = {'request': self.context['request']}
return serializer(instance.object, nested=True, context=context).data

View File

@ -15,6 +15,9 @@ router.register('custom-links', views.CustomLinkViewSet)
router.register('export-templates', views.ExportTemplateViewSet)
router.register('saved-filters', views.SavedFilterViewSet)
router.register('bookmarks', views.BookmarkViewSet)
router.register('notifications', views.NotificationViewSet)
router.register('notification-groups', views.NotificationGroupViewSet)
router.register('subscriptions', views.SubscriptionViewSet)
router.register('tags', views.TagViewSet)
router.register('image-attachments', views.ImageAttachmentViewSet)
router.register('journal-entries', views.JournalEntryViewSet)

View File

@ -140,6 +140,27 @@ class BookmarkViewSet(NetBoxModelViewSet):
filterset_class = filtersets.BookmarkFilterSet
#
# Notifications & subscriptions
#
class NotificationViewSet(NetBoxModelViewSet):
metadata_class = ContentTypeMetadata
queryset = Notification.objects.all()
serializer_class = serializers.NotificationSerializer
class NotificationGroupViewSet(NetBoxModelViewSet):
queryset = NotificationGroup.objects.all()
serializer_class = serializers.NotificationGroupSerializer
class SubscriptionViewSet(NetBoxModelViewSet):
metadata_class = ContentTypeMetadata
queryset = Subscription.objects.all()
serializer_class = serializers.SubscriptionSerializer
#
# Tags
#

View File

@ -302,8 +302,10 @@ class EventRuleActionChoices(ChoiceSet):
WEBHOOK = 'webhook'
SCRIPT = 'script'
NOTIFICATION = 'notification'
CHOICES = (
(WEBHOOK, _('Webhook')),
(SCRIPT, _('Script')),
(NOTIFICATION, _('Notification')),
)

View File

@ -1,9 +1,5 @@
# Events
EVENT_CREATE = 'create'
EVENT_UPDATE = 'update'
EVENT_DELETE = 'delete'
EVENT_JOB_START = 'job_start'
EVENT_JOB_END = 'job_end'
from core.events import *
from extras.choices import LogLevelChoices
# Custom fields
CUSTOMFIELD_EMPTY_VALUES = (None, '', [])
@ -12,11 +8,14 @@ CUSTOMFIELD_EMPTY_VALUES = (None, '', [])
HTTP_CONTENT_TYPE_JSON = 'application/json'
WEBHOOK_EVENT_TYPES = {
EVENT_CREATE: 'created',
EVENT_UPDATE: 'updated',
EVENT_DELETE: 'deleted',
EVENT_JOB_START: 'job_started',
EVENT_JOB_END: 'job_ended',
# Map registered event types to public webhook "event" equivalents
OBJECT_CREATED: 'created',
OBJECT_UPDATED: 'updated',
OBJECT_DELETED: 'deleted',
JOB_STARTED: 'job_started',
JOB_COMPLETED: 'job_ended',
JOB_FAILED: 'job_ended',
JOB_ERRORED: 'job_ended',
}
# Dashboard
@ -135,3 +134,12 @@ DEFAULT_DASHBOARD = [
}
},
]
LOG_LEVEL_RANK = {
LogLevelChoices.LOG_DEFAULT: 0,
LogLevelChoices.LOG_DEBUG: 1,
LogLevelChoices.LOG_SUCCESS: 2,
LogLevelChoices.LOG_INFO: 3,
LogLevelChoices.LOG_WARNING: 4,
LogLevelChoices.LOG_FAILURE: 5,
}

View File

@ -381,17 +381,17 @@ class BookmarksWidget(DashboardWidget):
if request.user.is_anonymous:
bookmarks = list()
else:
user_bookmarks = Bookmark.objects.filter(user=request.user)
if self.config['order_by'] == BookmarkOrderingChoices.ORDERING_ALPHABETICAL_AZ:
bookmarks = sorted(user_bookmarks, key=lambda bookmark: bookmark.__str__().lower())
elif self.config['order_by'] == BookmarkOrderingChoices.ORDERING_ALPHABETICAL_ZA:
bookmarks = sorted(user_bookmarks, key=lambda bookmark: bookmark.__str__().lower(), reverse=True)
else:
bookmarks = user_bookmarks.order_by(self.config['order_by'])
bookmarks = Bookmark.objects.filter(user=request.user)
if object_types := self.config.get('object_types'):
models = get_models_from_content_types(object_types)
content_types = ObjectType.objects.get_for_models(*models).values()
bookmarks = bookmarks.filter(object_type__in=content_types)
if self.config['order_by'] == BookmarkOrderingChoices.ORDERING_ALPHABETICAL_AZ:
bookmarks = sorted(bookmarks, key=lambda bookmark: bookmark.__str__().lower())
elif self.config['order_by'] == BookmarkOrderingChoices.ORDERING_ALPHABETICAL_ZA:
bookmarks = sorted(bookmarks, key=lambda bookmark: bookmark.__str__().lower(), reverse=True)
else:
bookmarks = bookmarks.order_by(self.config['order_by'])
if max_items := self.config.get('max_items'):
bookmarks = bookmarks[:max_items]

View File

@ -8,7 +8,7 @@ from django.utils.module_loading import import_string
from django.utils.translation import gettext as _
from django_rq import get_queue
from core.choices import ObjectChangeActionChoices
from core.events import *
from core.models import Job
from netbox.config import get_config
from netbox.constants import RQ_QUEUE_DEFAULT
@ -35,12 +35,12 @@ def serialize_for_event(instance):
return serializer.data
def get_snapshots(instance, action):
def get_snapshots(instance, event_type):
snapshots = {
'prechange': getattr(instance, '_prechange_snapshot', None),
'postchange': None,
}
if action != ObjectChangeActionChoices.ACTION_DELETE:
if event_type != OBJECT_DELETED:
# Use model's serialize_object() method if defined; fall back to serialize_object() utility function
if hasattr(instance, 'serialize_object'):
snapshots['postchange'] = instance.serialize_object()
@ -50,7 +50,7 @@ def get_snapshots(instance, action):
return snapshots
def enqueue_object(queue, instance, user, request_id, action):
def enqueue_event(queue, instance, user, request_id, event_type):
"""
Enqueue a serialized representation of a created/updated/deleted object for the processing of
events once the request has completed.
@ -65,24 +65,24 @@ def enqueue_object(queue, instance, user, request_id, action):
key = f'{app_label}.{model_name}:{instance.pk}'
if key in queue:
queue[key]['data'] = serialize_for_event(instance)
queue[key]['snapshots']['postchange'] = get_snapshots(instance, action)['postchange']
queue[key]['snapshots']['postchange'] = get_snapshots(instance, event_type)['postchange']
# If the object is being deleted, update any prior "update" event to "delete"
if event_type == OBJECT_DELETED:
queue[key]['event_type'] = event_type
else:
queue[key] = {
'content_type': ContentType.objects.get_for_model(instance),
'object_type': ContentType.objects.get_for_model(instance),
'object_id': instance.pk,
'event': action,
'event_type': event_type,
'data': serialize_for_event(instance),
'snapshots': get_snapshots(instance, action),
'snapshots': get_snapshots(instance, event_type),
'username': user.username,
'request_id': request_id
}
def process_event_rules(event_rules, model_name, event, data, username=None, snapshots=None, request_id=None):
if username:
user = get_user_model().objects.get(username=username)
else:
user = None
def process_event_rules(event_rules, object_type, event_type, data, username=None, snapshots=None, request_id=None):
user = get_user_model().objects.get(username=username) if username else None
for event_rule in event_rules:
@ -100,8 +100,8 @@ def process_event_rules(event_rules, model_name, event, data, username=None, sna
# Compile the task parameters
params = {
"event_rule": event_rule,
"model_name": model_name,
"event": event,
"model_name": object_type.model,
"event_type": event_type,
"data": data,
"snapshots": snapshots,
"timestamp": timezone.now().isoformat(),
@ -133,6 +133,15 @@ def process_event_rules(event_rules, model_name, event, data, username=None, sna
data=data
)
# Notification groups
elif event_rule.action_type == EventRuleActionChoices.NOTIFICATION:
# Bulk-create notifications for all members of the notification group
event_rule.action_object.notify(
object_type=object_type,
object_id=data['id'],
event_type=event_type
)
else:
raise ValueError(_("Unknown action type for an event rule: {action_type}").format(
action_type=event_rule.action_type
@ -148,27 +157,39 @@ def process_event_queue(events):
'type_update': {},
'type_delete': {},
}
event_actions = {
# TODO: Add EventRule support for dynamically registered event types
OBJECT_CREATED: 'type_create',
OBJECT_UPDATED: 'type_update',
OBJECT_DELETED: 'type_delete',
JOB_STARTED: 'type_job_start',
JOB_COMPLETED: 'type_job_end',
# Map failed & errored jobs to type_job_end
JOB_FAILED: 'type_job_end',
JOB_ERRORED: 'type_job_end',
}
for data in events:
action_flag = {
ObjectChangeActionChoices.ACTION_CREATE: 'type_create',
ObjectChangeActionChoices.ACTION_UPDATE: 'type_update',
ObjectChangeActionChoices.ACTION_DELETE: 'type_delete',
}[data['event']]
content_type = data['content_type']
for event in events:
action_flag = event_actions[event['event_type']]
object_type = event['object_type']
# Cache applicable Event Rules
if content_type not in events_cache[action_flag]:
events_cache[action_flag][content_type] = EventRule.objects.filter(
if object_type not in events_cache[action_flag]:
events_cache[action_flag][object_type] = EventRule.objects.filter(
**{action_flag: True},
object_types=content_type,
object_types=object_type,
enabled=True
)
event_rules = events_cache[action_flag][content_type]
event_rules = events_cache[action_flag][object_type]
process_event_rules(
event_rules, content_type.model, data['event'], data['data'], data['username'],
snapshots=data['snapshots'], request_id=data['request_id']
event_rules=event_rules,
object_type=object_type,
event_type=event['event_type'],
data=event['data'],
username=event['username'],
snapshots=event['snapshots'],
request_id=event['request_id']
)

View File

@ -8,6 +8,7 @@ from core.models import DataSource, ObjectType
from dcim.models import DeviceRole, DeviceType, Location, Platform, Region, Site, SiteGroup
from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet
from tenancy.models import Tenant, TenantGroup
from users.models import Group, User
from utilities.filters import ContentTypeFilter, MultiValueCharFilter, MultiValueNumberFilter
from virtualization.models import Cluster, ClusterGroup, ClusterType
from .choices import *
@ -26,6 +27,7 @@ __all__ = (
'ImageAttachmentFilterSet',
'JournalEntryFilterSet',
'LocalConfigContextFilterSet',
'NotificationGroupFilterSet',
'ObjectTypeFilterSet',
'SavedFilterFilterSet',
'ScriptFilterSet',
@ -336,6 +338,49 @@ class BookmarkFilterSet(BaseFilterSet):
fields = ('id', 'object_id')
class NotificationGroupFilterSet(ChangeLoggedModelFilterSet):
q = django_filters.CharFilter(
method='search',
label=_('Search'),
)
user_id = django_filters.ModelMultipleChoiceFilter(
field_name='users',
queryset=User.objects.all(),
label=_('User (ID)'),
)
user = django_filters.ModelMultipleChoiceFilter(
field_name='users__username',
queryset=User.objects.all(),
to_field_name='username',
label=_('User (name)'),
)
group_id = django_filters.ModelMultipleChoiceFilter(
field_name='groups',
queryset=Group.objects.all(),
label=_('Group (ID)'),
)
group = django_filters.ModelMultipleChoiceFilter(
field_name='groups__name',
queryset=Group.objects.all(),
to_field_name='name',
label=_('Group (name)'),
)
class Meta:
model = NotificationGroup
fields = (
'id', 'name', 'description',
)
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(name__icontains=value) |
Q(description__icontains=value)
)
class ImageAttachmentFilterSet(ChangeLoggedModelFilterSet):
q = django_filters.CharFilter(
method='search',

View File

@ -18,6 +18,7 @@ __all__ = (
'EventRuleBulkEditForm',
'ExportTemplateBulkEditForm',
'JournalEntryBulkEditForm',
'NotificationGroupBulkEditForm',
'SavedFilterBulkEditForm',
'TagBulkEditForm',
'WebhookBulkEditForm',
@ -343,3 +344,17 @@ class JournalEntryBulkEditForm(BulkEditForm):
required=False
)
comments = CommentField()
class NotificationGroupBulkEditForm(BulkEditForm):
pk = forms.ModelMultipleChoiceField(
queryset=NotificationGroup.objects.all(),
widget=forms.MultipleHiddenInput
)
description = forms.CharField(
label=_('Description'),
max_length=200,
required=False
)
nullable_fields = ('description',)

View File

@ -3,16 +3,17 @@ import re
from django import forms
from django.contrib.postgres.forms import SimpleArrayField
from django.core.exceptions import ObjectDoesNotExist
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _
from core.models import ObjectType
from extras.choices import *
from extras.models import *
from netbox.forms import NetBoxModelImportForm
from users.models import Group, User
from utilities.forms import CSVModelForm
from utilities.forms.fields import (
CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVMultipleContentTypeField, SlugField,
CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVModelMultipleChoiceField, CSVMultipleContentTypeField,
SlugField,
)
__all__ = (
@ -23,6 +24,7 @@ __all__ = (
'EventRuleImportForm',
'ExportTemplateImportForm',
'JournalEntryImportForm',
'NotificationGroupImportForm',
'SavedFilterImportForm',
'TagImportForm',
'WebhookImportForm',
@ -229,9 +231,6 @@ class TagImportForm(CSVModelForm):
class Meta:
model = Tag
fields = ('name', 'slug', 'color', 'description')
help_texts = {
'color': mark_safe(_('RGB color in hexadecimal. Example:') + ' <code>00ff00</code>'),
}
class JournalEntryImportForm(NetBoxModelImportForm):
@ -250,3 +249,24 @@ class JournalEntryImportForm(NetBoxModelImportForm):
fields = (
'assigned_object_type', 'assigned_object_id', 'created_by', 'kind', 'comments', 'tags'
)
class NotificationGroupImportForm(CSVModelForm):
users = CSVModelMultipleChoiceField(
label=_('Users'),
queryset=User.objects.all(),
required=False,
to_field_name='username',
help_text=_('User names separated by commas, encased with double quotes')
)
groups = CSVModelMultipleChoiceField(
label=_('Groups'),
queryset=Group.objects.all(),
required=False,
to_field_name='name',
help_text=_('Group names separated by commas, encased with double quotes')
)
class Meta:
model = NotificationGroup
fields = ('name', 'description', 'users', 'groups')

View File

@ -9,6 +9,7 @@ from extras.models import *
from netbox.forms.base import NetBoxModelFilterSetForm
from netbox.forms.mixins import SavedFiltersMixin
from tenancy.models import Tenant, TenantGroup
from users.models import Group
from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice
from utilities.forms.fields import (
ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelMultipleChoiceField, TagFilterField,
@ -28,6 +29,7 @@ __all__ = (
'ImageAttachmentFilterForm',
'JournalEntryFilterForm',
'LocalConfigContextFilterForm',
'NotificationGroupFilterForm',
'SavedFilterFilterForm',
'TagFilterForm',
'WebhookFilterForm',
@ -496,3 +498,16 @@ class JournalEntryFilterForm(NetBoxModelFilterSetForm):
required=False
)
tag = TagFilterField(model)
class NotificationGroupFilterForm(SavedFiltersMixin, FilterForm):
user_id = DynamicModelMultipleChoiceField(
queryset=get_user_model().objects.all(),
required=False,
label=_('User')
)
group_id = DynamicModelMultipleChoiceField(
queryset=Group.objects.all(),
required=False,
label=_('Group')
)

View File

@ -12,6 +12,7 @@ from extras.choices import *
from extras.models import *
from netbox.forms import NetBoxModelForm
from tenancy.models import Tenant, TenantGroup
from users.models import Group, User
from utilities.forms import add_blank_choice, get_field_value
from utilities.forms.fields import (
CommentField, ContentTypeChoiceField, ContentTypeMultipleChoiceField, DynamicModelChoiceField,
@ -32,7 +33,9 @@ __all__ = (
'ExportTemplateForm',
'ImageAttachmentForm',
'JournalEntryForm',
'NotificationGroupForm',
'SavedFilterForm',
'SubscriptionForm',
'TagForm',
'WebhookForm',
)
@ -238,6 +241,43 @@ class BookmarkForm(forms.ModelForm):
fields = ('object_type', 'object_id')
class NotificationGroupForm(forms.ModelForm):
groups = DynamicModelMultipleChoiceField(
label=_('Groups'),
required=False,
queryset=Group.objects.all()
)
users = DynamicModelMultipleChoiceField(
label=_('Users'),
required=False,
queryset=User.objects.all()
)
class Meta:
model = NotificationGroup
fields = ('name', 'description', 'groups', 'users')
def clean(self):
super().clean()
# At least one User or Group must be assigned
if not self.cleaned_data['groups'] and not self.cleaned_data['users']:
raise forms.ValidationError(_("A notification group specify at least one user or group."))
return self.cleaned_data
class SubscriptionForm(forms.ModelForm):
object_type = ContentTypeChoiceField(
label=_('Object type'),
queryset=ObjectType.objects.with_feature('notifications')
)
class Meta:
model = Subscription
fields = ('object_type', 'object_id')
class WebhookForm(NetBoxModelForm):
fieldsets = (
@ -329,6 +369,18 @@ class EventRuleForm(NetBoxModelForm):
initial=initial
)
def init_notificationgroup_choice(self):
initial = None
if self.instance.action_type == EventRuleActionChoices.NOTIFICATION:
notificationgroup_id = get_field_value(self, 'action_object_id')
initial = NotificationGroup.objects.get(pk=notificationgroup_id) if notificationgroup_id else None
self.fields['action_choice'] = DynamicModelChoiceField(
label=_('Notification group'),
queryset=NotificationGroup.objects.all(),
required=True,
initial=initial
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['action_object_type'].required = False
@ -341,6 +393,8 @@ class EventRuleForm(NetBoxModelForm):
self.init_webhook_choice()
elif action_type == EventRuleActionChoices.SCRIPT:
self.init_script_choice()
elif action_type == EventRuleActionChoices.NOTIFICATION:
self.init_notificationgroup_choice()
def clean(self):
super().clean()
@ -357,6 +411,10 @@ class EventRuleForm(NetBoxModelForm):
for_concrete_model=False
)
self.cleaned_data['action_object_id'] = action_choice.id
# Notification
elif self.cleaned_data.get('action_type') == EventRuleActionChoices.NOTIFICATION:
self.cleaned_data['action_object_type'] = ObjectType.objects.get_for_model(action_choice)
self.cleaned_data['action_object_id'] = action_choice.id
return self.cleaned_data

View File

@ -13,6 +13,7 @@ __all__ = (
'ExportTemplateFilter',
'ImageAttachmentFilter',
'JournalEntryFilter',
'NotificationGroupFilter',
'SavedFilterFilter',
'TagFilter',
'WebhookFilter',
@ -67,6 +68,12 @@ class JournalEntryFilter(BaseFilterMixin):
pass
@strawberry_django.filter(models.NotificationGroup, lookups=True)
@autotype_decorator(filtersets.NotificationGroupFilterSet)
class NotificationGroupFilter(BaseFilterMixin):
pass
@strawberry_django.filter(models.SavedFilter, lookups=True)
@autotype_decorator(filtersets.SavedFilterFilterSet)
class SavedFilterFilter(BaseFilterMixin):

View File

@ -54,6 +54,21 @@ class ExtrasQuery:
return models.JournalEntry.objects.get(pk=id)
journal_entry_list: List[JournalEntryType] = strawberry_django.field()
@strawberry.field
def notification(self, id: int) -> NotificationType:
return models.Notification.objects.get(pk=id)
notification_list: List[NotificationType] = strawberry_django.field()
@strawberry.field
def notification_group(self, id: int) -> NotificationGroupType:
return models.NotificationGroup.objects.get(pk=id)
notification_group_list: List[NotificationGroupType] = strawberry_django.field()
@strawberry.field
def subscription(self, id: int) -> SubscriptionType:
return models.Subscription.objects.get(pk=id)
subscription_list: List[SubscriptionType] = strawberry_django.field()
@strawberry.field
def tag(self, id: int) -> TagType:
return models.Tag.objects.get(pk=id)

View File

@ -18,7 +18,10 @@ __all__ = (
'ExportTemplateType',
'ImageAttachmentType',
'JournalEntryType',
'NotificationGroupType',
'NotificationType',
'SavedFilterType',
'SubscriptionType',
'TagType',
'WebhookType',
)
@ -122,6 +125,23 @@ class JournalEntryType(CustomFieldsMixin, TagsMixin, ObjectType):
created_by: Annotated["UserType", strawberry.lazy('users.graphql.types')] | None
@strawberry_django.type(
models.Notification,
# filters=NotificationFilter
)
class NotificationType(ObjectType):
user: Annotated["UserType", strawberry.lazy('users.graphql.types')] | None
@strawberry_django.type(
models.NotificationGroup,
filters=NotificationGroupFilter
)
class NotificationGroupType(ObjectType):
users: List[Annotated["UserType", strawberry.lazy('users.graphql.types')]]
groups: List[Annotated["GroupType", strawberry.lazy('users.graphql.types')]]
@strawberry_django.type(
models.SavedFilter,
exclude=['content_types',],
@ -131,6 +151,14 @@ class SavedFilterType(ObjectType):
user: Annotated["UserType", strawberry.lazy('users.graphql.types')] | None
@strawberry_django.type(
models.Subscription,
# filters=NotificationFilter
)
class SubscriptionType(ObjectType):
user: Annotated["UserType", strawberry.lazy('users.graphql.types')] | None
@strawberry_django.type(
models.Tag,
exclude=['extras_taggeditem_items', ],

View File

@ -66,11 +66,16 @@ class Command(BaseCommand):
raise CommandError(_("No indexers found!"))
self.stdout.write(f'Reindexing {len(indexers)} models.')
# Clear all cached values for the specified models (if not being lazy)
# Clear cached values for the specified models (if not being lazy)
if not kwargs['lazy']:
if model_labels:
content_types = [ContentType.objects.get_for_model(model) for model in indexers.keys()]
else:
content_types = None
self.stdout.write('Clearing cached values... ', ending='')
self.stdout.flush()
deleted_count = search_backend.clear()
deleted_count = search_backend.clear(object_types=content_types)
self.stdout.write(f'{deleted_count} entries deleted.')
# Index models

View File

@ -0,0 +1,78 @@
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('contenttypes', '0002_remove_content_type_name'),
('extras', '0117_customfield_uniqueness'),
('users', '0009_update_group_perms'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name='NotificationGroup',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('created', models.DateTimeField(auto_now_add=True, null=True)),
('last_updated', models.DateTimeField(auto_now=True, null=True)),
('name', models.CharField(max_length=100, unique=True)),
('description', models.CharField(blank=True, max_length=200)),
('groups', models.ManyToManyField(blank=True, related_name='notification_groups', to='users.group')),
('users', models.ManyToManyField(blank=True, related_name='notification_groups', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'notification group',
'verbose_name_plural': 'notification groups',
'ordering': ('name',),
},
),
migrations.CreateModel(
name='Subscription',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('created', models.DateTimeField(auto_now_add=True)),
('object_id', models.PositiveBigIntegerField()),
('object_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='contenttypes.contenttype')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='subscriptions', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'subscription',
'verbose_name_plural': 'subscriptions',
'ordering': ('-created', 'user'),
},
),
migrations.CreateModel(
name='Notification',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
('created', models.DateTimeField(auto_now_add=True)),
('read', models.DateTimeField(blank=True, null=True)),
('object_id', models.PositiveBigIntegerField()),
('event_type', models.CharField(max_length=50)),
('object_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to='contenttypes.contenttype')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'notification',
'verbose_name_plural': 'notifications',
'ordering': ('-created', 'pk'),
'indexes': [models.Index(fields=['object_type', 'object_id'], name='extras_noti_object__be74d5_idx')],
},
),
migrations.AddConstraint(
model_name='notification',
constraint=models.UniqueConstraint(fields=('object_type', 'object_id', 'user'), name='extras_notification_unique_per_object_and_user'),
),
migrations.AddIndex(
model_name='subscription',
index=models.Index(fields=['object_type', 'object_id'], name='extras_subs_object__37ef68_idx'),
),
migrations.AddConstraint(
model_name='subscription',
constraint=models.UniqueConstraint(fields=('object_type', 'object_id', 'user'), name='extras_subscription_unique_per_object_and_user'),
),
]

View File

@ -2,6 +2,7 @@ from .configs import *
from .customfields import *
from .dashboard import *
from .models import *
from .notifications import *
from .scripts import *
from .search import *
from .staging import *

View File

@ -21,6 +21,7 @@ from netbox.models import ChangeLoggedModel
from netbox.models.features import CloningMixin, ExportTemplatesMixin
from netbox.search import FieldTypes
from utilities import filters
from utilities.datetime import datetime_from_timestamp
from utilities.forms.fields import (
CSVChoiceField, CSVModelChoiceField, CSVModelMultipleChoiceField, CSVMultipleChoiceField, DynamicChoiceField,
DynamicModelChoiceField, DynamicModelMultipleChoiceField, DynamicMultipleChoiceField, JSONField, LaxURLField,
@ -501,7 +502,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
# JSON
elif self.type == CustomFieldTypeChoices.TYPE_JSON:
field = JSONField(required=required, initial=json.dumps(initial) if initial else '')
field = JSONField(required=required, initial=json.dumps(initial) if initial else None)
# Object
elif self.type == CustomFieldTypeChoices.TYPE_OBJECT:
@ -672,12 +673,8 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
# Validate date & time
elif self.type == CustomFieldTypeChoices.TYPE_DATETIME:
if type(value) is not datetime:
# Work around UTC issue for Python < 3.11; see
# https://docs.python.org/3/library/datetime.html#datetime.datetime.fromisoformat
if type(value) is str and value.endswith('Z'):
value = f'{value[:-1]}+00:00'
try:
datetime.fromisoformat(value)
datetime_from_timestamp(value)
except ValueError:
raise ValidationError(
_("Date and time values must be in ISO 8601 format (YYYY-MM-DD HH:MM:SS).")

View File

@ -0,0 +1,222 @@
from functools import cached_property
from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey
from django.core.exceptions import ValidationError
from django.db import models
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from core.models import ObjectType
from extras.querysets import NotificationQuerySet
from netbox.models import ChangeLoggedModel
from netbox.registry import registry
from users.models import User
from utilities.querysets import RestrictedQuerySet
__all__ = (
'Notification',
'NotificationGroup',
'Subscription',
)
def get_event_type_choices():
"""
Compile a list of choices from all registered event types
"""
return [
(name, event.text)
for name, event in registry['events'].items()
]
class Notification(models.Model):
"""
A notification message for a User relating to a specific object in NetBox.
"""
created = models.DateTimeField(
verbose_name=_('created'),
auto_now_add=True
)
read = models.DateTimeField(
verbose_name=_('read'),
null=True,
blank=True
)
user = models.ForeignKey(
to=settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='notifications'
)
object_type = models.ForeignKey(
to='contenttypes.ContentType',
on_delete=models.PROTECT
)
object_id = models.PositiveBigIntegerField()
object = GenericForeignKey(
ct_field='object_type',
fk_field='object_id'
)
event_type = models.CharField(
verbose_name=_('event'),
max_length=50,
choices=get_event_type_choices
)
objects = NotificationQuerySet.as_manager()
class Meta:
ordering = ('-created', 'pk')
indexes = (
models.Index(fields=('object_type', 'object_id')),
)
constraints = (
models.UniqueConstraint(
fields=('object_type', 'object_id', 'user'),
name='%(app_label)s_%(class)s_unique_per_object_and_user'
),
)
verbose_name = _('notification')
verbose_name_plural = _('notifications')
def __str__(self):
if self.object:
return str(self.object)
return super().__str__()
def get_absolute_url(self):
return reverse('account:notifications')
def clean(self):
super().clean()
# Validate the assigned object type
if self.object_type not in ObjectType.objects.with_feature('notifications'):
raise ValidationError(
_("Objects of this type ({type}) do not support notifications.").format(type=self.object_type)
)
@cached_property
def event(self):
"""
Returns the registered Event which triggered this Notification.
"""
return registry['events'].get(self.event_type)
class NotificationGroup(ChangeLoggedModel):
"""
A collection of users and/or groups to be informed for certain notifications.
"""
name = models.CharField(
verbose_name=_('name'),
max_length=100,
unique=True
)
description = models.CharField(
verbose_name=_('description'),
max_length=200,
blank=True
)
groups = models.ManyToManyField(
to='users.Group',
verbose_name=_('groups'),
blank=True,
related_name='notification_groups'
)
users = models.ManyToManyField(
to='users.User',
verbose_name=_('users'),
blank=True,
related_name='notification_groups'
)
objects = RestrictedQuerySet.as_manager()
class Meta:
ordering = ('name',)
verbose_name = _('notification group')
verbose_name_plural = _('notification groups')
def __str__(self):
return self.name
def get_absolute_url(self):
return reverse('extras:notificationgroup', args=[self.pk])
@cached_property
def members(self):
"""
Return all Users who belong to this notification group.
"""
return self.users.union(
User.objects.filter(groups__in=self.groups.all())
).order_by('username')
def notify(self, **kwargs):
"""
Bulk-create Notifications for all members of this group.
"""
Notification.objects.bulk_create([
Notification(user=member, **kwargs)
for member in self.members
])
notify.alters_data = True
class Subscription(models.Model):
"""
A User's subscription to a particular object, to be notified of changes.
"""
created = models.DateTimeField(
verbose_name=_('created'),
auto_now_add=True
)
user = models.ForeignKey(
to=settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name='subscriptions'
)
object_type = models.ForeignKey(
to='contenttypes.ContentType',
on_delete=models.PROTECT
)
object_id = models.PositiveBigIntegerField()
object = GenericForeignKey(
ct_field='object_type',
fk_field='object_id'
)
objects = RestrictedQuerySet.as_manager()
class Meta:
ordering = ('-created', 'user')
indexes = (
models.Index(fields=('object_type', 'object_id')),
)
constraints = (
models.UniqueConstraint(
fields=('object_type', 'object_id', 'user'),
name='%(app_label)s_%(class)s_unique_per_object_and_user'
),
)
verbose_name = _('subscription')
verbose_name_plural = _('subscriptions')
def __str__(self):
if self.object:
return str(self.object)
return super().__str__()
def get_absolute_url(self):
return reverse('account:subscriptions')
def clean(self):
super().clean()
# Validate the assigned object type
if self.object_type not in ObjectType.objects.with_feature('notifications'):
raise ValidationError(
_("Objects of this type ({type}) do not support notifications.").format(type=self.object_type)
)

View File

@ -5,6 +5,12 @@ from extras.models.tags import TaggedItem
from utilities.query_functions import EmptyGroupByJSONBAgg
from utilities.querysets import RestrictedQuerySet
__all__ = (
'ConfigContextModelQuerySet',
'ConfigContextQuerySet',
'NotificationQuerySet',
)
class ConfigContextQuerySet(RestrictedQuerySet):
@ -145,3 +151,12 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
)
return base_query
class NotificationQuerySet(RestrictedQuerySet):
def unread(self):
"""
Return only unread notifications.
"""
return self.filter(read__isnull=True)

View File

@ -10,17 +10,18 @@ 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, ObjectType
from core.signals import job_end, job_start
from extras.constants import EVENT_JOB_END, EVENT_JOB_START
from extras.events import process_event_rules
from extras.models import EventRule
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_object, get_snapshots, serialize_for_event
from .events import enqueue_event
from .models import CustomField, TaggedItem
from .validators import CustomValidator
@ -72,17 +73,22 @@ def handle_changed_object(sender, instance, **kwargs):
# Determine the type of change being made
if kwargs.get('created'):
action = ObjectChangeActionChoices.ACTION_CREATE
event_type = OBJECT_CREATED
elif 'created' in kwargs:
action = ObjectChangeActionChoices.ACTION_UPDATE
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
action = ObjectChangeActionChoices.ACTION_UPDATE
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
@ -106,13 +112,13 @@ def handle_changed_object(sender, instance, **kwargs):
# Enqueue the object for event processing
queue = events_queue.get()
enqueue_object(queue, instance, request.user, request.id, action)
enqueue_event(queue, instance, request.user, request.id, event_type)
events_queue.set(queue)
# Increment metric counters
if action == ObjectChangeActionChoices.ACTION_CREATE:
if event_type == OBJECT_CREATED:
model_inserts.labels(instance._meta.model_name).inc()
elif action == ObjectChangeActionChoices.ACTION_UPDATE:
elif event_type == OBJECT_UPDATED:
model_updates.labels(instance._meta.model_name).inc()
@ -168,7 +174,7 @@ def handle_deleted_object(sender, instance, **kwargs):
# Enqueue the object for event processing
queue = events_queue.get()
enqueue_object(queue, instance, request.user, request.id, ObjectChangeActionChoices.ACTION_DELETE)
enqueue_event(queue, instance, request.user, request.id, OBJECT_DELETED)
events_queue.set(queue)
# Increment metric counters
@ -270,7 +276,13 @@ def process_job_start_event_rules(sender, **kwargs):
"""
event_rules = EventRule.objects.filter(type_job_start=True, enabled=True, object_types=sender.object_type)
username = sender.user.username if sender.user else None
process_event_rules(event_rules, sender.object_type.model, EVENT_JOB_START, sender.data, username)
process_event_rules(
event_rules=event_rules,
object_type=sender.object_type,
event_type=JOB_STARTED,
data=sender.data,
username=username
)
@receiver(job_end)
@ -280,4 +292,39 @@ def process_job_end_event_rules(sender, **kwargs):
"""
event_rules = EventRule.objects.filter(type_job_end=True, enabled=True, object_types=sender.object_type)
username = sender.user.username if sender.user else None
process_event_rules(event_rules, sender.object_type.model, EVENT_JOB_END, sender.data, username)
process_event_rules(
event_rules=event_rules,
object_type=sender.object_type,
event_type=JOB_COMPLETED,
data=sender.data,
username=username
)
#
# Notifications
#
@receiver(post_save)
def notify_object_changed(sender, instance, created, raw, **kwargs):
if created or raw:
return
# Skip unsupported object types
ct = ContentType.objects.get_for_model(instance)
if ct.model not in registry['model_features']['notifications'].get(ct.app_label, []):
return
# Find all subscribed Users
subscribed_users = Subscription.objects.filter(object_type=ct, object_id=instance.pk).values_list('user', flat=True)
if not subscribed_users:
return
# Delete any existing Notifications for the object
Notification.objects.filter(object_type=ct, object_id=instance.pk, user__in=subscribed_users).delete()
# Create Notifications for Subscribers
Notification.objects.bulk_create([
Notification(user_id=user, object=instance, event_type=OBJECT_UPDATED)
for user in subscribed_users
])

View File

@ -0,0 +1,13 @@
from django.utils.translation import gettext as _
from netbox.tables.columns import ActionsColumn, ActionsItem
__all__ = (
'NotificationActionsColumn',
)
class NotificationActionsColumn(ActionsColumn):
actions = {
'dismiss': ActionsItem(_('Dismiss'), 'trash-can-outline', 'delete', 'danger'),
}

View File

@ -7,6 +7,7 @@ from django.utils.translation import gettext_lazy as _
from extras.models import *
from netbox.constants import EMPTY_TABLE_TEXT
from netbox.tables import BaseTable, NetBoxTable, columns
from .columns import NotificationActionsColumn
__all__ = (
'BookmarkTable',
@ -19,21 +20,28 @@ __all__ = (
'ExportTemplateTable',
'ImageAttachmentTable',
'JournalEntryTable',
'NotificationGroupTable',
'NotificationTable',
'SavedFilterTable',
'ReportResultsTable',
'ScriptResultsTable',
'SubscriptionTable',
'TaggedItemTable',
'TagTable',
'WebhookTable',
)
IMAGEATTACHMENT_IMAGE = '''
IMAGEATTACHMENT_IMAGE = """
{% if record.image %}
<a class="image-preview" href="{{ record.image.url }}" target="_blank">{{ record }}</a>
{% else %}
&mdash;
{% endif %}
'''
"""
NOTIFICATION_ICON = """
<span class="text-{{ value.color }} fs-3"><i class="{{ value.icon }}"></i></span>
"""
class CustomFieldTable(NetBoxTable):
@ -263,6 +271,93 @@ class BookmarkTable(NetBoxTable):
default_columns = ('object', 'object_type', 'created')
class SubscriptionTable(NetBoxTable):
object_type = columns.ContentTypeColumn(
verbose_name=_('Object Type'),
)
object = tables.Column(
verbose_name=_('Object'),
linkify=True,
orderable=False
)
user = tables.Column(
verbose_name=_('User'),
linkify=True
)
actions = columns.ActionsColumn(
actions=('delete',)
)
class Meta(NetBoxTable.Meta):
model = Subscription
fields = ('pk', 'object', 'object_type', 'created', 'user')
default_columns = ('object', 'object_type', 'created')
class NotificationTable(NetBoxTable):
icon = columns.TemplateColumn(
template_code=NOTIFICATION_ICON,
accessor=tables.A('event'),
attrs={
'td': {'class': 'w-1'},
'th': {'class': 'w-1'},
},
verbose_name=''
)
object_type = columns.ContentTypeColumn(
verbose_name=_('Object Type'),
)
object = tables.Column(
verbose_name=_('Object'),
linkify={
'viewname': 'extras:notification_read',
'args': [tables.A('pk')],
},
orderable=False
)
created = columns.DateTimeColumn(
timespec='minutes',
verbose_name=_('Created'),
)
read = columns.DateTimeColumn(
timespec='minutes',
verbose_name=_('Read'),
)
user = tables.Column(
verbose_name=_('User'),
linkify=True
)
actions = NotificationActionsColumn(
actions=('dismiss',)
)
class Meta(NetBoxTable.Meta):
model = Notification
fields = ('pk', 'icon', 'object', 'object_type', 'event_type', 'created', 'read', 'user')
default_columns = ('icon', 'object', 'object_type', 'event_type', 'created')
row_attrs = {
'data-read': lambda record: bool(record.read),
}
class NotificationGroupTable(NetBoxTable):
name = tables.Column(
linkify=True,
verbose_name=_('Name')
)
users = columns.ManyToManyColumn(
linkify_item=True
)
groups = columns.ManyToManyColumn(
linkify_item=True
)
class Meta(NetBoxTable.Meta):
model = NotificationGroup
fields = ('pk', 'name', 'description', 'groups', 'users')
default_columns = ('name', 'description', 'groups', 'users')
class WebhookTable(NetBoxTable):
name = tables.Column(
verbose_name=_('Name'),

View File

@ -7,15 +7,15 @@ from django.utils.timezone import make_aware
from rest_framework import status
from core.choices import ManagedFileRootPathChoices
from core.events import *
from core.models import ObjectType
from dcim.models import Device, DeviceRole, DeviceType, Manufacturer, Rack, Location, RackRole, Site
from extras.choices import *
from extras.models import *
from extras.scripts import BooleanVar, IntegerVar, Script as PythonClass, StringVar
from users.models import Group, User
from utilities.testing import APITestCase, APIViewTestCases
User = get_user_model()
class AppTest(APITestCase):
@ -890,3 +890,196 @@ class ObjectTypeTest(APITestCase):
url = reverse('extras-api:objecttype-detail', kwargs={'pk': object_type.pk})
self.assertHttpStatus(self.client.get(url, **self.header), status.HTTP_200_OK)
class SubscriptionTest(APIViewTestCases.APIViewTestCase):
model = Subscription
brief_fields = ['display', 'id', 'object_id', 'object_type', 'url', 'user']
@classmethod
def setUpTestData(cls):
users = (
User(username='User 1'),
User(username='User 2'),
User(username='User 3'),
User(username='User 4'),
)
User.objects.bulk_create(users)
sites = (
Site(name='Site 1', slug='site-1'),
Site(name='Site 2', slug='site-2'),
Site(name='Site 3', slug='site-3'),
)
Site.objects.bulk_create(sites)
subscriptions = (
Subscription(
object=sites[0],
user=users[0],
),
Subscription(
object=sites[1],
user=users[1],
),
Subscription(
object=sites[2],
user=users[2],
),
)
Subscription.objects.bulk_create(subscriptions)
cls.create_data = [
{
'object_type': 'dcim.site',
'object_id': sites[0].pk,
'user': users[3].pk,
},
{
'object_type': 'dcim.site',
'object_id': sites[1].pk,
'user': users[3].pk,
},
{
'object_type': 'dcim.site',
'object_id': sites[2].pk,
'user': users[3].pk,
},
]
class NotificationGroupTest(APIViewTestCases.APIViewTestCase):
model = NotificationGroup
brief_fields = ['description', 'display', 'id', 'name', 'url']
create_data = [
{
'object_types': ['dcim.site'],
'name': 'Custom Link 4',
'enabled': True,
'link_text': 'Link 4',
'link_url': 'http://example.com/?4',
},
{
'object_types': ['dcim.site'],
'name': 'Custom Link 5',
'enabled': True,
'link_text': 'Link 5',
'link_url': 'http://example.com/?5',
},
{
'object_types': ['dcim.site'],
'name': 'Custom Link 6',
'enabled': False,
'link_text': 'Link 6',
'link_url': 'http://example.com/?6',
},
]
bulk_update_data = {
'description': 'New description',
}
@classmethod
def setUpTestData(cls):
users = (
User(username='User 1'),
User(username='User 2'),
User(username='User 3'),
)
User.objects.bulk_create(users)
groups = (
Group(name='Group 1'),
Group(name='Group 2'),
Group(name='Group 3'),
)
Group.objects.bulk_create(groups)
notification_groups = (
NotificationGroup(name='Notification Group 1'),
NotificationGroup(name='Notification Group 2'),
NotificationGroup(name='Notification Group 3'),
)
NotificationGroup.objects.bulk_create(notification_groups)
for i, notification_group in enumerate(notification_groups):
notification_group.users.add(users[i])
notification_group.groups.add(groups[i])
cls.create_data = [
{
'name': 'Notification Group 4',
'description': 'Foo',
'users': [users[0].pk],
'groups': [groups[0].pk],
},
{
'name': 'Notification Group 5',
'description': 'Bar',
'users': [users[1].pk],
'groups': [groups[1].pk],
},
{
'name': 'Notification Group 6',
'description': 'Baz',
'users': [users[2].pk],
'groups': [groups[2].pk],
},
]
class NotificationTest(APIViewTestCases.APIViewTestCase):
model = Notification
brief_fields = ['display', 'event_type', 'id', 'object_id', 'object_type', 'read', 'url', 'user']
@classmethod
def setUpTestData(cls):
users = (
User(username='User 1'),
User(username='User 2'),
User(username='User 3'),
User(username='User 4'),
)
User.objects.bulk_create(users)
sites = (
Site(name='Site 1', slug='site-1'),
Site(name='Site 2', slug='site-2'),
Site(name='Site 3', slug='site-3'),
)
Site.objects.bulk_create(sites)
notifications = (
Notification(
object=sites[0],
event_type=OBJECT_CREATED,
user=users[0],
),
Notification(
object=sites[1],
event_type=OBJECT_UPDATED,
user=users[1],
),
Notification(
object=sites[2],
event_type=OBJECT_DELETED,
user=users[2],
),
)
Notification.objects.bulk_create(notifications)
cls.create_data = [
{
'object_type': 'dcim.site',
'object_id': sites[0].pk,
'user': users[3].pk,
'event_type': OBJECT_CREATED,
},
{
'object_type': 'dcim.site',
'object_id': sites[1].pk,
'user': users[3].pk,
'event_type': OBJECT_UPDATED,
},
{
'object_type': 'dcim.site',
'object_id': sites[2].pk,
'user': users[3].pk,
'event_type': OBJECT_DELETED,
},
]

View File

@ -9,12 +9,12 @@ from django.urls import reverse
from requests import Session
from rest_framework import status
from core.choices import ObjectChangeActionChoices
from core.events import *
from core.models import ObjectType
from dcim.choices import SiteStatusChoices
from dcim.models import Site
from extras.choices import EventRuleActionChoices
from extras.events import enqueue_object, flush_events, serialize_for_event
from extras.events import enqueue_event, flush_events, serialize_for_event
from extras.models import EventRule, Tag, Webhook
from extras.webhooks import generate_signature, send_webhook
from netbox.context_managers import event_tracking
@ -132,7 +132,7 @@ class EventRuleTest(APITestCase):
self.assertEqual(self.queue.count, 1)
job = self.queue.jobs[0]
self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(type_create=True))
self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_CREATE)
self.assertEqual(job.kwargs['event_type'], OBJECT_CREATED)
self.assertEqual(job.kwargs['model_name'], 'site')
self.assertEqual(job.kwargs['data']['id'], response.data['id'])
self.assertEqual(len(job.kwargs['data']['tags']), len(response.data['tags']))
@ -182,7 +182,7 @@ class EventRuleTest(APITestCase):
self.assertEqual(self.queue.count, 3)
for i, job in enumerate(self.queue.jobs):
self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(type_create=True))
self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_CREATE)
self.assertEqual(job.kwargs['event_type'], OBJECT_CREATED)
self.assertEqual(job.kwargs['model_name'], 'site')
self.assertEqual(job.kwargs['data']['id'], response.data[i]['id'])
self.assertEqual(len(job.kwargs['data']['tags']), len(response.data[i]['tags']))
@ -213,7 +213,7 @@ class EventRuleTest(APITestCase):
self.assertEqual(self.queue.count, 1)
job = self.queue.jobs[0]
self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(type_update=True))
self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_UPDATE)
self.assertEqual(job.kwargs['event_type'], OBJECT_UPDATED)
self.assertEqual(job.kwargs['model_name'], 'site')
self.assertEqual(job.kwargs['data']['id'], site.pk)
self.assertEqual(len(job.kwargs['data']['tags']), len(response.data['tags']))
@ -269,7 +269,7 @@ class EventRuleTest(APITestCase):
self.assertEqual(self.queue.count, 3)
for i, job in enumerate(self.queue.jobs):
self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(type_update=True))
self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_UPDATE)
self.assertEqual(job.kwargs['event_type'], OBJECT_UPDATED)
self.assertEqual(job.kwargs['model_name'], 'site')
self.assertEqual(job.kwargs['data']['id'], data[i]['id'])
self.assertEqual(len(job.kwargs['data']['tags']), len(response.data[i]['tags']))
@ -295,7 +295,7 @@ class EventRuleTest(APITestCase):
self.assertEqual(self.queue.count, 1)
job = self.queue.jobs[0]
self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(type_delete=True))
self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_DELETE)
self.assertEqual(job.kwargs['event_type'], OBJECT_DELETED)
self.assertEqual(job.kwargs['model_name'], 'site')
self.assertEqual(job.kwargs['data']['id'], site.pk)
self.assertEqual(job.kwargs['snapshots']['prechange']['name'], 'Site 1')
@ -328,7 +328,7 @@ class EventRuleTest(APITestCase):
self.assertEqual(self.queue.count, 3)
for i, job in enumerate(self.queue.jobs):
self.assertEqual(job.kwargs['event_rule'], EventRule.objects.get(type_delete=True))
self.assertEqual(job.kwargs['event'], ObjectChangeActionChoices.ACTION_DELETE)
self.assertEqual(job.kwargs['event_type'], OBJECT_DELETED)
self.assertEqual(job.kwargs['model_name'], 'site')
self.assertEqual(job.kwargs['data']['id'], sites[i].pk)
self.assertEqual(job.kwargs['snapshots']['prechange']['name'], sites[i].name)
@ -365,12 +365,12 @@ class EventRuleTest(APITestCase):
# Enqueue a webhook for processing
webhooks_queue = {}
site = Site.objects.create(name='Site 1', slug='site-1')
enqueue_object(
enqueue_event(
webhooks_queue,
instance=site,
user=self.user,
request_id=request_id,
action=ObjectChangeActionChoices.ACTION_CREATE
event_type=OBJECT_CREATED
)
flush_events(list(webhooks_queue.values()))
@ -378,7 +378,7 @@ class EventRuleTest(APITestCase):
job = self.queue.jobs[0]
# Patch the Session object with our dummy_send() method, then process the webhook for sending
with patch.object(Session, 'send', dummy_send) as mock_send:
with patch.object(Session, 'send', dummy_send):
send_webhook(**job.kwargs)
def test_duplicate_triggers(self):
@ -391,13 +391,36 @@ class EventRuleTest(APITestCase):
request.id = uuid.uuid4()
request.user = self.user
self.assertEqual(self.queue.count, 0, msg="Unexpected jobs found in queue")
# Test create & update
with event_tracking(request):
site = Site(name='Site 1', slug='site-1')
site.save()
# Save the site a second time
site.description = 'foo'
site.save()
self.assertEqual(self.queue.count, 1, msg="Duplicate jobs found in queue")
job = self.queue.get_jobs()[0]
self.assertEqual(job.kwargs['event_type'], OBJECT_CREATED)
self.queue.empty()
# Test multiple updates
site = Site.objects.create(name='Site 2', slug='site-2')
with event_tracking(request):
site.description = 'foo'
site.save()
site.description = 'bar'
site.save()
self.assertEqual(self.queue.count, 1, msg="Duplicate jobs found in queue")
job = self.queue.get_jobs()[0]
self.assertEqual(job.kwargs['event_type'], OBJECT_UPDATED)
self.queue.empty()
# Test update & delete
site = Site.objects.create(name='Site 3', slug='site-3')
with event_tracking(request):
site.description = 'foo'
site.save()
site.delete()
self.assertEqual(self.queue.count, 1, msg="Duplicate jobs found in queue")
job = self.queue.get_jobs()[0]
self.assertEqual(job.kwargs['event_type'], OBJECT_DELETED)
self.queue.empty()

View File

@ -1,7 +1,6 @@
import uuid
from datetime import datetime, timezone
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.test import TestCase
@ -15,13 +14,11 @@ from extras.choices import *
from extras.filtersets import *
from extras.models import *
from tenancy.models import Tenant, TenantGroup
from users.models import Group, User
from utilities.testing import BaseFilterSetTests, ChangeLoggedFilterSetTests, create_tags
from virtualization.models import Cluster, ClusterGroup, ClusterType
User = get_user_model()
class CustomFieldTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = CustomField.objects.all()
filterset = CustomFieldFilterSet
@ -1187,6 +1184,7 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
'rack',
'rackreservation',
'rackrole',
'racktype',
'rearport',
'region',
'rir',
@ -1370,3 +1368,65 @@ class ChangeLoggedFilterSetTestCase(TestCase):
params = {'modified_by_request': self.create_update_request_id}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
self.assertEqual(self.queryset.count(), 4)
class NotificationGroupTestCase(TestCase, BaseFilterSetTests):
queryset = NotificationGroup.objects.all()
filterset = NotificationGroupFilterSet
@classmethod
def setUpTestData(cls):
users = (
User(username='User 1'),
User(username='User 2'),
User(username='User 3'),
)
User.objects.bulk_create(users)
groups = (
Group(name='Group 1'),
Group(name='Group 2'),
Group(name='Group 3'),
)
Group.objects.bulk_create(groups)
sites = (
Site(name='Site 1', slug='site-1'),
Site(name='Site 2', slug='site-2'),
Site(name='Site 3', slug='site-3'),
)
Site.objects.bulk_create(sites)
tenants = (
Tenant(name='Tenant 1', slug='tenant-1'),
Tenant(name='Tenant 2', slug='tenant-2'),
Tenant(name='Tenant 3', slug='tenant-3'),
)
Tenant.objects.bulk_create(tenants)
notification_groups = (
NotificationGroup(name='Notification Group 1'),
NotificationGroup(name='Notification Group 2'),
NotificationGroup(name='Notification Group 3'),
)
NotificationGroup.objects.bulk_create(notification_groups)
notification_groups[0].users.add(users[0])
notification_groups[1].users.add(users[1])
notification_groups[2].users.add(users[2])
notification_groups[0].groups.add(groups[0])
notification_groups[1].groups.add(groups[1])
notification_groups[2].groups.add(groups[2])
def test_user(self):
users = User.objects.filter(username__startswith='User')
params = {'user': [users[0].username, users[1].username]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'user_id': [users[0].pk, users[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_group(self):
groups = Group.objects.all()
params = {'group': [groups[0].name, groups[1].name]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'group_id': [groups[0].pk, groups[1].pk]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

View File

@ -1,4 +1,3 @@
from django.contrib.auth import get_user_model
from django.contrib.contenttypes.models import ContentType
from django.urls import reverse
@ -6,10 +5,9 @@ from core.models import ObjectType
from dcim.models import DeviceType, Manufacturer, Site
from extras.choices import *
from extras.models import *
from users.models import Group, User
from utilities.testing import ViewTestCases, TestCase
User = get_user_model()
class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = CustomField
@ -620,3 +618,166 @@ class CustomLinkTest(TestCase):
response = self.client.get(site.get_absolute_url(), follow=True)
self.assertEqual(response.status_code, 200)
self.assertIn(f'FOO {site.name} BAR', str(response.content))
class SubscriptionTestCase(
ViewTestCases.CreateObjectViewTestCase,
ViewTestCases.DeleteObjectViewTestCase,
ViewTestCases.ListObjectsViewTestCase,
ViewTestCases.BulkDeleteObjectsViewTestCase
):
model = Subscription
@classmethod
def setUpTestData(cls):
site_ct = ContentType.objects.get_for_model(Site)
sites = (
Site(name='Site 1', slug='site-1'),
Site(name='Site 2', slug='site-2'),
Site(name='Site 3', slug='site-3'),
Site(name='Site 4', slug='site-4'),
)
Site.objects.bulk_create(sites)
cls.form_data = {
'object_type': site_ct.pk,
'object_id': sites[3].pk,
}
def setUp(self):
super().setUp()
sites = Site.objects.all()
user = self.user
subscriptions = (
Subscription(object=sites[0], user=user),
Subscription(object=sites[1], user=user),
Subscription(object=sites[2], user=user),
)
Subscription.objects.bulk_create(subscriptions)
def _get_url(self, action, instance=None):
if action == 'list':
return reverse('account:subscriptions')
return super()._get_url(action, instance)
def test_list_objects_anonymous(self):
self.client.logout()
url = reverse('account:subscriptions')
login_url = reverse('login')
self.assertRedirects(self.client.get(url), f'{login_url}?next={url}')
def test_list_objects_with_permission(self):
return
def test_list_objects_with_constrained_permission(self):
return
class NotificationGroupTestCase(ViewTestCases.PrimaryObjectViewTestCase):
model = NotificationGroup
@classmethod
def setUpTestData(cls):
users = (
User(username='User 1'),
User(username='User 2'),
User(username='User 3'),
)
User.objects.bulk_create(users)
groups = (
Group(name='Group 1'),
Group(name='Group 2'),
Group(name='Group 3'),
)
Group.objects.bulk_create(groups)
notification_groups = (
NotificationGroup(name='Notification Group 1'),
NotificationGroup(name='Notification Group 2'),
NotificationGroup(name='Notification Group 3'),
)
NotificationGroup.objects.bulk_create(notification_groups)
for i, notification_group in enumerate(notification_groups):
notification_group.users.add(users[i])
notification_group.groups.add(groups[i])
cls.form_data = {
'name': 'Notification Group X',
'description': 'Blah',
'users': [users[0].pk, users[1].pk],
'groups': [groups[0].pk, groups[1].pk],
}
cls.csv_data = (
'name,description,users,groups',
'Notification Group 4,Foo,"User 1,User 2","Group 1,Group 2"',
'Notification Group 5,Bar,"User 1,User 2","Group 1,Group 2"',
'Notification Group 6,Baz,"User 1,User 2","Group 1,Group 2"',
)
cls.csv_update_data = (
"id,name",
f"{notification_groups[0].pk},Notification Group 7",
f"{notification_groups[1].pk},Notification Group 8",
f"{notification_groups[2].pk},Notification Group 9",
)
cls.bulk_edit_data = {
'description': 'New description',
}
class NotificationTestCase(
ViewTestCases.DeleteObjectViewTestCase,
ViewTestCases.ListObjectsViewTestCase,
ViewTestCases.BulkDeleteObjectsViewTestCase
):
model = Notification
@classmethod
def setUpTestData(cls):
site_ct = ContentType.objects.get_for_model(Site)
sites = (
Site(name='Site 1', slug='site-1'),
Site(name='Site 2', slug='site-2'),
Site(name='Site 3', slug='site-3'),
Site(name='Site 4', slug='site-4'),
)
Site.objects.bulk_create(sites)
cls.form_data = {
'object_type': site_ct.pk,
'object_id': sites[3].pk,
}
def setUp(self):
super().setUp()
sites = Site.objects.all()
user = self.user
notifications = (
Notification(object=sites[0], user=user),
Notification(object=sites[1], user=user),
Notification(object=sites[2], user=user),
)
Notification.objects.bulk_create(notifications)
def _get_url(self, action, instance=None):
if action == 'list':
return reverse('account:notifications')
return super()._get_url(action, instance)
def test_list_objects_anonymous(self):
self.client.logout()
url = reverse('account:notifications')
login_url = reverse('login')
self.assertRedirects(self.client.get(url), f'{login_url}?next={url}')
def test_list_objects_with_permission(self):
return
def test_list_objects_with_constrained_permission(self):
return

View File

@ -53,6 +53,24 @@ urlpatterns = [
path('bookmarks/delete/', views.BookmarkBulkDeleteView.as_view(), name='bookmark_bulk_delete'),
path('bookmarks/<int:pk>/', include(get_model_urls('extras', 'bookmark'))),
# Notification groups
path('notification-groups/', views.NotificationGroupListView.as_view(), name='notificationgroup_list'),
path('notification-groups/add/', views.NotificationGroupEditView.as_view(), name='notificationgroup_add'),
path('notification-groups/import/', views.NotificationGroupBulkImportView.as_view(), name='notificationgroup_import'),
path('notification-groups/edit/', views.NotificationGroupBulkEditView.as_view(), name='notificationgroup_bulk_edit'),
path('notification-groups/delete/', views.NotificationGroupBulkDeleteView.as_view(), name='notificationgroup_bulk_delete'),
path('notification-groups/<int:pk>/', include(get_model_urls('extras', 'notificationgroup'))),
# Notifications
path('notifications/', views.NotificationsView.as_view(), name='notifications'),
path('notifications/delete/', views.NotificationBulkDeleteView.as_view(), name='notification_bulk_delete'),
path('notifications/<int:pk>/', include(get_model_urls('extras', 'notification'))),
# Subscriptions
path('subscriptions/add/', views.SubscriptionCreateView.as_view(), name='subscription_add'),
path('subscriptions/delete/', views.SubscriptionBulkDeleteView.as_view(), name='subscription_bulk_delete'),
path('subscriptions/<int:pk>/', include(get_model_urls('extras', 'subscription'))),
# Webhooks
path('webhooks/', views.WebhookListView.as_view(), name='webhook_list'),
path('webhooks/add/', views.WebhookEditView.as_view(), name='webhook_add'),

View File

@ -6,6 +6,7 @@ from django.db.models import Count, Q
from django.http import HttpResponseBadRequest, HttpResponseForbidden, HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse
from django.utils import timezone
from django.utils.translation import gettext as _
from django.views.generic import View
@ -14,6 +15,7 @@ from core.forms import ManagedFileForm
from core.models import Job
from core.tables import JobTable
from dcim.models import Device, DeviceRole, Platform
from extras.choices import LogLevelChoices
from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm
from extras.dashboard.utils import get_widget_class
from netbox.constants import DEFAULT_ACTION_PERMISSIONS
@ -30,6 +32,7 @@ from utilities.templatetags.builtins.filters import render_markdown
from utilities.views import ContentTypePermissionRequiredMixin, get_viewname, register_model_view
from virtualization.models import VirtualMachine
from . import filtersets, forms, tables
from .constants import LOG_LEVEL_RANK
from .models import *
from .scripts import run_script
from .tables import ReportResultsTable, ScriptResultsTable
@ -354,6 +357,139 @@ class BookmarkBulkDeleteView(generic.BulkDeleteView):
return Bookmark.objects.filter(user=request.user)
#
# Notification groups
#
class NotificationGroupListView(generic.ObjectListView):
queryset = NotificationGroup.objects.all()
filterset = filtersets.NotificationGroupFilterSet
filterset_form = forms.NotificationGroupFilterForm
table = tables.NotificationGroupTable
@register_model_view(NotificationGroup)
class NotificationGroupView(generic.ObjectView):
queryset = NotificationGroup.objects.all()
@register_model_view(NotificationGroup, 'edit')
class NotificationGroupEditView(generic.ObjectEditView):
queryset = NotificationGroup.objects.all()
form = forms.NotificationGroupForm
@register_model_view(NotificationGroup, 'delete')
class NotificationGroupDeleteView(generic.ObjectDeleteView):
queryset = NotificationGroup.objects.all()
class NotificationGroupBulkImportView(generic.BulkImportView):
queryset = NotificationGroup.objects.all()
model_form = forms.NotificationGroupImportForm
class NotificationGroupBulkEditView(generic.BulkEditView):
queryset = NotificationGroup.objects.all()
filterset = filtersets.NotificationGroupFilterSet
table = tables.NotificationGroupTable
form = forms.NotificationGroupBulkEditForm
class NotificationGroupBulkDeleteView(generic.BulkDeleteView):
queryset = NotificationGroup.objects.all()
filterset = filtersets.NotificationGroupFilterSet
table = tables.NotificationGroupTable
#
# Notifications
#
class NotificationsView(LoginRequiredMixin, View):
"""
HTMX-only user-specific notifications list.
"""
def get(self, request):
return render(request, 'htmx/notifications.html', {
'notifications': request.user.notifications.unread(),
'total_count': request.user.notifications.count(),
})
@register_model_view(Notification, 'read')
class NotificationReadView(LoginRequiredMixin, View):
"""
Mark the Notification read and redirect the user to its attached object.
"""
def get(self, request, pk):
notification = get_object_or_404(request.user.notifications, pk=pk)
notification.read = timezone.now()
notification.save()
return redirect(notification.object.get_absolute_url())
@register_model_view(Notification, 'dismiss')
class NotificationDismissView(LoginRequiredMixin, View):
"""
A convenience view which allows deleting notifications with one click.
"""
def get(self, request, pk):
notification = get_object_or_404(request.user.notifications, pk=pk)
notification.delete()
if htmx_partial(request):
return render(request, 'htmx/notifications.html', {
'notifications': request.user.notifications.unread()[:10],
})
return redirect('account:notifications')
@register_model_view(Notification, 'delete')
class NotificationDeleteView(generic.ObjectDeleteView):
def get_queryset(self, request):
return Notification.objects.filter(user=request.user)
class NotificationBulkDeleteView(generic.BulkDeleteView):
table = tables.NotificationTable
def get_queryset(self, request):
return Notification.objects.filter(user=request.user)
#
# Subscriptions
#
class SubscriptionCreateView(generic.ObjectEditView):
form = forms.SubscriptionForm
def get_queryset(self, request):
return Subscription.objects.filter(user=request.user)
def alter_object(self, obj, request, url_args, url_kwargs):
obj.user = request.user
return obj
@register_model_view(Subscription, 'delete')
class SubscriptionDeleteView(generic.ObjectDeleteView):
def get_queryset(self, request):
return Subscription.objects.filter(user=request.user)
class SubscriptionBulkDeleteView(generic.BulkDeleteView):
table = tables.SubscriptionTable
def get_queryset(self, request):
return Subscription.objects.filter(user=request.user)
#
# Webhooks
#
@ -1119,22 +1255,27 @@ class ScriptResultView(TableMixin, generic.ObjectView):
tests = None
table = None
index = 0
log_threshold = LOG_LEVEL_RANK.get(request.GET.get('log_threshold', LogLevelChoices.LOG_DEFAULT))
if job.data:
if 'log' in job.data:
if 'tests' in job.data:
tests = job.data['tests']
for log in job.data['log']:
index += 1
result = {
'index': index,
'time': log.get('time'),
'status': log.get('status'),
'message': log.get('message'),
'object': log.get('obj'),
'url': log.get('url'),
}
data.append(result)
log_level = LOG_LEVEL_RANK.get(log.get('status'), LogLevelChoices.LOG_DEFAULT)
if log_level >= log_threshold:
index += 1
result = {
'index': index,
'time': log.get('time'),
'status': log.get('status'),
'message': log.get('message'),
'object': log.get('obj'),
'url': log.get('url'),
}
data.append(result)
table = ScriptResultsTable(data, user=request.user)
table.configure(request)
@ -1146,17 +1287,19 @@ class ScriptResultView(TableMixin, generic.ObjectView):
for method, test_data in tests.items():
if 'log' in test_data:
for time, status, obj, url, message in test_data['log']:
index += 1
result = {
'index': index,
'method': method,
'time': time,
'status': status,
'object': obj,
'url': url,
'message': message,
}
data.append(result)
log_level = LOG_LEVEL_RANK.get(status, LogLevelChoices.LOG_DEFAULT)
if log_level >= log_threshold:
index += 1
result = {
'index': index,
'method': method,
'time': time,
'status': status,
'object': obj,
'url': url,
'message': message,
}
data.append(result)
table = ReportResultsTable(data, user=request.user)
table.configure(request)
@ -1174,6 +1317,8 @@ class ScriptResultView(TableMixin, generic.ObjectView):
'script': job.object,
'job': job,
'table': table,
'log_levels': dict(LogLevelChoices),
'log_threshold': request.GET.get('log_threshold', LogLevelChoices.LOG_DEFAULT)
}
if job.data and 'log' in job.data:
@ -1200,7 +1345,7 @@ class ScriptResultView(TableMixin, generic.ObjectView):
# Markdown
#
class RenderMarkdownView(View):
class RenderMarkdownView(LoginRequiredMixin, View):
def post(self, request):
form = forms.RenderMarkdownForm(request.POST)

View File

@ -25,7 +25,7 @@ def generate_signature(request_body, secret):
@job('default')
def send_webhook(event_rule, model_name, event, data, timestamp, username, request_id=None, snapshots=None):
def send_webhook(event_rule, model_name, event_type, data, timestamp, username, request_id=None, snapshots=None):
"""
Make a POST request to the defined Webhook
"""
@ -33,7 +33,7 @@ def send_webhook(event_rule, model_name, event, data, timestamp, username, reque
# Prepare context data for headers & body templates
context = {
'event': WEBHOOK_EVENT_TYPES[event],
'event': WEBHOOK_EVENT_TYPES.get(event_type, event_type),
'timestamp': timestamp,
'model': model_name,
'username': username,

View File

@ -6,7 +6,7 @@ from dcim.api.serializers_.sites import SiteSerializer
from ipam.choices import *
from ipam.constants import VLANGROUP_SCOPE_TYPES
from ipam.models import VLAN, VLANGroup
from netbox.api.fields import ChoiceField, ContentTypeField, RelatedObjectCountField
from netbox.api.fields import ChoiceField, ContentTypeField, IntegerRangeSerializer, RelatedObjectCountField
from netbox.api.serializers import NetBoxModelSerializer
from tenancy.api.serializers_.tenants import TenantSerializer
from utilities.api import get_serializer_for_model
@ -32,6 +32,7 @@ class VLANGroupSerializer(NetBoxModelSerializer):
)
scope_id = serializers.IntegerField(allow_null=True, required=False, default=None)
scope = serializers.SerializerMethodField(read_only=True)
vid_ranges = IntegerRangeSerializer(many=True, required=False)
utilization = serializers.CharField(read_only=True)
# Related object counts
@ -40,8 +41,8 @@ class VLANGroupSerializer(NetBoxModelSerializer):
class Meta:
model = VLANGroup
fields = [
'id', 'url', 'display_url', 'display', 'name', 'slug', 'scope_type', 'scope_id', 'scope', 'min_vid',
'max_vid', 'description', 'tags', 'custom_fields', 'created', 'last_updated', 'vlan_count', 'utilization'
'id', 'url', 'display_url', 'display', 'name', 'slug', 'scope_type', 'scope_id', 'scope', 'vid_ranges',
'description', 'tags', 'custom_fields', 'created', 'last_updated', 'vlan_count', 'utilization'
]
brief_fields = ('id', 'url', 'display', 'name', 'slug', 'description', 'vlan_count')
validators = []

View File

@ -911,10 +911,13 @@ class VLANGroupFilterSet(OrganizationalModelFilterSet):
cluster = django_filters.NumberFilter(
method='filter_scope'
)
contains_vid = django_filters.NumberFilter(
method='filter_contains_vid'
)
class Meta:
model = VLANGroup
fields = ('id', 'name', 'slug', 'min_vid', 'max_vid', 'description', 'scope_id')
fields = ('id', 'name', 'slug', 'description', 'scope_id')
def search(self, queryset, name, value):
if not value.strip():
@ -932,6 +935,21 @@ class VLANGroupFilterSet(OrganizationalModelFilterSet):
scope_id=value
)
def filter_contains_vid(self, queryset, name, value):
"""
Return all VLANGroups which contain the given VLAN ID.
"""
table_name = VLANGroup._meta.db_table
# TODO: See if this can be optimized without compromising queryset integrity
# Expand VLAN ID ranges to query by integer
groups = VLANGroup.objects.raw(
f'SELECT id FROM {table_name}, unnest(vid_ranges) vid_range WHERE %s <@ vid_range',
params=(value,)
)
return queryset.filter(
pk__in=[g.id for g in groups]
)
class VLANFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
region_id = TreeNodeMultipleChoiceFilter(

View File

@ -12,6 +12,7 @@ from tenancy.models import Tenant
from utilities.forms import add_blank_choice
from utilities.forms.fields import (
CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, NumericArrayField,
NumericRangeArrayField,
)
from utilities.forms.rendering import FieldSet
from utilities.forms.widgets import BulkEditNullBooleanSelect
@ -408,18 +409,6 @@ class FHRPGroupBulkEditForm(NetBoxModelBulkEditForm):
class VLANGroupBulkEditForm(NetBoxModelBulkEditForm):
min_vid = forms.IntegerField(
min_value=VLAN_VID_MIN,
max_value=VLAN_VID_MAX,
required=False,
label=_('Minimum child VLAN VID')
)
max_vid = forms.IntegerField(
min_value=VLAN_VID_MIN,
max_value=VLAN_VID_MAX,
required=False,
label=_('Maximum child VLAN VID')
)
description = forms.CharField(
label=_('Description'),
max_length=200,
@ -483,10 +472,14 @@ class VLANGroupBulkEditForm(NetBoxModelBulkEditForm):
'group_id': '$clustergroup',
}
)
vid_ranges = NumericRangeArrayField(
label=_('VLAN ID ranges'),
required=False
)
model = VLANGroup
fieldsets = (
FieldSet('site', 'min_vid', 'max_vid', 'description'),
FieldSet('site', 'vid_ranges', 'description'),
FieldSet(
'scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster', name=_('Scope')
),

View File

@ -9,7 +9,8 @@ from ipam.models import *
from netbox.forms import NetBoxModelImportForm
from tenancy.models import Tenant
from utilities.forms.fields import (
CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVModelMultipleChoiceField, SlugField
CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVModelMultipleChoiceField, SlugField,
NumericRangeArrayField,
)
from virtualization.models import VirtualMachine, VMInterface
@ -411,22 +412,13 @@ class VLANGroupImportForm(NetBoxModelImportForm):
required=False,
label=_('Scope type (app & model)')
)
min_vid = forms.IntegerField(
min_value=VLAN_VID_MIN,
max_value=VLAN_VID_MAX,
required=False,
label=_('Minimum child VLAN VID (default: {minimum})').format(minimum=VLAN_VID_MIN)
)
max_vid = forms.IntegerField(
min_value=VLAN_VID_MIN,
max_value=VLAN_VID_MAX,
required=False,
label=_('Maximum child VLAN VID (default: {maximum})').format(maximum=VLAN_VID_MIN)
vid_ranges = NumericRangeArrayField(
required=False
)
class Meta:
model = VLANGroup
fields = ('name', 'slug', 'scope_type', 'scope_id', 'min_vid', 'max_vid', 'description', 'tags')
fields = ('name', 'slug', 'scope_type', 'scope_id', 'vid_ranges', 'description', 'tags')
labels = {
'scope_id': 'Scope ID',
}

View File

@ -413,7 +413,7 @@ class VLANGroupFilterForm(NetBoxModelFilterSetForm):
FieldSet('q', 'filter_id', 'tag'),
FieldSet('region', 'sitegroup', 'site', 'location', 'rack', name=_('Location')),
FieldSet('cluster_group', 'cluster', name=_('Cluster')),
FieldSet('min_vid', 'max_vid', name=_('VLAN ID')),
FieldSet('contains_vid', name=_('VLANs')),
)
model = VLANGroup
region = DynamicModelMultipleChoiceField(
@ -441,18 +441,6 @@ class VLANGroupFilterForm(NetBoxModelFilterSetForm):
required=False,
label=_('Rack')
)
min_vid = forms.IntegerField(
required=False,
min_value=VLAN_VID_MIN,
max_value=VLAN_VID_MAX,
label=_('Minimum VID')
)
max_vid = forms.IntegerField(
required=False,
min_value=VLAN_VID_MIN,
max_value=VLAN_VID_MAX,
label=_('Maximum VID')
)
cluster = DynamicModelMultipleChoiceField(
queryset=Cluster.objects.all(),
required=False,
@ -463,6 +451,11 @@ class VLANGroupFilterForm(NetBoxModelFilterSetForm):
required=False,
label=_('Cluster group')
)
contains_vid = forms.IntegerField(
min_value=0,
required=False,
label=_('Contains VLAN ID')
)
tag = TagFilterField(model)

View File

@ -1,5 +1,6 @@
from django import forms
from django.contrib.contenttypes.models import ContentType
from django.contrib.postgres.forms import IntegerRangeField, SimpleArrayField
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
@ -14,7 +15,7 @@ from utilities.exceptions import PermissionsViolation
from utilities.forms import add_blank_choice
from utilities.forms.fields import (
CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, NumericArrayField,
SlugField,
NumericRangeArrayField, SlugField
)
from utilities.forms.rendering import FieldSet, InlineFields, ObjectAttribute, TabbedGroups
from utilities.forms.widgets import DatePicker
@ -632,10 +633,13 @@ class VLANGroupForm(NetBoxModelForm):
}
)
slug = SlugField()
vid_ranges = NumericRangeArrayField(
label=_('VLAN IDs')
)
fieldsets = (
FieldSet('name', 'slug', 'description', 'tags', name=_('VLAN Group')),
FieldSet('min_vid', 'max_vid', name=_('Child VLANs')),
FieldSet('vid_ranges', name=_('Child VLANs')),
FieldSet(
'scope_type', 'region', 'sitegroup', 'site', 'location', 'rack', 'clustergroup', 'cluster',
name=_('Scope')
@ -646,7 +650,7 @@ class VLANGroupForm(NetBoxModelForm):
model = VLANGroup
fields = [
'name', 'slug', 'description', 'scope_type', 'region', 'sitegroup', 'site', 'location', 'rack',
'clustergroup', 'cluster', 'min_vid', 'max_vid', 'tags',
'clustergroup', 'cluster', 'vid_ranges', 'tags',
]
def __init__(self, *args, **kwargs):

View File

@ -251,6 +251,7 @@ class VLANType(NetBoxObjectType):
class VLANGroupType(OrganizationalObjectType):
vlans: List[VLANType]
vid_ranges: List[str]
@strawberry_django.field
def scope(self) -> Annotated[Union[

View File

@ -0,0 +1,55 @@
import django.contrib.postgres.fields
import django.contrib.postgres.fields.ranges
from django.db import migrations, models
from django.db.backends.postgresql.psycopg_any import NumericRange
import ipam.models.vlans
def set_vid_ranges(apps, schema_editor):
"""
Convert the min_vid & max_vid fields to a range in the new vid_ranges ArrayField.
"""
VLANGroup = apps.get_model('ipam', 'VLANGroup')
for group in VLANGroup.objects.all():
group.vid_ranges = [
NumericRange(group.min_vid, group.max_vid, bounds='[]')
]
group._total_vlan_ids = group.max_vid - group.min_vid + 1
group.save()
class Migration(migrations.Migration):
dependencies = [
('ipam', '0069_gfk_indexes'),
]
operations = [
migrations.AddField(
model_name='vlangroup',
name='vid_ranges',
field=django.contrib.postgres.fields.ArrayField(
base_field=django.contrib.postgres.fields.ranges.IntegerRangeField(),
default=ipam.models.vlans.default_vid_ranges,
size=None
),
),
migrations.AddField(
model_name='vlangroup',
name='_total_vlan_ids',
field=models.PositiveBigIntegerField(default=4094),
),
migrations.RunPython(
code=set_vid_ranges,
reverse_code=migrations.RunPython.noop
),
migrations.RemoveField(
model_name='vlangroup',
name='max_vid',
),
migrations.RemoveField(
model_name='vlangroup',
name='min_vid',
),
]

View File

@ -1,7 +1,9 @@
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
from django.contrib.postgres.fields import ArrayField, IntegerRangeField
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.db.backends.postgresql.psycopg_any import NumericRange
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
@ -10,6 +12,7 @@ from ipam.choices import *
from ipam.constants import *
from ipam.querysets import VLANQuerySet, VLANGroupQuerySet
from netbox.models import OrganizationalModel, PrimaryModel
from utilities.data import check_ranges_overlap, ranges_to_string
from virtualization.models import VMInterface
__all__ = (
@ -18,9 +21,16 @@ __all__ = (
)
def default_vid_ranges():
return [
NumericRange(VLAN_VID_MIN, VLAN_VID_MAX, bounds='[]')
]
class VLANGroup(OrganizationalModel):
"""
A VLAN group is an arbitrary collection of VLANs within which VLAN IDs and names must be unique.
A VLAN group is an arbitrary collection of VLANs within which VLAN IDs and names must be unique. Each group must
define one or more ranges of valid VLAN IDs, and may be assigned a specific scope.
"""
name = models.CharField(
verbose_name=_('name'),
@ -45,23 +55,13 @@ class VLANGroup(OrganizationalModel):
ct_field='scope_type',
fk_field='scope_id'
)
min_vid = models.PositiveSmallIntegerField(
verbose_name=_('minimum VLAN ID'),
default=VLAN_VID_MIN,
validators=(
MinValueValidator(VLAN_VID_MIN),
MaxValueValidator(VLAN_VID_MAX)
),
help_text=_('Lowest permissible ID of a child VLAN')
vid_ranges = ArrayField(
IntegerRangeField(),
verbose_name=_('VLAN ID ranges'),
default=default_vid_ranges
)
max_vid = models.PositiveSmallIntegerField(
verbose_name=_('maximum VLAN ID'),
default=VLAN_VID_MAX,
validators=(
MinValueValidator(VLAN_VID_MIN),
MaxValueValidator(VLAN_VID_MAX)
),
help_text=_('Highest permissible ID of a child VLAN')
_total_vlan_ids = models.PositiveBigIntegerField(
default=VLAN_VID_MAX - VLAN_VID_MIN + 1
)
objects = VLANGroupQuerySet.as_manager()
@ -96,17 +96,33 @@ class VLANGroup(OrganizationalModel):
if self.scope_id and not self.scope_type:
raise ValidationError(_("Cannot set scope_id without scope_type."))
# Validate min/max child VID limits
if self.max_vid < self.min_vid:
raise ValidationError({
'max_vid': _("Maximum child VID must be greater than or equal to minimum child VID")
})
# Validate VID ranges
if self.vid_ranges and check_ranges_overlap(self.vid_ranges):
raise ValidationError({'vid_ranges': _("Ranges cannot overlap.")})
for vid_range in self.vid_ranges:
if vid_range.lower >= vid_range.upper:
raise ValidationError({
'vid_ranges': _(
"Maximum child VID must be greater than or equal to minimum child VID ({value})"
).format(value=vid_range)
})
def save(self, *args, **kwargs):
self._total_vlan_ids = 0
for vid_range in self.vid_ranges:
self._total_vlan_ids += vid_range.upper - vid_range.lower + 1
super().save(*args, **kwargs)
def get_available_vids(self):
"""
Return all available VLANs within this group.
"""
available_vlans = {vid for vid in range(self.min_vid, self.max_vid + 1)}
available_vlans = set()
for vlan_range in self.vid_ranges:
available_vlans = available_vlans.union({
vid for vid in range(vlan_range.lower, vlan_range.upper)
})
available_vlans -= set(VLAN.objects.filter(group=self).values_list('vid', flat=True))
return sorted(available_vlans)
@ -126,6 +142,10 @@ class VLANGroup(OrganizationalModel):
"""
return VLAN.objects.filter(group=self).order_by('vid')
@property
def vid_ranges_list(self):
return ranges_to_string(self.vid_ranges)
class VLAN(PrimaryModel):
"""
@ -231,13 +251,14 @@ class VLAN(PrimaryModel):
).format(group=self.group, scope=self.group.scope, site=self.site)
)
# Validate group min/max VIDs
if self.group and not self.group.min_vid <= self.vid <= self.group.max_vid:
raise ValidationError({
'vid': _(
"VID must be between {minimum} and {maximum} for VLANs in group {group}"
).format(minimum=self.group.min_vid, maximum=self.group.max_vid, group=self.group)
})
# Check that the VLAN ID is permitted in the assigned group (if any)
if self.group:
if not any([self.vid in r for r in self.group.vid_ranges]):
raise ValidationError({
'vid': _(
"VID must be in ranges {ranges} for VLANs in group {group}"
).format(ranges=ranges_to_string(self.group.vid_ranges), group=self.group)
})
def get_status_color(self):
return VLANStatusChoices.colors.get(self.status)

View File

@ -9,6 +9,7 @@ from utilities.querysets import RestrictedQuerySet
__all__ = (
'ASNRangeQuerySet',
'PrefixQuerySet',
'VLANGroupQuerySet',
'VLANQuerySet',
)
@ -63,7 +64,7 @@ class VLANGroupQuerySet(RestrictedQuerySet):
return self.annotate(
vlan_count=count_related(VLAN, 'group'),
utilization=Round(F('vlan_count') / (F('max_vid') - F('min_vid') + 1.0) * 100, 2)
utilization=Round(F('vlan_count') * 100 / F('_total_vlan_ids'), 2)
)

View File

@ -154,9 +154,8 @@ class VLANGroupIndex(SearchIndex):
('name', 100),
('slug', 110),
('description', 500),
('max_vid', 2000),
)
display_attrs = ('scope_type', 'min_vid', 'max_vid', 'description')
display_attrs = ('scope_type', 'description')
@register_search

View File

@ -72,6 +72,10 @@ class VLANGroupTable(NetBoxTable):
linkify=True,
orderable=False
)
vid_ranges_list = tables.Column(
verbose_name=_('VID Ranges'),
orderable=False
)
vlan_count = columns.LinkedCountColumn(
viewname='ipam:vlan_list',
url_params={'group_id': 'pk'},
@ -91,7 +95,7 @@ class VLANGroupTable(NetBoxTable):
class Meta(NetBoxTable.Meta):
model = VLANGroup
fields = (
'pk', 'id', 'name', 'scope_type', 'scope', 'min_vid', 'max_vid', 'vlan_count', 'slug', 'description',
'pk', 'id', 'name', 'scope_type', 'scope', 'vid_ranges_list', 'vlan_count', 'slug', 'description',
'tags', 'created', 'last_updated', 'actions', 'utilization',
)
default_columns = ('pk', 'name', 'scope_type', 'scope', 'vlan_count', 'utilization', 'description')

View File

@ -8,6 +8,7 @@ from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer,
from ipam.choices import *
from ipam.models import *
from tenancy.models import Tenant
from utilities.data import string_to_ranges
from utilities.testing import APITestCase, APIViewTestCases, create_test_device, disable_warnings
@ -882,8 +883,7 @@ class VLANGroupTest(APIViewTestCases.APIViewTestCase):
vlangroup = VLANGroup.objects.create(
name='VLAN Group X',
slug='vlan-group-x',
min_vid=MIN_VID,
max_vid=MAX_VID
vid_ranges=string_to_ranges(f"{MIN_VID}-{MAX_VID}")
)
# Create a set of VLANs within the group

View File

@ -1,4 +1,5 @@
from django.contrib.contenttypes.models import ContentType
from django.db.backends.postgresql.psycopg_any import NumericRange
from django.test import TestCase
from netaddr import IPNetwork
@ -1465,6 +1466,7 @@ class FHRPGroupAssignmentTestCase(TestCase, ChangeLoggedFilterSetTests):
class VLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = VLANGroup.objects.all()
filterset = VLANGroupFilterSet
ignore_fields = ('vid_ranges',)
@classmethod
def setUpTestData(cls):
@ -1494,14 +1496,55 @@ class VLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
cluster.save()
vlan_groups = (
VLANGroup(name='VLAN Group 1', slug='vlan-group-1', scope=region, description='foobar1'),
VLANGroup(name='VLAN Group 2', slug='vlan-group-2', scope=sitegroup, description='foobar2'),
VLANGroup(name='VLAN Group 3', slug='vlan-group-3', scope=site, description='foobar3'),
VLANGroup(name='VLAN Group 4', slug='vlan-group-4', scope=location),
VLANGroup(name='VLAN Group 5', slug='vlan-group-5', scope=rack),
VLANGroup(name='VLAN Group 6', slug='vlan-group-6', scope=clustergroup),
VLANGroup(name='VLAN Group 7', slug='vlan-group-7', scope=cluster),
VLANGroup(name='VLAN Group 8', slug='vlan-group-8'),
VLANGroup(
name='VLAN Group 1',
slug='vlan-group-1',
vid_ranges=[NumericRange(1, 11), NumericRange(100, 200)],
scope=region,
description='foobar1'
),
VLANGroup(
name='VLAN Group 2',
slug='vlan-group-2',
vid_ranges=[NumericRange(1, 11), NumericRange(200, 300)],
scope=sitegroup,
description='foobar2'
),
VLANGroup(
name='VLAN Group 3',
slug='vlan-group-3',
vid_ranges=[NumericRange(1, 11), NumericRange(300, 400)],
scope=site,
description='foobar3'
),
VLANGroup(
name='VLAN Group 4',
slug='vlan-group-4',
vid_ranges=[NumericRange(1, 11), NumericRange(400, 500)],
scope=location
),
VLANGroup(
name='VLAN Group 5',
slug='vlan-group-5',
vid_ranges=[NumericRange(1, 11), NumericRange(500, 600)],
scope=rack
),
VLANGroup(
name='VLAN Group 6',
slug='vlan-group-6',
vid_ranges=[NumericRange(1, 11), NumericRange(600, 700)],
scope=clustergroup
),
VLANGroup(
name='VLAN Group 7',
slug='vlan-group-7',
vid_ranges=[NumericRange(1, 11), NumericRange(700, 800)],
scope=cluster
),
VLANGroup(
name='VLAN Group 8',
slug='vlan-group-8'
),
)
VLANGroup.objects.bulk_create(vlan_groups)
@ -1521,6 +1564,12 @@ class VLANGroupTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'description': ['foobar1', 'foobar2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_contains_vid(self):
params = {'contains_vid': 123}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'contains_vid': 1}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8)
def test_region(self):
params = {'region': Region.objects.first().pk}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)

View File

@ -1,6 +1,7 @@
from django.core.exceptions import ValidationError
from django.test import TestCase, override_settings
from netaddr import IPNetwork, IPSet
from utilities.data import string_to_ranges
from ipam.choices import *
from ipam.models import *
@ -509,8 +510,7 @@ class TestVLANGroup(TestCase):
vlangroup = VLANGroup.objects.create(
name='VLAN Group 1',
slug='vlan-group-1',
min_vid=100,
max_vid=199
vid_ranges=string_to_ranges('100-199'),
)
VLAN.objects.bulk_create((
VLAN(name='VLAN 100', vid=100, group=vlangroup),
@ -533,3 +533,13 @@ class TestVLANGroup(TestCase):
VLAN.objects.create(name='VLAN 104', vid=104, group=vlangroup)
self.assertEqual(vlangroup.get_next_available_vid(), 105)
def test_vid_validation(self):
vlangroup = VLANGroup.objects.first()
vlan = VLAN(vid=1, name='VLAN 1', group=vlangroup)
with self.assertRaises(ValidationError):
vlan.full_clean()
vlan = VLAN(vid=109, name='VLAN 109', group=vlangroup)
vlan.full_clean()

View File

@ -764,9 +764,8 @@ class VLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
cls.form_data = {
'name': 'VLAN Group X',
'slug': 'vlan-group-x',
'min_vid': 1,
'max_vid': 4094,
'description': 'A new VLAN group',
'vid_ranges': '100-199,300-399',
'tags': [t.pk for t in tags],
}

View File

@ -90,12 +90,12 @@ def add_available_ipaddresses(prefix, ipaddress_list, is_pool=False):
return output
def add_available_vlans(vlans, vlan_group=None):
def available_vlans_from_range(vlans, vlan_group, vlan_range):
"""
Create fake records for all gaps between used VLANs
"""
min_vid = vlan_group.min_vid if vlan_group else VLAN_VID_MIN
max_vid = vlan_group.max_vid if vlan_group else VLAN_VID_MAX
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
if not vlans:
return [{
@ -128,6 +128,17 @@ def add_available_vlans(vlans, vlan_group=None):
'available': max_vid - prev_vid,
})
return new_vlans
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))
vlans = list(vlans) + new_vlans
vlans.sort(key=lambda v: v.vid if type(v) is VLAN else v['vid'])

View File

@ -7,6 +7,7 @@ from django.utils.translation import gettext as _
from circuits.models import Provider
from dcim.filtersets import InterfaceFilterSet
from dcim.forms import InterfaceFilterForm
from dcim.models import Interface, Site
from netbox.views import generic
from tenancy.views import ObjectContactsView
@ -14,6 +15,7 @@ from utilities.query import count_related
from utilities.tables import get_table_ordering
from utilities.views import GetRelatedModelsMixin, ViewTab, register_model_view
from virtualization.filtersets import VMInterfaceFilterSet
from virtualization.forms import VMInterfaceFilterForm
from virtualization.models import VMInterface
from . import filtersets, forms, tables
from .choices import PrefixStatusChoices
@ -206,6 +208,7 @@ class ASNRangeASNsView(generic.ObjectChildrenView):
child_model = ASN
table = tables.ASNTable
filterset = filtersets.ASNFilterSet
filterset_form = forms.ASNFilterForm
tab = ViewTab(
label=_('ASNs'),
badge=lambda x: x.get_child_asns().count(),
@ -337,6 +340,7 @@ class AggregatePrefixesView(generic.ObjectChildrenView):
child_model = Prefix
table = tables.PrefixTable
filterset = filtersets.PrefixFilterSet
filterset_form = forms.PrefixFilterForm
template_name = 'ipam/aggregate/prefixes.html'
tab = ViewTab(
label=_('Prefixes'),
@ -523,6 +527,7 @@ class PrefixPrefixesView(generic.ObjectChildrenView):
child_model = Prefix
table = tables.PrefixTable
filterset = filtersets.PrefixFilterSet
filterset_form = forms.PrefixFilterForm
template_name = 'ipam/prefix/prefixes.html'
tab = ViewTab(
label=_('Child Prefixes'),
@ -558,6 +563,7 @@ class PrefixIPRangesView(generic.ObjectChildrenView):
child_model = IPRange
table = tables.IPRangeTable
filterset = filtersets.IPRangeFilterSet
filterset_form = forms.IPRangeFilterForm
template_name = 'ipam/prefix/ip_ranges.html'
tab = ViewTab(
label=_('Child Ranges'),
@ -584,6 +590,7 @@ class PrefixIPAddressesView(generic.ObjectChildrenView):
child_model = IPAddress
table = tables.IPAddressTable
filterset = filtersets.IPAddressFilterSet
filterset_form = forms.IPAddressFilterForm
template_name = 'ipam/prefix/ip_addresses.html'
tab = ViewTab(
label=_('IP Addresses'),
@ -683,6 +690,7 @@ class IPRangeIPAddressesView(generic.ObjectChildrenView):
child_model = IPAddress
table = tables.IPAddressTable
filterset = filtersets.IPAddressFilterSet
filterset_form = forms.IPRangeFilterForm
template_name = 'ipam/iprange/ip_addresses.html'
tab = ViewTab(
label=_('IP Addresses'),
@ -885,6 +893,7 @@ class IPAddressRelatedIPsView(generic.ObjectChildrenView):
child_model = IPAddress
table = tables.IPAddressTable
filterset = filtersets.IPAddressFilterSet
filterset_form = forms.IPAddressFilterForm
tab = ViewTab(
label=_('Related IPs'),
badge=lambda x: x.get_related_ips().count(),
@ -906,7 +915,7 @@ class IPAddressContactsView(ObjectContactsView):
#
class VLANGroupListView(generic.ObjectListView):
queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags')
queryset = VLANGroup.objects.annotate_utilization()
filterset = filtersets.VLANGroupFilterSet
filterset_form = forms.VLANGroupFilterForm
table = tables.VLANGroupTable
@ -914,7 +923,7 @@ class VLANGroupListView(generic.ObjectListView):
@register_model_view(VLANGroup)
class VLANGroupView(GetRelatedModelsMixin, generic.ObjectView):
queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags')
queryset = VLANGroup.objects.annotate_utilization()
def get_extra_context(self, request, instance):
return {
@ -957,6 +966,7 @@ class VLANGroupVLANsView(generic.ObjectChildrenView):
child_model = VLAN
table = tables.VLANTable
filterset = filtersets.VLANFilterSet
filterset_form = forms.VLANFilterForm
tab = ViewTab(
label=_('VLANs'),
badge=lambda x: x.get_child_vlans().count(),
@ -1112,6 +1122,7 @@ class VLANInterfacesView(generic.ObjectChildrenView):
child_model = Interface
table = tables.VLANDevicesTable
filterset = InterfaceFilterSet
filterset_form = InterfaceFilterForm
tab = ViewTab(
label=_('Device Interfaces'),
badge=lambda x: x.get_interfaces().count(),
@ -1129,6 +1140,7 @@ class VLANVMInterfacesView(generic.ObjectChildrenView):
child_model = VMInterface
table = tables.VLANVirtualMachinesTable
filterset = VMInterfaceFilterSet
filterset_form = VMInterfaceFilterForm
tab = ViewTab(
label=_('VM Interfaces'),
badge=lambda x: x.get_vminterfaces().count(),

View File

@ -1,4 +1,5 @@
from django.core.exceptions import ObjectDoesNotExist
from django.db.backends.postgresql.psycopg_any import NumericRange
from django.utils.translation import gettext as _
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field
@ -11,6 +12,7 @@ __all__ = (
'ChoiceField',
'ContentTypeField',
'IPNetworkSerializer',
'IntegerRangeSerializer',
'RelatedObjectCountField',
'SerializedPKRelatedField',
)
@ -154,3 +156,19 @@ class RelatedObjectCountField(serializers.ReadOnlyField):
self.relation = relation
super().__init__(**kwargs)
class IntegerRangeSerializer(serializers.Serializer):
"""
Represents a range of integers.
"""
def to_internal_value(self, data):
if not isinstance(data, (list, tuple)) or len(data) != 2:
raise ValidationError(_("Ranges must be specified in the form (lower, upper)."))
if type(data[0]) is not int or type(data[1]) is not int:
raise ValidationError(_("Range boundaries must be defined as integers."))
return NumericRange(data[0], data[1], bounds='[]')
def to_representation(self, instance):
return instance.lower, instance.upper - 1

45
netbox/netbox/events.py Normal file
View File

@ -0,0 +1,45 @@
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'
__all__ = (
'EVENT_TYPE_DANGER',
'EVENT_TYPE_INFO',
'EVENT_TYPE_SUCCESS',
'EVENT_TYPE_WARNING',
'Event',
)
@dataclass
class Event:
name: str
text: str
type: str = EVENT_TYPE_INFO
def __str__(self):
return self.text
def register(self):
registry['events'][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)
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)

View File

@ -1,7 +1,7 @@
import re
from django import forms
from django.utils.translation import gettext as _
from django.utils.translation import gettext_lazy as _
from netbox.search import LookupTypes
from netbox.search.backends import search_backend
@ -36,7 +36,8 @@ class SearchForm(forms.Form):
lookup = forms.ChoiceField(
choices=LOOKUP_CHOICES,
initial=LookupTypes.PARTIAL,
required=False
required=False,
label=_('Lookup')
)
def __init__(self, *args, **kwargs):

Some files were not shown because too many files have changed in this diff Show More