mirror of
https://github.com/netbox-community/netbox.git
synced 2025-08-08 08:38:16 -06:00
Merge branch 'feature-ui' into 16394-distinguish-product-edition
This commit is contained in:
commit
853fa99ea4
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@ -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
|
||||
|
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
@ -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
|
||||
|
@ -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:
|
||||
|
@ -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.
|
||||
|
||||
|
@ -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.
|
||||
|
@ -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.
|
||||
|
@ -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).
|
||||
|
57
docs/models/dcim/racktype.md
Normal file
57
docs/models/dcim/racktype.md
Normal 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.)
|
||||
|
||||
|
17
docs/models/extras/notification.md
Normal file
17
docs/models/extras/notification.md
Normal 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.
|
17
docs/models/extras/notificationgroup.md
Normal file
17
docs/models/extras/notificationgroup.md
Normal 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.
|
15
docs/models/extras/subscription.md
Normal file
15
docs/models/extras/subscription.md
Normal 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.
|
@ -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
|
||||
|
||||
|
@ -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={
|
||||
|
@ -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.
|
||||
|
@ -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
|
||||
|
||||
---
|
||||
|
||||
|
@ -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
|
||||
|
||||
|
@ -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:
|
||||
|
@ -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'),
|
||||
|
@ -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
|
||||
#
|
||||
|
@ -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)'),
|
||||
]
|
||||
|
@ -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):
|
||||
|
@ -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
33
netbox/core/events.py
Normal 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()
|
@ -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
|
||||
|
@ -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:
|
||||
|
@ -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')
|
||||
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
#
|
||||
|
@ -51,7 +51,7 @@ class LocationStatusChoices(ChoiceSet):
|
||||
# Racks
|
||||
#
|
||||
|
||||
class RackTypeChoices(ChoiceSet):
|
||||
class RackFormFactorChoices(ChoiceSet):
|
||||
|
||||
TYPE_2POST = '2-post-frame'
|
||||
TYPE_4POST = '4-post-frame'
|
||||
|
@ -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(),
|
||||
|
@ -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')),
|
||||
)
|
||||
|
@ -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):
|
||||
"""
|
||||
|
@ -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)
|
||||
|
||||
|
||||
|
@ -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(
|
||||
|
@ -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):
|
||||
|
@ -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)
|
||||
|
@ -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')]]
|
||||
|
93
netbox/dcim/migrations/0188_racktype.py
Normal file
93
netbox/dcim/migrations/0188_racktype.py
Normal 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',
|
||||
),
|
||||
),
|
||||
]
|
@ -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):
|
||||
"""
|
||||
|
@ -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
|
||||
|
@ -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',
|
||||
|
@ -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']
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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'),
|
||||
|
@ -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(),
|
||||
|
@ -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 *
|
||||
|
82
netbox/extras/api/serializers_/notifications.py
Normal file
82
netbox/extras/api/serializers_/notifications.py
Normal 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
|
@ -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)
|
||||
|
@ -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
|
||||
#
|
||||
|
@ -302,8 +302,10 @@ class EventRuleActionChoices(ChoiceSet):
|
||||
|
||||
WEBHOOK = 'webhook'
|
||||
SCRIPT = 'script'
|
||||
NOTIFICATION = 'notification'
|
||||
|
||||
CHOICES = (
|
||||
(WEBHOOK, _('Webhook')),
|
||||
(SCRIPT, _('Script')),
|
||||
(NOTIFICATION, _('Notification')),
|
||||
)
|
||||
|
@ -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,
|
||||
}
|
||||
|
@ -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]
|
||||
|
||||
|
@ -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']
|
||||
)
|
||||
|
||||
|
||||
|
@ -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',
|
||||
|
@ -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',)
|
||||
|
@ -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')
|
||||
|
@ -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')
|
||||
)
|
||||
|
@ -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
|
||||
|
||||
|
@ -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):
|
||||
|
@ -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)
|
||||
|
@ -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', ],
|
||||
|
@ -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
|
||||
|
78
netbox/extras/migrations/0118_notifications.py
Normal file
78
netbox/extras/migrations/0118_notifications.py
Normal 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'),
|
||||
),
|
||||
]
|
@ -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 *
|
||||
|
@ -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).")
|
||||
|
222
netbox/extras/models/notifications.py
Normal file
222
netbox/extras/models/notifications.py
Normal 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)
|
||||
)
|
@ -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)
|
||||
|
@ -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
|
||||
])
|
||||
|
13
netbox/extras/tables/columns.py
Normal file
13
netbox/extras/tables/columns.py
Normal 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'),
|
||||
}
|
@ -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 %}
|
||||
—
|
||||
{% 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'),
|
||||
|
@ -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,
|
||||
},
|
||||
]
|
||||
|
@ -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()
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
@ -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'),
|
||||
|
@ -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)
|
||||
|
@ -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,
|
||||
|
@ -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 = []
|
||||
|
@ -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(
|
||||
|
@ -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')
|
||||
),
|
||||
|
@ -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',
|
||||
}
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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):
|
||||
|
@ -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[
|
||||
|
55
netbox/ipam/migrations/0070_vlangroup_vlan_id_ranges.py
Normal file
55
netbox/ipam/migrations/0070_vlangroup_vlan_id_ranges.py
Normal 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',
|
||||
),
|
||||
]
|
@ -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)
|
||||
|
@ -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)
|
||||
)
|
||||
|
||||
|
||||
|
@ -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
|
||||
|
@ -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')
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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()
|
||||
|
@ -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],
|
||||
}
|
||||
|
||||
|
@ -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'])
|
||||
|
||||
|
@ -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(),
|
||||
|
@ -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
45
netbox/netbox/events.py
Normal 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)
|
@ -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
Loading…
Reference in New Issue
Block a user