diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 3af0fa740..393ae6dc8 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -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 diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index 2c6384b27..910da96f9 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -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 diff --git a/docs/configuration/error-reporting.md b/docs/configuration/error-reporting.md index 8c3526dec..56f187845 100644 --- a/docs/configuration/error-reporting.md +++ b/docs/configuration/error-reporting.md @@ -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: diff --git a/docs/configuration/system.md b/docs/configuration/system.md index a1e0ebb17..f3c68db1b 100644 --- a/docs/configuration/system.md +++ b/docs/configuration/system.md @@ -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. diff --git a/docs/development/release-checklist.md b/docs/development/release-checklist.md index 91162f08a..019eb2a6c 100644 --- a/docs/development/release-checklist.md +++ b/docs/development/release-checklist.md @@ -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 has been updated. diff --git a/docs/features/facilities.md b/docs/features/facilities.md index 84c7c5733..4c8dfe265 100644 --- a/docs/features/facilities.md +++ b/docs/features/facilities.md @@ -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. diff --git a/docs/models/dcim/rack.md b/docs/models/dcim/rack.md index 3989e2b04..d610f6368 100644 --- a/docs/models/dcim/rack.md +++ b/docs/models/dcim/rack.md @@ -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). diff --git a/docs/models/dcim/racktype.md b/docs/models/dcim/racktype.md new file mode 100644 index 000000000..d44e17b17 --- /dev/null +++ b/docs/models/dcim/racktype.md @@ -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.) + + diff --git a/docs/models/extras/notification.md b/docs/models/extras/notification.md new file mode 100644 index 000000000..e72a35bec --- /dev/null +++ b/docs/models/extras/notification.md @@ -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. diff --git a/docs/models/extras/notificationgroup.md b/docs/models/extras/notificationgroup.md new file mode 100644 index 000000000..6463d137a --- /dev/null +++ b/docs/models/extras/notificationgroup.md @@ -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. diff --git a/docs/models/extras/subscription.md b/docs/models/extras/subscription.md new file mode 100644 index 000000000..3fc4a1f11 --- /dev/null +++ b/docs/models/extras/subscription.md @@ -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. diff --git a/docs/models/ipam/vlangroup.md b/docs/models/ipam/vlangroup.md index a2920fb70..67050ab4c 100644 --- a/docs/models/ipam/vlangroup.md +++ b/docs/models/ipam/vlangroup.md @@ -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 diff --git a/docs/plugins/development/views.md b/docs/plugins/development/views.md index f6624f42c..cbf920ad5 100644 --- a/docs/plugins/development/views.md +++ b/docs/plugins/development/views.md @@ -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={ diff --git a/docs/plugins/removal.md b/docs/plugins/removal.md index f5e81bdc0..37228a939 100644 --- a/docs/plugins/removal.md +++ b/docs/plugins/removal.md @@ -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. diff --git a/docs/release-notes/version-4.0.md b/docs/release-notes/version-4.0.md index 4537a8800..e91d7eb9e 100644 --- a/docs/release-notes/version-4.0.md +++ b/docs/release-notes/version-4.0.md @@ -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 --- diff --git a/docs/release-notes/version-4.1.md b/docs/release-notes/version-4.1.md index c6c260ee0..54bbf7ed9 100644 --- a/docs/release-notes/version-4.1.md +++ b/docs/release-notes/version-4.1.md @@ -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 diff --git a/mkdocs.yml b/mkdocs.yml index f90ef4dbe..f5819d0ea 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -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: diff --git a/netbox/account/urls.py b/netbox/account/urls.py index 1276dce40..d74677599 100644 --- a/netbox/account/urls.py +++ b/netbox/account/urls.py @@ -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'), diff --git a/netbox/account/views.py b/netbox/account/views.py index 5220c6fe8..a36d3380a 100644 --- a/netbox/account/views.py +++ b/netbox/account/views.py @@ -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 # diff --git a/netbox/circuits/choices.py b/netbox/circuits/choices.py index 5d0065edc..e2d345581 100644 --- a/netbox/circuits/choices.py +++ b/netbox/circuits/choices.py @@ -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)'), ] diff --git a/netbox/circuits/forms/bulk_import.py b/netbox/circuits/forms/bulk_import.py index 1ceb44b60..88fdd2c71 100644 --- a/netbox/circuits/forms/bulk_import.py +++ b/netbox/circuits/forms/bulk_import.py @@ -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:') + ' 00ff00'), - } class CircuitImportForm(NetBoxModelImportForm): diff --git a/netbox/core/apps.py b/netbox/core/apps.py index b1103469c..855ac3170 100644 --- a/netbox/core/apps.py +++ b/netbox/core/apps.py @@ -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()) diff --git a/netbox/core/events.py b/netbox/core/events.py new file mode 100644 index 000000000..60c9a34a0 --- /dev/null +++ b/netbox/core/events.py @@ -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() diff --git a/netbox/core/models/jobs.py b/netbox/core/models/jobs.py index b9f0d0b91..c5fbb918c 100644 --- a/netbox/core/models/jobs.py +++ b/netbox/core/models/jobs.py @@ -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 diff --git a/netbox/core/views.py b/netbox/core/views.py index a976c1ec6..508401585 100644 --- a/netbox/core/views.py +++ b/netbox/core/views.py @@ -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: diff --git a/netbox/dcim/api/serializers_/racks.py b/netbox/dcim/api/serializers_/racks.py index d8d738001..4fb96e08c 100644 --- a/netbox/dcim/api/serializers_/racks.py +++ b/netbox/dcim/api/serializers_/racks.py @@ -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') diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index 36a0c99a5..d099b392a 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -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) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index be7a9c306..87aa7535c 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -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 # diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index fe8d8a158..464c396ff 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -51,7 +51,7 @@ class LocationStatusChoices(ChoiceSet): # Racks # -class RackTypeChoices(ChoiceSet): +class RackFormFactorChoices(ChoiceSet): TYPE_2POST = '2-post-frame' TYPE_4POST = '4-post-frame' diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index a4d75654e..7159efb40 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -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(), diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 25b049e6d..4a017dd46 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -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')), ) diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 5a64cad02..7f7efecf6 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -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:') + ' 00ff00'), - } + + +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:') + ' 00ff00'), - } 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:') + ' 00ff00'), - } # @@ -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:') + ' 00ff00'), - } def _clean_side(self, side): """ diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 0a28a4ec4..5d16a7b39 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -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) diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index d493687f9..bff0d8c46 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -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( diff --git a/netbox/dcim/graphql/filters.py b/netbox/dcim/graphql/filters.py index 2ae5e7771..8c256aecb 100644 --- a/netbox/dcim/graphql/filters.py +++ b/netbox/dcim/graphql/filters.py @@ -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): diff --git a/netbox/dcim/graphql/schema.py b/netbox/dcim/graphql/schema.py index c3962a87a..713a63778 100644 --- a/netbox/dcim/graphql/schema.py +++ b/netbox/dcim/graphql/schema.py @@ -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) diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index 8b4613f14..d2bf4b416 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -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')]] diff --git a/netbox/dcim/migrations/0188_racktype.py b/netbox/dcim/migrations/0188_racktype.py new file mode 100644 index 000000000..18e4152b6 --- /dev/null +++ b/netbox/dcim/migrations/0188_racktype.py @@ -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', + ), + ), + ] diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index 289c38133..e6487c705 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -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): """ diff --git a/netbox/dcim/search.py b/netbox/dcim/search.py index b349bcac0..32add68d0 100644 --- a/netbox/dcim/search.py +++ b/netbox/dcim/search.py @@ -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 diff --git a/netbox/dcim/tables/racks.py b/netbox/dcim/tables/racks.py index 22ca3da90..d269681c5 100644 --- a/netbox/dcim/tables/racks.py +++ b/netbox/dcim/tables/racks.py @@ -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', diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 52b850b24..8d14c077f 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -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'] diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index df0dc7c7e..12b14ee02 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -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) diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index 9056a66c0..229edc0de 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -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 diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index ec85fc1d5..04281b9fd 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -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, diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index c71a0aff1..627136bf9 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -63,6 +63,14 @@ urlpatterns = [ path('racks/delete/', views.RackBulkDeleteView.as_view(), name='rack_bulk_delete'), path('racks//', 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//', 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'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 87f351e4d..5ff20f35e 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -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(), diff --git a/netbox/extras/api/serializers.py b/netbox/extras/api/serializers.py index ddd13815a..f1b0e0894 100644 --- a/netbox/extras/api/serializers.py +++ b/netbox/extras/api/serializers.py @@ -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 * diff --git a/netbox/extras/api/serializers_/notifications.py b/netbox/extras/api/serializers_/notifications.py new file mode 100644 index 000000000..62e1a8d63 --- /dev/null +++ b/netbox/extras/api/serializers_/notifications.py @@ -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 diff --git a/netbox/extras/api/urls.py b/netbox/extras/api/urls.py index bc68103b7..bbcb8f0ef 100644 --- a/netbox/extras/api/urls.py +++ b/netbox/extras/api/urls.py @@ -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) diff --git a/netbox/extras/api/views.py b/netbox/extras/api/views.py index 34565384b..2369e8f10 100644 --- a/netbox/extras/api/views.py +++ b/netbox/extras/api/views.py @@ -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 # diff --git a/netbox/extras/choices.py b/netbox/extras/choices.py index 8959ba0ab..387716c85 100644 --- a/netbox/extras/choices.py +++ b/netbox/extras/choices.py @@ -302,8 +302,10 @@ class EventRuleActionChoices(ChoiceSet): WEBHOOK = 'webhook' SCRIPT = 'script' + NOTIFICATION = 'notification' CHOICES = ( (WEBHOOK, _('Webhook')), (SCRIPT, _('Script')), + (NOTIFICATION, _('Notification')), ) diff --git a/netbox/extras/constants.py b/netbox/extras/constants.py index 005f6863d..e8e2c6d8a 100644 --- a/netbox/extras/constants.py +++ b/netbox/extras/constants.py @@ -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, +} diff --git a/netbox/extras/dashboard/widgets.py b/netbox/extras/dashboard/widgets.py index 5327421ca..254a03ab5 100644 --- a/netbox/extras/dashboard/widgets.py +++ b/netbox/extras/dashboard/widgets.py @@ -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] diff --git a/netbox/extras/events.py b/netbox/extras/events.py index dae3f29cf..9cf3220d0 100644 --- a/netbox/extras/events.py +++ b/netbox/extras/events.py @@ -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'] ) diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index bf0275c2d..f34270f07 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -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', diff --git a/netbox/extras/forms/bulk_edit.py b/netbox/extras/forms/bulk_edit.py index acb564b30..f785eaaf4 100644 --- a/netbox/extras/forms/bulk_edit.py +++ b/netbox/extras/forms/bulk_edit.py @@ -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',) diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py index cf022ba0e..2ebba365a 100644 --- a/netbox/extras/forms/bulk_import.py +++ b/netbox/extras/forms/bulk_import.py @@ -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:') + ' 00ff00'), - } 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') diff --git a/netbox/extras/forms/filtersets.py b/netbox/extras/forms/filtersets.py index e29fd549d..a446af632 100644 --- a/netbox/extras/forms/filtersets.py +++ b/netbox/extras/forms/filtersets.py @@ -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') + ) diff --git a/netbox/extras/forms/model_forms.py b/netbox/extras/forms/model_forms.py index 19823e9a4..a8406b671 100644 --- a/netbox/extras/forms/model_forms.py +++ b/netbox/extras/forms/model_forms.py @@ -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 diff --git a/netbox/extras/graphql/filters.py b/netbox/extras/graphql/filters.py index 7451eef8a..ff2e6a0f1 100644 --- a/netbox/extras/graphql/filters.py +++ b/netbox/extras/graphql/filters.py @@ -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): diff --git a/netbox/extras/graphql/schema.py b/netbox/extras/graphql/schema.py index f78285035..7e509c0e0 100644 --- a/netbox/extras/graphql/schema.py +++ b/netbox/extras/graphql/schema.py @@ -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) diff --git a/netbox/extras/graphql/types.py b/netbox/extras/graphql/types.py index 1f3bfcdb9..a43f80cc3 100644 --- a/netbox/extras/graphql/types.py +++ b/netbox/extras/graphql/types.py @@ -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', ], diff --git a/netbox/extras/management/commands/reindex.py b/netbox/extras/management/commands/reindex.py index e20fad0ce..5aab74511 100644 --- a/netbox/extras/management/commands/reindex.py +++ b/netbox/extras/management/commands/reindex.py @@ -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 diff --git a/netbox/extras/migrations/0118_notifications.py b/netbox/extras/migrations/0118_notifications.py new file mode 100644 index 000000000..08904ebb5 --- /dev/null +++ b/netbox/extras/migrations/0118_notifications.py @@ -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'), + ), + ] diff --git a/netbox/extras/models/__init__.py b/netbox/extras/models/__init__.py index 0413d1b91..e85721034 100644 --- a/netbox/extras/models/__init__.py +++ b/netbox/extras/models/__init__.py @@ -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 * diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index cee69d16b..1d84a3f4f 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -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).") diff --git a/netbox/extras/models/notifications.py b/netbox/extras/models/notifications.py new file mode 100644 index 000000000..dba059ea7 --- /dev/null +++ b/netbox/extras/models/notifications.py @@ -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) + ) diff --git a/netbox/extras/querysets.py b/netbox/extras/querysets.py index 3ee9d73e8..9b3722eef 100644 --- a/netbox/extras/querysets.py +++ b/netbox/extras/querysets.py @@ -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) diff --git a/netbox/extras/signals.py b/netbox/extras/signals.py index 53ec39cac..6f63e121e 100644 --- a/netbox/extras/signals.py +++ b/netbox/extras/signals.py @@ -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 + ]) diff --git a/netbox/extras/tables/columns.py b/netbox/extras/tables/columns.py new file mode 100644 index 000000000..9b6aadcbf --- /dev/null +++ b/netbox/extras/tables/columns.py @@ -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'), + } diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py index d919ff1d5..db4472313 100644 --- a/netbox/extras/tables/tables.py +++ b/netbox/extras/tables/tables.py @@ -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 %} {{ record }} {% else %} — {% endif %} -''' +""" + +NOTIFICATION_ICON = """ + +""" 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'), diff --git a/netbox/extras/tests/test_api.py b/netbox/extras/tests/test_api.py index 5d243ae1a..a1c75ac28 100644 --- a/netbox/extras/tests/test_api.py +++ b/netbox/extras/tests/test_api.py @@ -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, + }, + ] diff --git a/netbox/extras/tests/test_event_rules.py b/netbox/extras/tests/test_event_rules.py index 39b896616..ac36ef1d9 100644 --- a/netbox/extras/tests/test_event_rules.py +++ b/netbox/extras/tests/test_event_rules.py @@ -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() diff --git a/netbox/extras/tests/test_filtersets.py b/netbox/extras/tests/test_filtersets.py index 5c737f7cf..85ebfc579 100644 --- a/netbox/extras/tests/test_filtersets.py +++ b/netbox/extras/tests/test_filtersets.py @@ -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) diff --git a/netbox/extras/tests/test_views.py b/netbox/extras/tests/test_views.py index cbede195b..552c0f57a 100644 --- a/netbox/extras/tests/test_views.py +++ b/netbox/extras/tests/test_views.py @@ -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 diff --git a/netbox/extras/urls.py b/netbox/extras/urls.py index f2e11e71e..6d515cf5f 100644 --- a/netbox/extras/urls.py +++ b/netbox/extras/urls.py @@ -53,6 +53,24 @@ urlpatterns = [ path('bookmarks/delete/', views.BookmarkBulkDeleteView.as_view(), name='bookmark_bulk_delete'), path('bookmarks//', 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//', 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//', 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//', 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'), diff --git a/netbox/extras/views.py b/netbox/extras/views.py index c902b1499..d3e346feb 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -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) diff --git a/netbox/extras/webhooks.py b/netbox/extras/webhooks.py index 53ec161d7..889c97ac2 100644 --- a/netbox/extras/webhooks.py +++ b/netbox/extras/webhooks.py @@ -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, diff --git a/netbox/ipam/api/serializers_/vlans.py b/netbox/ipam/api/serializers_/vlans.py index 5525545a8..608fcf0b4 100644 --- a/netbox/ipam/api/serializers_/vlans.py +++ b/netbox/ipam/api/serializers_/vlans.py @@ -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 = [] diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 5cdfac34e..30634850a 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -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( diff --git a/netbox/ipam/forms/bulk_edit.py b/netbox/ipam/forms/bulk_edit.py index c7f64ab1d..2f59c564f 100644 --- a/netbox/ipam/forms/bulk_edit.py +++ b/netbox/ipam/forms/bulk_edit.py @@ -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') ), diff --git a/netbox/ipam/forms/bulk_import.py b/netbox/ipam/forms/bulk_import.py index bfff1f4f4..dea250c79 100644 --- a/netbox/ipam/forms/bulk_import.py +++ b/netbox/ipam/forms/bulk_import.py @@ -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', } diff --git a/netbox/ipam/forms/filtersets.py b/netbox/ipam/forms/filtersets.py index 80fb04226..a32694321 100644 --- a/netbox/ipam/forms/filtersets.py +++ b/netbox/ipam/forms/filtersets.py @@ -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) diff --git a/netbox/ipam/forms/model_forms.py b/netbox/ipam/forms/model_forms.py index 4e405a035..e6060d1af 100644 --- a/netbox/ipam/forms/model_forms.py +++ b/netbox/ipam/forms/model_forms.py @@ -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): diff --git a/netbox/ipam/graphql/types.py b/netbox/ipam/graphql/types.py index 36e09eaac..46d45816e 100644 --- a/netbox/ipam/graphql/types.py +++ b/netbox/ipam/graphql/types.py @@ -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[ diff --git a/netbox/ipam/migrations/0070_vlangroup_vlan_id_ranges.py b/netbox/ipam/migrations/0070_vlangroup_vlan_id_ranges.py new file mode 100644 index 000000000..b01941401 --- /dev/null +++ b/netbox/ipam/migrations/0070_vlangroup_vlan_id_ranges.py @@ -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', + ), + ] diff --git a/netbox/ipam/models/vlans.py b/netbox/ipam/models/vlans.py index 7434bd0b4..ca6b27d07 100644 --- a/netbox/ipam/models/vlans.py +++ b/netbox/ipam/models/vlans.py @@ -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) diff --git a/netbox/ipam/querysets.py b/netbox/ipam/querysets.py index a3f37fe3c..717c63a37 100644 --- a/netbox/ipam/querysets.py +++ b/netbox/ipam/querysets.py @@ -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) ) diff --git a/netbox/ipam/search.py b/netbox/ipam/search.py index a1cddbb1a..59b741b8f 100644 --- a/netbox/ipam/search.py +++ b/netbox/ipam/search.py @@ -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 diff --git a/netbox/ipam/tables/vlans.py b/netbox/ipam/tables/vlans.py index 11de0381c..1b428aeb6 100644 --- a/netbox/ipam/tables/vlans.py +++ b/netbox/ipam/tables/vlans.py @@ -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') diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index 2cf7a2f1c..00c240769 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -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 diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index 8f07a241a..e149c0a8d 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/netbox/ipam/tests/test_filtersets.py @@ -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) diff --git a/netbox/ipam/tests/test_models.py b/netbox/ipam/tests/test_models.py index d0f42e8a6..39eb33a4f 100644 --- a/netbox/ipam/tests/test_models.py +++ b/netbox/ipam/tests/test_models.py @@ -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() diff --git a/netbox/ipam/tests/test_views.py b/netbox/ipam/tests/test_views.py index bc42341ba..2acb80ac1 100644 --- a/netbox/ipam/tests/test_views.py +++ b/netbox/ipam/tests/test_views.py @@ -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], } diff --git a/netbox/ipam/utils.py b/netbox/ipam/utils.py index 21b90fbcd..ccf6cb632 100644 --- a/netbox/ipam/utils.py +++ b/netbox/ipam/utils.py @@ -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']) diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index 12c86c533..67d56f15e 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -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(), diff --git a/netbox/netbox/api/fields.py b/netbox/netbox/api/fields.py index 08ffd0bc4..e7d1ef574 100644 --- a/netbox/netbox/api/fields.py +++ b/netbox/netbox/api/fields.py @@ -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 diff --git a/netbox/netbox/events.py b/netbox/netbox/events.py new file mode 100644 index 000000000..15691aafb --- /dev/null +++ b/netbox/netbox/events.py @@ -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) diff --git a/netbox/netbox/forms/__init__.py b/netbox/netbox/forms/__init__.py index fa82689a5..f88fb18bc 100644 --- a/netbox/netbox/forms/__init__.py +++ b/netbox/netbox/forms/__init__.py @@ -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): diff --git a/netbox/netbox/middleware.py b/netbox/netbox/middleware.py index 58c70451c..8012965a4 100644 --- a/netbox/netbox/middleware.py +++ b/netbox/netbox/middleware.py @@ -1,6 +1,5 @@ import logging import uuid -from urllib import parse from django.conf import settings from django.contrib import auth, messages @@ -33,20 +32,15 @@ class CoreMiddleware: # Assign a random unique ID to the request. This will be used for change logging. request.id = uuid.uuid4() - # Enforce the LOGIN_REQUIRED config parameter. If true, redirect all non-exempt unauthenticated requests - # to the login page. - if ( - settings.LOGIN_REQUIRED and - not request.user.is_authenticated and - not request.path_info.startswith(settings.AUTH_EXEMPT_PATHS) - ): - login_url = f'{settings.LOGIN_URL}?next={parse.quote(request.get_full_path_info())}' - return HttpResponseRedirect(login_url) - # Enable the event_tracking context manager and process the request. with event_tracking(request): response = self.get_response(request) + # Check if language cookie should be renewed + if request.user.is_authenticated and settings.SESSION_SAVE_EVERY_REQUEST: + if language := request.user.config.get('locale.language'): + response.set_cookie(settings.LANGUAGE_COOKIE_NAME, language, max_age=request.session.get_expiry_age()) + # Attach the unique request ID as an HTTP header. response['X-Request-ID'] = request.id diff --git a/netbox/netbox/models/__init__.py b/netbox/netbox/models/__init__.py index 2c262b258..4ba5f60da 100644 --- a/netbox/netbox/models/__init__.py +++ b/netbox/netbox/models/__init__.py @@ -29,6 +29,7 @@ class NetBoxFeatureSet( CustomValidationMixin, ExportTemplatesMixin, JournalingMixin, + NotificationsMixin, TagsMixin, EventRulesMixin ): diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index 0393bf25d..b270382d3 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -34,6 +34,7 @@ __all__ = ( 'ImageAttachmentsMixin', 'JobsMixin', 'JournalingMixin', + 'NotificationsMixin', 'SyncedDataMixin', 'TagsMixin', 'register_models', @@ -377,6 +378,25 @@ class BookmarksMixin(models.Model): abstract = True +class NotificationsMixin(models.Model): + """ + Enables support for user notifications. + """ + notifications = GenericRelation( + to='extras.Notification', + content_type_field='object_type', + object_id_field='object_id' + ) + subscriptions = GenericRelation( + to='extras.Subscription', + content_type_field='object_type', + object_id_field='object_id' + ) + + class Meta: + abstract = True + + class JobsMixin(models.Model): """ Enables support for job results. @@ -582,13 +602,14 @@ FEATURES_MAP = { 'custom_fields': CustomFieldsMixin, 'custom_links': CustomLinksMixin, 'custom_validation': CustomValidationMixin, + 'event_rules': EventRulesMixin, 'export_templates': ExportTemplatesMixin, 'image_attachments': ImageAttachmentsMixin, 'jobs': JobsMixin, 'journaling': JournalingMixin, + 'notifications': NotificationsMixin, 'synced_data': SyncedDataMixin, 'tags': TagsMixin, - 'event_rules': EventRulesMixin, } registry['model_features'].update({ diff --git a/netbox/netbox/navigation/menu.py b/netbox/netbox/navigation/menu.py index 6db7ac14c..b96465c35 100644 --- a/netbox/netbox/navigation/menu.py +++ b/netbox/netbox/navigation/menu.py @@ -24,6 +24,7 @@ ORGANIZATION_MENU = Menu( label=_('Racks'), items=( get_model_item('dcim', 'rack', _('Racks')), + get_model_item('dcim', 'racktype', _('Rack Types')), get_model_item('dcim', 'rackrole', _('Rack Roles')), get_model_item('dcim', 'rackreservation', _('Reservations')), MenuItem( @@ -355,6 +356,7 @@ OPERATIONS_MENU = Menu( MenuGroup( label=_('Logging'), items=( + get_model_item('extras', 'notificationgroup', _('Notification Groups')), get_model_item('extras', 'journalentry', _('Journal Entries'), actions=['import']), get_model_item('core', 'objectchange', _('Change Log'), actions=[]), ), @@ -462,16 +464,13 @@ MENUS = [ PROVISIONING_MENU, CUSTOMIZATION_MENU, OPERATIONS_MENU, - ADMIN_MENU, ] -# -# Add plugin menus -# - +# Add top-level plugin menus for menu in registry['plugins']['menus']: MENUS.append(menu) +# Add the default "plugins" menu if registry['plugins']['menu_items']: # Build the default plugins menu @@ -485,3 +484,6 @@ if registry['plugins']['menu_items']: groups=groups ) MENUS.append(plugins_menu) + +# Add the admin menu last +MENUS.append(ADMIN_MENU) diff --git a/netbox/netbox/plugins/registration.py b/netbox/netbox/plugins/registration.py index fbece12e5..c84572794 100644 --- a/netbox/netbox/plugins/registration.py +++ b/netbox/netbox/plugins/registration.py @@ -18,8 +18,8 @@ def register_template_extensions(class_list): """ Register a list of PluginTemplateExtension classes """ - # Validation for template_extension in class_list: + # Validation if not inspect.isclass(template_extension): raise TypeError( _("PluginTemplateExtension class {template_extension} was passed as an instance!").format( @@ -33,7 +33,17 @@ def register_template_extensions(class_list): ) ) - registry['plugins']['template_extensions'][template_extension.model].append(template_extension) + if template_extension.models: + # Registration for multiple models + models = template_extension.models + elif template_extension.model: + # Registration for a single model + models = [template_extension.model] + else: + # Global registration (no specific models) + models = [None] + for model in models: + registry['plugins']['template_extensions'][model].append(template_extension) def register_menu(menu): diff --git a/netbox/netbox/plugins/templates.py b/netbox/netbox/plugins/templates.py index ccd549160..e1f4b7a47 100644 --- a/netbox/netbox/plugins/templates.py +++ b/netbox/netbox/plugins/templates.py @@ -20,6 +20,7 @@ class PluginTemplateExtension: * settings - Global NetBox settings * config - Plugin-specific configuration parameters """ + models = None model = None def __init__(self, context): @@ -37,6 +38,10 @@ class PluginTemplateExtension: return get_template(template_name).render({**self.context, **extra_context}) + # + # Global methods + # + def navbar(self): """ Content that will be rendered inside the top navigation menu. Content should be returned as an HTML @@ -44,6 +49,37 @@ class PluginTemplateExtension: """ raise NotImplementedError + # + # Object list views + # + + def list_buttons(self): + """ + Buttons that will be rendered and added to the existing list of buttons on the list view. Content + should be returned as an HTML string. Note that content does not need to be marked as safe because this is + automatically handled. + """ + raise NotImplementedError + + # + # Object detail views + # + + def buttons(self): + """ + Buttons that will be rendered and added to the existing list of buttons on the detail page view. Content + should be returned as an HTML string. Note that content does not need to be marked as safe because this is + automatically handled. + """ + raise NotImplementedError + + def alerts(self): + """ + Arbitrary content to be inserted at the top of an object's detail view. Content should be returned as an + HTML string. Note that content does not need to be marked as safe because this is automatically handled. + """ + raise NotImplementedError + def left_page(self): """ Content that will be rendered on the left of the detail page view. Content should be returned as an @@ -64,19 +100,3 @@ class PluginTemplateExtension: HTML string. Note that content does not need to be marked as safe because this is automatically handled. """ raise NotImplementedError - - def buttons(self): - """ - Buttons that will be rendered and added to the existing list of buttons on the detail page view. Content - should be returned as an HTML string. Note that content does not need to be marked as safe because this is - automatically handled. - """ - raise NotImplementedError - - def list_buttons(self): - """ - Buttons that will be rendered and added to the existing list of buttons on the list view. Content - should be returned as an HTML string. Note that content does not need to be marked as safe because this is - automatically handled. - """ - raise NotImplementedError diff --git a/netbox/netbox/registry.py b/netbox/netbox/registry.py index d783647ec..44cdfb92b 100644 --- a/netbox/netbox/registry.py +++ b/netbox/netbox/registry.py @@ -25,6 +25,7 @@ registry = Registry({ 'counter_fields': collections.defaultdict(dict), 'data_backends': dict(), 'denormalized_fields': collections.defaultdict(list), + 'events': dict(), 'model_features': dict(), 'models': collections.defaultdict(set), 'plugins': dict(), diff --git a/netbox/netbox/search/backends.py b/netbox/netbox/search/backends.py index 227a79205..12243e9b6 100644 --- a/netbox/netbox/search/backends.py +++ b/netbox/netbox/search/backends.py @@ -8,6 +8,7 @@ from django.db.models.fields.related import ForeignKey from django.db.models.functions import window from django.db.models.signals import post_delete, post_save from django.utils.module_loading import import_string +from django.utils.translation import gettext_lazy as _ import netaddr from netaddr.core import AddrFormatError @@ -39,7 +40,7 @@ class SearchBackend: # Organize choices by category categories = defaultdict(dict) for label, idx in registry['search'].items(): - categories[idx.get_category()][label] = title(idx.model._meta.verbose_name) + categories[idx.get_category()][label] = _(title(idx.model._meta.verbose_name)) # Compile a nested tuple of choices for form rendering results = ( diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 7c586e109..64fb24f09 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -84,6 +84,16 @@ DEFAULT_PERMISSIONS = getattr(configuration, 'DEFAULT_PERMISSIONS', { 'extras.add_bookmark': ({'user': '$user'},), 'extras.change_bookmark': ({'user': '$user'},), 'extras.delete_bookmark': ({'user': '$user'},), + # Permit users to manage their own notifications + 'extras.view_notification': ({'user': '$user'},), + 'extras.add_notification': ({'user': '$user'},), + 'extras.change_notification': ({'user': '$user'},), + 'extras.delete_notification': ({'user': '$user'},), + # Permit users to manage their own subscriptions + 'extras.view_subscription': ({'user': '$user'},), + 'extras.add_subscription': ({'user': '$user'},), + 'extras.change_subscription': ({'user': '$user'},), + 'extras.delete_subscription': ({'user': '$user'},), # Permit users to manage their own API tokens 'users.view_token': ({'user': '$user'},), 'users.add_token': ({'user': '$user'},), @@ -149,6 +159,7 @@ SECURE_SSL_REDIRECT = getattr(configuration, 'SECURE_SSL_REDIRECT', False) SENTRY_DSN = getattr(configuration, 'SENTRY_DSN', None) SENTRY_ENABLED = getattr(configuration, 'SENTRY_ENABLED', False) SENTRY_SAMPLE_RATE = getattr(configuration, 'SENTRY_SAMPLE_RATE', 1.0) +SENTRY_SEND_DEFAULT_PII = getattr(configuration, 'SENTRY_SEND_DEFAULT_PII', False) SENTRY_TAGS = getattr(configuration, 'SENTRY_TAGS', {}) SENTRY_TRACES_SAMPLE_RATE = getattr(configuration, 'SENTRY_TRACES_SAMPLE_RATE', 0) SESSION_COOKIE_NAME = getattr(configuration, 'SESSION_COOKIE_NAME', 'sessionid') @@ -227,6 +238,23 @@ if STORAGE_BACKEND is not None: return globals().get(name, default) storages.utils.setting = _setting + # django-storage-swift + elif STORAGE_BACKEND == 'swift.storage.SwiftStorage': + try: + import swift.utils # type: ignore + except ModuleNotFoundError as e: + if getattr(e, 'name') == 'swift': + raise ImproperlyConfigured( + f"STORAGE_BACKEND is set to {STORAGE_BACKEND} but django-storage-swift is not present. " + "It can be installed by running 'pip install django-storage-swift'." + ) + raise e + + # Load all SWIFT_* settings from the user configuration + for param, value in STORAGE_CONFIG.items(): + if param.startswith('SWIFT_'): + globals()[param] = value + if STORAGE_CONFIG and STORAGE_BACKEND is None: warnings.warn( "STORAGE_CONFIG has been set in configuration.py but STORAGE_BACKEND is not defined. STORAGE_CONFIG will be " @@ -502,15 +530,6 @@ EXEMPT_EXCLUDE_MODELS = ( ('users', 'user'), ) -# All URLs starting with a string listed here are exempt from login enforcement -AUTH_EXEMPT_PATHS = ( - f'/{BASE_PATH}api/', - f'/{BASE_PATH}graphql/', - f'/{BASE_PATH}login/', - f'/{BASE_PATH}oauth/', - f'/{BASE_PATH}metrics', -) - # All URLs starting with a string listed here are exempt from maintenance mode enforcement MAINTENANCE_EXEMPT_PATHS = ( f'/{BASE_PATH}admin/', @@ -538,7 +557,7 @@ if SENTRY_ENABLED: release=RELEASE.full_version, sample_rate=SENTRY_SAMPLE_RATE, traces_sample_rate=SENTRY_TRACES_SAMPLE_RATE, - send_default_pii=True, + send_default_pii=SENTRY_SEND_DEFAULT_PII, http_proxy=HTTP_PROXIES.get('http') if HTTP_PROXIES else None, https_proxy=HTTP_PROXIES.get('https') if HTTP_PROXIES else None ) diff --git a/netbox/netbox/tests/dummy_plugin/template_content.py b/netbox/netbox/tests/dummy_plugin/template_content.py index 764faa60e..e9a6b9da1 100644 --- a/netbox/netbox/tests/dummy_plugin/template_content.py +++ b/netbox/netbox/tests/dummy_plugin/template_content.py @@ -8,7 +8,13 @@ class GlobalContent(PluginTemplateExtension): class SiteContent(PluginTemplateExtension): - model = 'dcim.site' + models = ['dcim.site'] + + def buttons(self): + return "SITE CONTENT - BUTTONS" + + def alerts(self): + return "SITE CONTENT - ALERTS" def left_page(self): return "SITE CONTENT - LEFT PAGE" @@ -19,9 +25,6 @@ class SiteContent(PluginTemplateExtension): def full_width_page(self): return "SITE CONTENT - FULL WIDTH PAGE" - def buttons(self): - return "SITE CONTENT - BUTTONS" - def list_buttons(self): return "SITE CONTENT - LIST BUTTONS" diff --git a/netbox/netbox/views/generic/bulk_views.py b/netbox/netbox/views/generic/bulk_views.py index 87e352710..71ce411ba 100644 --- a/netbox/netbox/views/generic/bulk_views.py +++ b/netbox/netbox/views/generic/bulk_views.py @@ -176,7 +176,7 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin): 'model': model, 'table': table, 'actions': actions, - 'filter_form': self.filterset_form(request.GET, label_suffix='') if self.filterset_form else None, + 'filter_form': self.filterset_form(request.GET) if self.filterset_form else None, 'prerequisite_model': get_prerequisite_model(self.queryset), **self.get_extra_context(request), } diff --git a/netbox/netbox/views/generic/feature_views.py b/netbox/netbox/views/generic/feature_views.py index 9d898be2f..821d87e17 100644 --- a/netbox/netbox/views/generic/feature_views.py +++ b/netbox/netbox/views/generic/feature_views.py @@ -1,3 +1,4 @@ +from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.contenttypes.models import ContentType from django.contrib import messages from django.db import transaction @@ -12,7 +13,7 @@ from extras.forms import JournalEntryForm from extras.models import JournalEntry from extras.tables import JournalEntryTable from utilities.permissions import get_permission_for_model -from utilities.views import GetReturnURLMixin, ViewTab +from utilities.views import ConditionalLoginRequiredMixin, GetReturnURLMixin, ViewTab from .base import BaseMultiObjectView __all__ = ( @@ -24,7 +25,7 @@ __all__ = ( ) -class ObjectChangeLogView(View): +class ObjectChangeLogView(ConditionalLoginRequiredMixin, View): """ Present a history of changes made to a particular object. The model class must be passed as a keyword argument when referencing this view in a URL path. For example: @@ -77,7 +78,7 @@ class ObjectChangeLogView(View): }) -class ObjectJournalView(View): +class ObjectJournalView(ConditionalLoginRequiredMixin, View): """ Show all journal entries for an object. The model class must be passed as a keyword argument when referencing this view in a URL path. For example: @@ -138,7 +139,7 @@ class ObjectJournalView(View): }) -class ObjectJobsView(View): +class ObjectJobsView(ConditionalLoginRequiredMixin, View): """ Render a list of all Job assigned to an object. For example: @@ -191,7 +192,7 @@ class ObjectJobsView(View): }) -class ObjectSyncDataView(View): +class ObjectSyncDataView(LoginRequiredMixin, View): def post(self, request, model, **kwargs): """ diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index 243ae2547..cad7facd3 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -87,12 +87,14 @@ class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin): child_model: The model class which represents the child objects table: The django-tables2 Table class used to render the child objects list filterset: A django-filter FilterSet that is applied to the queryset + filterset_form: The form class used to render filter options actions: A mapping of supported actions to their required permissions. When adding custom actions, bulk action names must be prefixed with `bulk_`. (See ActionsMixin.) """ child_model = None table = None filterset = None + filterset_form = None template_name = 'generic/object_children.html' def get_children(self, request, parent): @@ -152,6 +154,7 @@ class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin): 'base_template': f'{instance._meta.app_label}/{instance._meta.model_name}.html', 'table': table, 'table_config': f'{table.name}_config', + 'filter_form': self.filterset_form(request.GET) if self.filterset_form else None, 'actions': actions, 'tab': self.tab, 'return_url': request.get_full_path(), diff --git a/netbox/netbox/views/htmx.py b/netbox/netbox/views/htmx.py index 04ddcb06b..b7894e36c 100644 --- a/netbox/netbox/views/htmx.py +++ b/netbox/netbox/views/htmx.py @@ -1,3 +1,4 @@ +from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist from django.http import Http404 @@ -6,7 +7,7 @@ from django.utils.module_loading import import_string from django.views.generic import View -class ObjectSelectorView(View): +class ObjectSelectorView(LoginRequiredMixin, View): template_name = 'htmx/object_selector.html' def get(self, request): diff --git a/netbox/netbox/views/misc.py b/netbox/netbox/views/misc.py index 569fcf728..c584e99e4 100644 --- a/netbox/netbox/views/misc.py +++ b/netbox/netbox/views/misc.py @@ -19,6 +19,7 @@ from netbox.search.backends import search_backend from netbox.tables import SearchTable from utilities.htmx import htmx_partial from utilities.paginator import EnhancedPaginator, get_paginate_count +from utilities.views import ConditionalLoginRequiredMixin __all__ = ( 'HomeView', @@ -28,7 +29,7 @@ __all__ = ( Link = namedtuple('Link', ('label', 'viewname', 'permission', 'count')) -class HomeView(View): +class HomeView(ConditionalLoginRequiredMixin, View): template_name = 'home.html' def get(self, request): @@ -62,7 +63,7 @@ class HomeView(View): }) -class SearchView(View): +class SearchView(ConditionalLoginRequiredMixin, View): def get(self, request): results = [] diff --git a/netbox/project-static/dist/netbox.css b/netbox/project-static/dist/netbox.css index 9e5931960..bd2bd1134 100644 Binary files a/netbox/project-static/dist/netbox.css and b/netbox/project-static/dist/netbox.css differ diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index 27db4718b..5624e7298 100644 Binary files a/netbox/project-static/dist/netbox.js and b/netbox/project-static/dist/netbox.js differ diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map index d54ded87b..af923dd4f 100644 Binary files a/netbox/project-static/dist/netbox.js.map and b/netbox/project-static/dist/netbox.js.map differ diff --git a/netbox/project-static/package.json b/netbox/project-static/package.json index 482e78598..9cb819f84 100644 --- a/netbox/project-static/package.json +++ b/netbox/project-static/package.json @@ -28,7 +28,7 @@ "bootstrap": "5.3.3", "clipboard": "2.0.11", "flatpickr": "4.6.13", - "gridstack": "10.2.1", + "gridstack": "10.3.0", "htmx.org": "1.9.12", "query-string": "9.0.0", "sass": "1.77.6", diff --git a/netbox/project-static/src/select/classes/dynamicTomSelect.ts b/netbox/project-static/src/select/classes/dynamicTomSelect.ts index 758462b60..72c9fe518 100644 --- a/netbox/project-static/src/select/classes/dynamicTomSelect.ts +++ b/netbox/project-static/src/select/classes/dynamicTomSelect.ts @@ -74,20 +74,25 @@ export class DynamicTomSelect extends TomSelect { load(value: string) { const self = this; - const url = self.getRequestUrl(value); // Automatically clear any cached options. (Only options included // in the API response should be present.) self.clearOptions(); - addClasses(self.wrapper, self.settings.loadingClass); - self.loading++; - // Populate the null option (if any) if not searching if (self.nullOption && !value) { self.addOption(self.nullOption); } + // Get the API request URL. If none is provided, abort as no request can be made. + const url = self.getRequestUrl(value); + if (!url) { + return; + } + + addClasses(self.wrapper, self.settings.loadingClass); + self.loading++; + // Make the API request fetch(url) .then(response => response.json()) @@ -129,6 +134,9 @@ export class DynamicTomSelect extends TomSelect { for (const result of this.api_url.matchAll(new RegExp(`({{${key}}})`, 'g'))) { if (value) { url = replaceAll(url, result[1], value.toString()); + } else { + // No value is available to replace the token; abort. + return ''; } } } diff --git a/netbox/project-static/styles/custom/_notifications.scss b/netbox/project-static/styles/custom/_notifications.scss new file mode 100644 index 000000000..4777362aa --- /dev/null +++ b/netbox/project-static/styles/custom/_notifications.scss @@ -0,0 +1,9 @@ +@use 'sass:map'; + +// Mute read notifications +tr[data-read=True] { + td { + background-color: var(--#{$prefix}bg-surface-secondary); + color: $text-muted; + } +} diff --git a/netbox/project-static/styles/netbox.scss b/netbox/project-static/styles/netbox.scss index 46d8da9aa..17cadf21c 100644 --- a/netbox/project-static/styles/netbox.scss +++ b/netbox/project-static/styles/netbox.scss @@ -25,3 +25,4 @@ @import 'custom/interfaces'; @import 'custom/markdown'; @import 'custom/misc'; +@import 'custom/notifications'; diff --git a/netbox/project-static/yarn.lock b/netbox/project-static/yarn.lock index 4f126692b..ac1759cb4 100644 --- a/netbox/project-static/yarn.lock +++ b/netbox/project-static/yarn.lock @@ -1759,10 +1759,10 @@ graphql@16.8.1: resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.8.1.tgz#1930a965bef1170603702acdb68aedd3f3cf6f07" integrity sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw== -gridstack@10.2.1: - version "10.2.1" - resolved "https://registry.yarnpkg.com/gridstack/-/gridstack-10.2.1.tgz#3ce6119ae86cfb0a533c5f0d15b03777a55384ca" - integrity sha512-UAPKnIvd9sIqPDFMtKMqj0G5GDj8MUFPcelRJq7FzQFSxSYBblKts/Gd52iEJg0EvTFP51t6ZuMWGx0pSSFBdw== +gridstack@10.3.0: + version "10.3.0" + resolved "https://registry.yarnpkg.com/gridstack/-/gridstack-10.3.0.tgz#8fa065f896d0a880c5c54c24d189f3197184488a" + integrity sha512-eGKsmU2TppV4coyDu9IIdIkm4qjgLLdjlEOFwQyQMuSwfOpzSfLdPc8du0HuebGr7CvAIrJxN4lBOmGrWSBg9g== has-bigints@^1.0.1, has-bigints@^1.0.2: version "1.0.2" diff --git a/netbox/templates/account/base.html b/netbox/templates/account/base.html index b0e4e702a..01d288ea6 100644 --- a/netbox/templates/account/base.html +++ b/netbox/templates/account/base.html @@ -9,6 +9,12 @@ + + diff --git a/netbox/templates/account/notifications.html b/netbox/templates/account/notifications.html new file mode 100644 index 000000000..5a471ef25 --- /dev/null +++ b/netbox/templates/account/notifications.html @@ -0,0 +1,32 @@ +{% extends 'account/base.html' %} +{% load buttons %} +{% load helpers %} +{% load render_table from django_tables2 %} +{% load i18n %} + +{% block title %}{% trans "Notifications" %}{% endblock %} + +{% block content %} +
+ {% csrf_token %} + + + {# Table #} +
+
+
+
+ {% include 'htmx/table.html' %} +
+
+
+
+ + {# Form buttons #} +
+ {% if 'bulk_delete' in actions %} + {% bulk_delete_button model query_params=request.GET %} + {% endif %} +
+
+{% endblock %} diff --git a/netbox/templates/account/subscriptions.html b/netbox/templates/account/subscriptions.html new file mode 100644 index 000000000..d97053d63 --- /dev/null +++ b/netbox/templates/account/subscriptions.html @@ -0,0 +1,32 @@ +{% extends 'account/base.html' %} +{% load buttons %} +{% load helpers %} +{% load render_table from django_tables2 %} +{% load i18n %} + +{% block title %}{% trans "Subscriptions" %}{% endblock %} + +{% block content %} +
+ {% csrf_token %} + + + {# Table #} +
+
+
+
+ {% include 'htmx/table.html' %} +
+
+
+
+ + {# Form buttons #} +
+ {% if 'bulk_delete' in actions %} + {% bulk_delete_button model query_params=request.GET %} + {% endif %} +
+
+{% endblock %} diff --git a/netbox/templates/base/layout.html b/netbox/templates/base/layout.html index d374b98cc..fc6c4d60d 100644 --- a/netbox/templates/base/layout.html +++ b/netbox/templates/base/layout.html @@ -117,6 +117,10 @@ Blocks:
+ {# Page alerts #} + {% block alerts %}{% endblock %} + {# /Page alerts #} + {# Page content #} {% block content %}{% endblock %} {# /Page content #} diff --git a/netbox/templates/core/system.html b/netbox/templates/core/system.html index 7109e54c9..8b6858054 100644 --- a/netbox/templates/core/system.html +++ b/netbox/templates/core/system.html @@ -93,9 +93,8 @@

{% trans "Current Configuration" %}

- {% include 'core/inc/config_data.html' with config=config.data %} + {% include 'core/inc/config_data.html' %}
-
{% endblock content %} diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 1b2488e56..19c3adc07 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -125,28 +125,30 @@
- - - - - + + + + + + + + {% for vc_member in vc_members %} - - - - - - + + + + + + {% endfor %} +
{% trans "Device" %}{% trans "Position" %}{% trans "Master" %}{% trans "Priority" %}
{% trans "Device" %}{% trans "Position" %}{% trans "Master" %}{% trans "Priority" %}
- {{ vc_member|linkify }} - - {% badge vc_member.vc_position show_empty=True %} - - {% if object.virtual_chassis.master == vc_member %}{% endif %} - - {{ vc_member.vc_priority|placeholder }} -
{{ vc_member|linkify }}{% badge vc_member.vc_position show_empty=True %} + {% if object.virtual_chassis.master == vc_member %} + {% checkmark True %} + {% else %} + {{ ''|placeholder }} + {% endif %} + {{ vc_member.vc_priority|placeholder }}
{% endif %} @@ -221,6 +223,11 @@ {% if object.oob_ip %} {{ object.oob_ip.address.ip }} + {% if object.oob_ip.nat_inside %} + ({% trans "NAT for" %} {{ object.oob_ip.nat_inside.address.ip }}) + {% elif object.oob_ip.nat_outside.exists %} + ({% trans "NAT" %}: {% for nat in object.oob_ip.nat_outside.all %}{{ nat.address.ip }}{% if not forloop.last %}, {% endif %}{% endfor %}) + {% endif %} {% copy_content "oob_ip" %} {% else %} {{ ''|placeholder }} diff --git a/netbox/templates/dcim/inc/panels/racktype_dimensions.html b/netbox/templates/dcim/inc/panels/racktype_dimensions.html new file mode 100644 index 000000000..03eab981b --- /dev/null +++ b/netbox/templates/dcim/inc/panels/racktype_dimensions.html @@ -0,0 +1,48 @@ +{% load i18n %} +
+

{% trans "Dimensions" %}

+ + + + + + + + + + + + + + + + + + + + + + + + + +
{% trans "Form factor" %}{{ object.get_form_factor_display|placeholder }}
{% trans "Width" %}{{ object.get_width_display }}
{% trans "Height" %}{{ object.u_height }}U
{% trans "Outer Width" %} + {% if object.outer_width %} + {{ object.outer_width }} {{ object.get_outer_unit_display }} + {% else %} + {{ ''|placeholder }} + {% endif %} +
{% trans "Outer Depth" %} + {% if object.outer_depth %} + {{ object.outer_depth }} {{ object.get_outer_unit_display }} + {% else %} + {{ ''|placeholder }} + {% endif %} +
{% trans "Mounting Depth" %} + {% if object.mounting_depth %} + {{ object.mounting_depth }} {% trans "Millimeters" %} + {% else %} + {{ ''|placeholder }} + {% endif %} +
+
diff --git a/netbox/templates/dcim/inc/panels/racktype_numbering.html b/netbox/templates/dcim/inc/panels/racktype_numbering.html new file mode 100644 index 000000000..c8259042e --- /dev/null +++ b/netbox/templates/dcim/inc/panels/racktype_numbering.html @@ -0,0 +1,14 @@ +{% load i18n %} +
+

{% trans "Numbering" %}

+ + + + + + + + + +
{% trans "Starting Unit" %}{{ object.starting_unit }}
{% trans "Descending Units" %}{% checkmark object.desc_units %}
+
diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index a472b838b..1c1b79fbe 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -9,157 +9,107 @@ {% block content %}
-
-

{% trans "Rack" %}

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
{% trans "Region" %} - {% nested_tree object.site.region %} -
{% trans "Site" %}{{ object.site|linkify }}
{% trans "Location" %}{% nested_tree object.location %}
{% trans "Facility ID" %}{{ object.facility_id|placeholder }}
{% trans "Tenant" %} - {% if object.tenant.group %} - {{ object.tenant.group|linkify }} / - {% endif %} - {{ object.tenant|linkify|placeholder }} -
{% trans "Status" %}{% badge object.get_status_display bg_color=object.get_status_color %}
{% trans "Role" %}{{ object.role|linkify|placeholder }}
{% trans "Description" %}{{ object.description|placeholder }}
{% trans "Serial Number" %}{{ object.serial|placeholder }}
{% trans "Asset Tag" %}{{ object.asset_tag|placeholder }}
{% trans "Space Utilization" %}{% utilization_graph object.get_utilization %}
{% trans "Power Utilization" %}{% utilization_graph object.get_power_utilization %}
-
-
-

{% trans "Dimensions" %}

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
{% trans "Type" %} - {% if object.type %} - {{ object.get_type_display }} - {% else %} - {{ ''|placeholder }} - {% endif %} -
{% trans "Width" %}{{ object.get_width_display }}
{% trans "Height" %}{{ object.u_height }}U ({% if object.desc_units %}{% trans "descending" %}{% else %}{% trans "ascending" %}{% endif %})
{% trans "Starting Unit" %} - {{ object.starting_unit }} -
{% trans "Outer Width" %} - {% if object.outer_width %} - {{ object.outer_width }} {{ object.get_outer_unit_display }} - {% else %} - {{ ''|placeholder }} - {% endif %} -
{% trans "Outer Depth" %} - {% if object.outer_depth %} - {{ object.outer_depth }} {{ object.get_outer_unit_display }} - {% else %} - {{ ''|placeholder }} - {% endif %} -
{% trans "Mounting Depth" %} - {% if object.mounting_depth %} - {{ object.mounting_depth }} {% trans "Millimeters" %} - {% else %} - {{ ''|placeholder }} - {% endif %} -
{% trans "Rack Weight" %} - {% if object.weight %} - {{ object.weight|floatformat }} {{ object.get_weight_unit_display }} - {% else %} - {{ ''|placeholder }} - {% endif %} -
{% trans "Maximum Weight" %} - {% if object.max_weight %} - {{ object.max_weight }} {{ object.get_weight_unit_display }} - {% else %} - {{ ''|placeholder }} - {% endif %} -
{% trans "Total Weight" %} - {{ object.total_weight|floatformat }} {% trans "Kilograms" %} - ({{ object.total_weight|kg_to_pounds|floatformat }} {% trans "Pounds" %}) -
-
- {% include 'inc/panels/custom_fields.html' %} - {% include 'inc/panels/tags.html' %} - {% include 'inc/panels/comments.html' %} - {% include 'inc/panels/image_attachments.html' %} - {% plugin_left_page object %} +
+

{% trans "Rack" %}

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
{% trans "Region" %}{% nested_tree object.site.region %}
{% trans "Site" %}{{ object.site|linkify }}
{% trans "Location" %}{% nested_tree object.location %}
{% trans "Facility ID" %}{{ object.facility_id|placeholder }}
{% trans "Tenant" %} + {% if object.tenant.group %} + {{ object.tenant.group|linkify }} / + {% endif %} + {{ object.tenant|linkify|placeholder }} +
{% trans "Status" %}{% badge object.get_status_display bg_color=object.get_status_color %}
{% trans "Rack Type" %}{{ object.rack_type|linkify|placeholder }}
{% trans "Role" %}{{ object.role|linkify|placeholder }}
{% trans "Description" %}{{ object.description|placeholder }}
{% trans "Serial Number" %}{{ object.serial|placeholder }}
{% trans "Asset Tag" %}{{ object.asset_tag|placeholder }}
{% trans "Space Utilization" %}{% utilization_graph object.get_utilization %}
{% trans "Power Utilization" %}{% utilization_graph object.get_power_utilization %}
+
+ {% include 'dcim/inc/panels/racktype_dimensions.html' %} + {% include 'dcim/inc/panels/racktype_numbering.html' %} +
+

{% trans "Weight" %}

+ + + + + + + + + + + + + +
{% trans "Rack Weight" %} + {% if object.weight %} + {{ object.weight|floatformat }} {{ object.get_weight_unit_display }} + {% else %} + {{ ''|placeholder }} + {% endif %} +
{% trans "Maximum Weight" %} + {% if object.max_weight %} + {{ object.max_weight }} {{ object.get_weight_unit_display }} + {% else %} + {{ ''|placeholder }} + {% endif %} +
{% trans "Total Weight" %} + {{ object.total_weight|floatformat }} {% trans "Kilograms" %} + ({{ object.total_weight|kg_to_pounds|floatformat }} {% trans "Pounds" %}) +
+
+ {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} + {% include 'inc/panels/image_attachments.html' %} + {% plugin_left_page object %}
@@ -170,26 +120,26 @@
-
-
-

{% trans "Front" %}

- {% include 'dcim/inc/rack_elevation.html' with face='front' extra_params=svg_extra %} -
+
+
+

{% trans "Front" %}

+ {% include 'dcim/inc/rack_elevation.html' with face='front' extra_params=svg_extra %}
-
-
-

{% trans "Rear" %}

- {% include 'dcim/inc/rack_elevation.html' with face='rear' extra_params=svg_extra %} -
+
+
+
+

{% trans "Rear" %}

+ {% include 'dcim/inc/rack_elevation.html' with face='rear' extra_params=svg_extra %}
+
{% include 'inc/panels/related_objects.html' %} {% plugin_right_page object %}
-
- {% plugin_full_width_page object %} -
+
+ {% plugin_full_width_page object %} +
{% endblock %} diff --git a/netbox/templates/dcim/racktype.html b/netbox/templates/dcim/racktype.html new file mode 100644 index 000000000..0c82b13d1 --- /dev/null +++ b/netbox/templates/dcim/racktype.html @@ -0,0 +1,71 @@ +{% extends 'generic/object.html' %} +{% load buttons %} +{% load helpers %} +{% load static %} +{% load plugins %} +{% load i18n %} +{% load mptt %} + +{% block content %} +
+
+
+
{% trans "Rack Type" %}
+ + + + + + + + + + + + + +
{% trans "Manufacturer" %}{{ object.manufacturer|linkify }}
{% trans "Name" %}{{ object.name }}
{% trans "Description" %}{{ object.description|placeholder }}
+
+ {% include 'dcim/inc/panels/racktype_dimensions.html' %} + {% include 'inc/panels/tags.html' %} + {% include 'inc/panels/comments.html' %} + {% plugin_left_page object %} +
+
+ {% include 'dcim/inc/panels/racktype_numbering.html' %} +
+
{% trans "Weight" %}
+ + + + + + + + + +
{% trans "Rack Weight" %} + {% if object.weight %} + {{ object.weight|floatformat }} {{ object.get_weight_unit_display }} + {% else %} + {{ ''|placeholder }} + {% endif %} +
{% trans "Maximum Weight" %} + {% if object.max_weight %} + {{ object.max_weight }} {{ object.get_weight_unit_display }} + {% else %} + {{ ''|placeholder }} + {% endif %} +
+
+ {% include 'inc/panels/custom_fields.html' %} + {% include 'inc/panels/related_objects.html' %} + {% plugin_right_page object %} +
+
+
+
+ {% plugin_full_width_page object %} +
+
+{% endblock %} diff --git a/netbox/templates/extras/notificationgroup.html b/netbox/templates/extras/notificationgroup.html new file mode 100644 index 000000000..ab514f8bf --- /dev/null +++ b/netbox/templates/extras/notificationgroup.html @@ -0,0 +1,57 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} +{% load render_table from django_tables2 %} +{% load i18n %} + +{% block content %} +
+
+
+
{% trans "Notification Group" %}
+ + + + + + + + + +
{% trans "Name" %} + {{ object.name }} +
{% trans "Description" %} + {{ object.description|placeholder }} +
+
+ {% plugin_left_page object %} +
+
+
+
{% trans "Groups" %}
+
+ {% for group in object.groups.all %} + {{ group }} + {% empty %} +
{% trans "None assigned" %}
+ {% endfor %} +
+
+
+
{% trans "Users" %}
+
+ {% for user in object.users.all %} + {{ user }} + {% empty %} +
{% trans "None assigned" %}
+ {% endfor %} +
+
+ {% plugin_right_page object %} +
+
+
+ {% plugin_full_width_page object %} +
+
+{% endblock %} diff --git a/netbox/templates/extras/script_result.html b/netbox/templates/extras/script_result.html index 1b297673b..40be0456e 100644 --- a/netbox/templates/extras/script_result.html +++ b/netbox/templates/extras/script_result.html @@ -42,8 +42,26 @@
{# Object table controls #} -
-
+
+
{% trans "Log threshold" %}
+ +
+ +
+ +
{% if request.user.is_authenticated and job.completed %}
-
-
-
- + {% if filter_form %} +
+
+
+ +
+ {{ filter_form.filter_id }}
- {{ filter_form.filter_id }}
-
+ {% endif %}
{% if request.user.is_authenticated and table_modal %} diff --git a/netbox/templates/inc/user_menu.html b/netbox/templates/inc/user_menu.html index 30ea63059..ab2c31239 100644 --- a/netbox/templates/inc/user_menu.html +++ b/netbox/templates/inc/user_menu.html @@ -2,6 +2,17 @@ {% load navigation %} {% if request.user.is_authenticated %} + {# Notifications #} + {% with notifications=request.user.notifications.unread.exists %} + + {% endwith %} + + {# User menu #}