Compare commits

...

11 Commits

Author SHA1 Message Date
Jeremy Stretch
32fb3869a4 Closes #19829: Move object types REST API endpoint to core app
Some checks are pending
CI / build (20.x, 3.10) (push) Waiting to run
CI / build (20.x, 3.11) (push) Waiting to run
CI / build (20.x, 3.12) (push) Waiting to run
2025-07-16 14:54:03 -04:00
Jeremy Stretch
c5ffab0c28 Closes #18349: Replace houskeeping management command with a system job (#19815) 2025-07-16 14:50:11 -04:00
Jeremy Stretch
5f8a4f6c43 Merge branch 'main' into feature
Some checks are pending
CI / build (20.x, 3.10) (push) Waiting to run
CI / build (20.x, 3.11) (push) Waiting to run
CI / build (20.x, 3.12) (push) Waiting to run
2025-07-16 09:52:58 -04:00
Jeremy Stretch
21a840c32e Closes #19816: Implement a logging mechanism for background jobs (#19838)
Some checks failed
CI / build (20.x, 3.10) (push) Has been cancelled
CI / build (20.x, 3.11) (push) Has been cancelled
CI / build (20.x, 3.12) (push) Has been cancelled
* Initial work on #19816

* Use TZ-aware timestamps

* Deserialize JobLogEntry timestamp

* Repurpose RQJobStatusColumn to display job entry level badges

* Misc cleanup

* Test logging

* Refactor HTML templates

* Update documentation
2025-07-14 08:52:50 -05:00
Jeremy Stretch
875a641687 Closes #19589: Background job for bulk operations (#19804)
Some checks failed
CI / build (20.x, 3.10) (push) Has been cancelled
CI / build (20.x, 3.11) (push) Has been cancelled
CI / build (20.x, 3.12) (push) Has been cancelled
* Initial work on #19589

* Add tooling for handling background requests

* UI notification should link to enqueued job

* Use an informative name for the job

* Disable background jobs for file uploads
2025-07-10 09:32:35 -05:00
Jeremy Stretch
6022433a40 Closes #19134: Allow negative values for interface TX power (#19847)
Some checks are pending
CI / build (20.x, 3.10) (push) Waiting to run
CI / build (20.x, 3.11) (push) Waiting to run
CI / build (20.x, 3.12) (push) Waiting to run
2025-07-09 10:17:41 -07:00
Jeremy Stretch
878c624eaf Closes #19722: Extend the object types REST API endpoint (#19826) 2025-07-09 08:43:24 -07:00
Jeremy Stretch
90e8a61670 Closes #19739: Add a user preference for CSV delimiter in table exports (#19824)
* Closes #19739: Add a user preference for CSV delimiter in table exports

* Pass custom delimiter when exporting entire table
2025-07-08 14:11:40 -05:00
Jeremy Stretch
a1cd81ff35 Closes #17413: Permit identical names for platforms belonging to different manufacturers (#19814) 2025-07-07 10:38:01 -07:00
Jeremy Stretch
ce12de8b6d Closes #19231: Add bulk renaming support for all models (#19795)
* Closes #19231: Add bulk renaming support for all models

* Introduce a template filter for getattr()

* Extend BulkRenameView to support arbitrary field names

* Address bulk renaming support for remaining models

* Bulk rename URL resolution should fail silently

* Update documentation

* Fix bulk button rendering for HTMX requests
2025-07-02 13:35:34 -05:00
Jeremy Stretch
601a77ac73 Closes #19735: Implement reuable bulk operations classes (#19774)
* Initial work on #19735

* Work in progress

* Remove ClusterRemoveDevicesView (anti-pattern)

* Misc cleanup

* Fix has_bulk_actions

* Fix has_bulk_actions for ObjectChildrenView

* Restore clone button

* Misc cleanup

* Clean up custom bulk actions

* Rename individual object actions

* Collapse into a single template tag

* Fix support for legacy action dicts

* Rename bulk attr to multi

* clone_button tag should fail silently if view name is invalid

* Clean up action buttons

* Fix export button label

* Replace clone_button with an ObjectAction

* Create object actions for adding device/VM components

* Move core_sync.html to core app

* Remove extra_bulk_buttons from template doc
2025-06-30 13:03:07 -04:00
119 changed files with 1856 additions and 1275 deletions

View File

@@ -1,17 +0,0 @@
[Unit]
Description=NetBox Housekeeping Service
Documentation=https://docs.netbox.dev/
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=netbox
Group=netbox
WorkingDirectory=/opt/netbox
ExecStart=/opt/netbox/venv/bin/python /opt/netbox/netbox/manage.py housekeeping
[Install]
WantedBy=multi-user.target

View File

@@ -1,9 +0,0 @@
#!/bin/sh
# This shell script invokes NetBox's housekeeping management command, which
# intended to be run nightly. This script can be copied into your system's
# daily cron directory (e.g. /etc/cron.daily), or referenced directly from
# within the cron configuration file.
#
# If NetBox has been installed into a nonstandard location, update the paths
# below.
/opt/netbox/venv/bin/python /opt/netbox/netbox/manage.py housekeeping

View File

@@ -1,13 +0,0 @@
[Unit]
Description=NetBox Housekeeping Timer
Documentation=https://docs.netbox.dev/
After=network-online.target
Wants=network-online.target
[Timer]
OnCalendar=daily
AccuracySec=1h
Persistent=true
[Install]
WantedBy=multi-user.target

View File

@@ -1,49 +0,0 @@
# Housekeeping
NetBox includes a `housekeeping` management command that should be run nightly. This command handles:
* Clearing expired authentication sessions from the database
* Deleting changelog records older than the configured [retention time](../configuration/miscellaneous.md#changelog_retention)
* Deleting job result records older than the configured [retention time](../configuration/miscellaneous.md#job_retention)
* Check for new NetBox releases (if [`RELEASE_CHECK_URL`](../configuration/miscellaneous.md#release_check_url) is set)
This command can be invoked directly, or by using the shell script provided at `/opt/netbox/contrib/netbox-housekeeping.sh`.
## Scheduling
### Using Cron
This script can be linked from your cron scheduler's daily jobs directory (e.g. `/etc/cron.daily`) or referenced directly within the cron configuration file.
```shell
sudo ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
```
!!! note
On Debian-based systems, be sure to omit the `.sh` file extension when linking to the script from within a cron directory. Otherwise, the task may not run.
### Using Systemd
First, create symbolic links for the systemd service and timer files. Link the existing service and timer files from the `/opt/netbox/contrib/` directory to the `/etc/systemd/system/` directory:
```bash
sudo ln -s /opt/netbox/contrib/netbox-housekeeping.service /etc/systemd/system/netbox-housekeeping.service
sudo ln -s /opt/netbox/contrib/netbox-housekeeping.timer /etc/systemd/system/netbox-housekeeping.timer
```
Then, reload the systemd configuration and enable the timer to start automatically at boot:
```bash
sudo systemctl daemon-reload
sudo systemctl enable --now netbox-housekeeping.timer
```
Check the status of your timer by running:
```bash
sudo systemctl list-timers --all
```
This command will show a list of all timers, including your `netbox-housekeeping.timer`. Make sure the timer is active and properly scheduled.
That's it! Your NetBox housekeeping service is now configured to run daily using systemd.

View File

@@ -159,6 +159,7 @@ LOGGING = {
* `netbox.auth.*` - Authentication events * `netbox.auth.*` - Authentication events
* `netbox.api.views.*` - Views which handle business logic for the REST API * `netbox.api.views.*` - Views which handle business logic for the REST API
* `netbox.event_rules` - Event rules * `netbox.event_rules` - Event rules
* `netbox.jobs.*` - Background jobs
* `netbox.reports.*` - Report execution (`module.name`) * `netbox.reports.*` - Report execution (`module.name`)
* `netbox.scripts.*` - Custom script execution (`module.name`) * `netbox.scripts.*` - Custom script execution (`module.name`)
* `netbox.views.*` - Views which handle business logic for the web UI * `netbox.views.*` - Views which handle business logic for the web UI

View File

@@ -264,18 +264,6 @@ cd /opt/netbox/netbox
python3 manage.py createsuperuser python3 manage.py createsuperuser
``` ```
## Schedule the Housekeeping Task
NetBox includes a `housekeeping` management command that handles some recurring cleanup tasks, such as clearing out old sessions and expired change records. Although this command may be run manually, it is recommended to configure a scheduled job using the system's `cron` daemon or a similar utility.
A shell script which invokes this command is included at `contrib/netbox-housekeeping.sh`. It can be copied to or linked from your system's daily cron task directory, or included within the crontab directly. (If installing NetBox into a nonstandard path, be sure to update the system paths within this script first.)
```shell
sudo ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
```
See the [housekeeping documentation](../administration/housekeeping.md) for further details.
## Test the Application ## Test the Application
At this point, we should be able to run NetBox's development server for testing. We can check by starting a development instance locally. At this point, we should be able to run NetBox's development server for testing. We can check by starting a development instance locally.

View File

@@ -183,13 +183,3 @@ Finally, restart the gunicorn and RQ services:
```no-highlight ```no-highlight
sudo systemctl restart netbox netbox-rq sudo systemctl restart netbox netbox-rq
``` ```
## 6. Verify Housekeeping Scheduling
If upgrading from a release prior to NetBox v3.0, check that a cron task (or similar scheduled process) has been configured to run NetBox's nightly housekeeping command. A shell script which invokes this command is included at `contrib/netbox-housekeeping.sh`. It can be linked from your system's daily cron task directory, or included within the crontab directly. (If NetBox has been installed in a nonstandard path, be sure to update the system paths within this script first.)
```shell
sudo ln -s /opt/netbox/contrib/netbox-housekeeping.sh /etc/cron.daily/netbox-housekeeping
```
See the [housekeeping documentation](../administration/housekeeping.md) for further details.

View File

@@ -10,11 +10,11 @@ The assignment of platforms to devices is an optional feature, and may be disreg
### Name ### Name
A unique human-friendly name. A human-friendly name for the platform. Must be unique per manufacturer.
### Slug ### Slug
A unique URL-friendly identifier. (This value can be used for filtering.) A URL-friendly identifier; must be unique per manufacturer. (This value can be used for filtering.)
### Manufacturer ### Manufacturer

View File

@@ -39,6 +39,27 @@ You can schedule the background job from within your code (e.g. from a model's `
This is the human-friendly names of your background job. If omitted, the class name will be used. This is the human-friendly names of your background job. If omitted, the class name will be used.
### Logging
!!! info "This feature was introduced in NetBox v4.4."
A Python logger is instantiated by the runner for each job. It can be utilized within a job's `run()` method as needed:
```python
def run(self, *args, **kwargs):
obj = MyModel.objects.get(pk=kwargs.get('pk'))
self.logger.info("Retrieved object {obj}")
```
Four of the standard Python logging levels are supported:
* `debug()`
* `info()`
* `warning()`
* `error()`
Log entries recorded using the runner's logger will be saved in the job's log in the database in addition to being processed by other [system logging handlers](../../configuration/system.md#logging).
### Scheduled Jobs ### Scheduled Jobs
As described above, jobs can be scheduled for immediate execution or at any later time using the `enqueue()` method. However, for management purposes, the `enqueue_once()` method allows a job to be scheduled exactly once avoiding duplicates. If a job is already scheduled for a particular instance, a second one won't be scheduled, respecting thread safety. An example use case would be to schedule a periodic task that is bound to an instance in general, but not to any event of that instance (such as updates). The parameters of the `enqueue_once()` method are identical to those of `enqueue()`. As described above, jobs can be scheduled for immediate execution or at any later time using the `enqueue()` method. However, for management purposes, the `enqueue_once()` method allows a job to be scheduled exactly once avoiding duplicates. If a job is already scheduled for a particular instance, a second one won't be scheduled, respecting thread safety. An example use case would be to schedule a periodic task that is bound to an instance in general, but not to any event of that instance (such as updates). The parameters of the `enqueue_once()` method are identical to those of `enqueue()`.

View File

@@ -64,6 +64,7 @@ Generic view classes (documented below) facilitate common operations, such as cr
| `ObjectListView` | View a list of objects | | `ObjectListView` | View a list of objects |
| `BulkImportView` | Import a set of new objects | | `BulkImportView` | Import a set of new objects |
| `BulkEditView` | Edit multiple objects | | `BulkEditView` | Edit multiple objects |
| `BulkRenameView` | Rename multiple objects |
| `BulkDeleteView` | Delete multiple objects | | `BulkDeleteView` | Delete multiple objects |
!!! warning !!! warning
@@ -171,6 +172,10 @@ Below are the class definitions for NetBox's multi-object views. These views han
options: options:
members: false members: false
::: netbox.views.generic.BulkRenameView
options:
members: false
::: netbox.views.generic.BulkDeleteView ::: netbox.views.generic.BulkDeleteView
options: options:
members: members:

View File

@@ -434,7 +434,7 @@ A new management command has been added: `manage.py housekeeping`. This command
* Delete change log records which have surpassed the configured retention period (if configured) * Delete change log records which have surpassed the configured retention period (if configured)
* Check for new NetBox releases (if enabled) * Check for new NetBox releases (if enabled)
A convenience script for calling this command via an automated scheduler has been included at `/contrib/netbox-housekeeping.sh`. Please see the [housekeeping documentation](../administration/housekeeping.md) for further details. A convenience script for calling this command via an automated scheduler has been included at `/contrib/netbox-housekeeping.sh`. Please see the housekeeping documentation for further details.
#### Custom Queue Support for Plugins ([#6651](https://github.com/netbox-community/netbox/issues/6651)) #### Custom Queue Support for Plugins ([#6651](https://github.com/netbox-community/netbox/issues/6651))

View File

@@ -158,7 +158,6 @@ nav:
- Okta: 'administration/authentication/okta.md' - Okta: 'administration/authentication/okta.md'
- Permissions: 'administration/permissions.md' - Permissions: 'administration/permissions.md'
- Error Reporting: 'administration/error-reporting.md' - Error Reporting: 'administration/error-reporting.md'
- Housekeeping: 'administration/housekeeping.md'
- Replicating NetBox: 'administration/replicating-netbox.md' - Replicating NetBox: 'administration/replicating-netbox.md'
- NetBox Shell: 'administration/netbox-shell.md' - NetBox Shell: 'administration/netbox-shell.md'
- Data Model: - Data Model:

View File

@@ -5,6 +5,7 @@ from django.utils.translation import gettext_lazy as _
from dcim.views import PathTraceView from dcim.views import PathTraceView
from ipam.models import ASN from ipam.models import ASN
from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport
from netbox.views import generic from netbox.views import generic
from utilities.forms import ConfirmationForm from utilities.forms import ConfirmationForm
from utilities.query import count_related from utilities.query import count_related
@@ -79,6 +80,11 @@ class ProviderBulkEditView(generic.BulkEditView):
form = forms.ProviderBulkEditForm form = forms.ProviderBulkEditForm
@register_model_view(Provider, 'bulk_rename', path='rename', detail=False)
class ProviderBulkRenameView(generic.BulkRenameView):
queryset = Provider.objects.all()
@register_model_view(Provider, 'bulk_delete', path='delete', detail=False) @register_model_view(Provider, 'bulk_delete', path='delete', detail=False)
class ProviderBulkDeleteView(generic.BulkDeleteView): class ProviderBulkDeleteView(generic.BulkDeleteView):
queryset = Provider.objects.annotate( queryset = Provider.objects.annotate(
@@ -141,6 +147,11 @@ class ProviderAccountBulkEditView(generic.BulkEditView):
form = forms.ProviderAccountBulkEditForm form = forms.ProviderAccountBulkEditForm
@register_model_view(ProviderAccount, 'bulk_rename', path='rename', detail=False)
class ProviderAccountBulkRenameView(generic.BulkRenameView):
queryset = ProviderAccount.objects.all()
@register_model_view(ProviderAccount, 'bulk_delete', path='delete', detail=False) @register_model_view(ProviderAccount, 'bulk_delete', path='delete', detail=False)
class ProviderAccountBulkDeleteView(generic.BulkDeleteView): class ProviderAccountBulkDeleteView(generic.BulkDeleteView):
queryset = ProviderAccount.objects.annotate( queryset = ProviderAccount.objects.annotate(
@@ -212,6 +223,11 @@ class ProviderNetworkBulkEditView(generic.BulkEditView):
form = forms.ProviderNetworkBulkEditForm form = forms.ProviderNetworkBulkEditForm
@register_model_view(ProviderNetwork, 'bulk_rename', path='rename', detail=False)
class ProviderNetworkBulkRenameView(generic.BulkRenameView):
queryset = ProviderNetwork.objects.all()
@register_model_view(ProviderNetwork, 'bulk_delete', path='delete', detail=False) @register_model_view(ProviderNetwork, 'bulk_delete', path='delete', detail=False)
class ProviderNetworkBulkDeleteView(generic.BulkDeleteView): class ProviderNetworkBulkDeleteView(generic.BulkDeleteView):
queryset = ProviderNetwork.objects.all() queryset = ProviderNetwork.objects.all()
@@ -271,6 +287,11 @@ class CircuitTypeBulkEditView(generic.BulkEditView):
form = forms.CircuitTypeBulkEditForm form = forms.CircuitTypeBulkEditForm
@register_model_view(CircuitType, 'bulk_rename', path='rename', detail=False)
class CircuitTypeBulkRenameView(generic.BulkRenameView):
queryset = CircuitType.objects.all()
@register_model_view(CircuitType, 'bulk_delete', path='delete', detail=False) @register_model_view(CircuitType, 'bulk_delete', path='delete', detail=False)
class CircuitTypeBulkDeleteView(generic.BulkDeleteView): class CircuitTypeBulkDeleteView(generic.BulkDeleteView):
queryset = CircuitType.objects.annotate( queryset = CircuitType.objects.annotate(
@@ -337,6 +358,12 @@ class CircuitBulkEditView(generic.BulkEditView):
form = forms.CircuitBulkEditForm form = forms.CircuitBulkEditForm
@register_model_view(Circuit, 'bulk_rename', path='rename', detail=False)
class CircuitBulkRenameView(generic.BulkRenameView):
queryset = Circuit.objects.all()
field_name = 'cid'
@register_model_view(Circuit, 'bulk_delete', path='delete', detail=False) @register_model_view(Circuit, 'bulk_delete', path='delete', detail=False)
class CircuitBulkDeleteView(generic.BulkDeleteView): class CircuitBulkDeleteView(generic.BulkDeleteView):
queryset = Circuit.objects.prefetch_related( queryset = Circuit.objects.prefetch_related(
@@ -432,6 +459,7 @@ class CircuitTerminationListView(generic.ObjectListView):
filterset = filtersets.CircuitTerminationFilterSet filterset = filtersets.CircuitTerminationFilterSet
filterset_form = forms.CircuitTerminationFilterForm filterset_form = forms.CircuitTerminationFilterForm
table = tables.CircuitTerminationTable table = tables.CircuitTerminationTable
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
@register_model_view(CircuitTermination) @register_model_view(CircuitTermination)
@@ -526,6 +554,11 @@ class CircuitGroupBulkEditView(generic.BulkEditView):
form = forms.CircuitGroupBulkEditForm form = forms.CircuitGroupBulkEditForm
@register_model_view(CircuitGroup, 'bulk_rename', path='rename', detail=False)
class CircuitGroupBulkRenameView(generic.BulkRenameView):
queryset = CircuitGroup.objects.all()
@register_model_view(CircuitGroup, 'bulk_delete', path='delete', detail=False) @register_model_view(CircuitGroup, 'bulk_delete', path='delete', detail=False)
class CircuitGroupBulkDeleteView(generic.BulkDeleteView): class CircuitGroupBulkDeleteView(generic.BulkDeleteView):
queryset = CircuitGroup.objects.all() queryset = CircuitGroup.objects.all()
@@ -543,6 +576,7 @@ class CircuitGroupAssignmentListView(generic.ObjectListView):
filterset = filtersets.CircuitGroupAssignmentFilterSet filterset = filtersets.CircuitGroupAssignmentFilterSet
filterset_form = forms.CircuitGroupAssignmentFilterForm filterset_form = forms.CircuitGroupAssignmentFilterForm
table = tables.CircuitGroupAssignmentTable table = tables.CircuitGroupAssignmentTable
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
@register_model_view(CircuitGroupAssignment) @register_model_view(CircuitGroupAssignment)
@@ -635,6 +669,11 @@ class VirtualCircuitTypeBulkEditView(generic.BulkEditView):
form = forms.VirtualCircuitTypeBulkEditForm form = forms.VirtualCircuitTypeBulkEditForm
@register_model_view(VirtualCircuitType, 'bulk_rename', path='rename', detail=False)
class VirtualCircuitTypeBulkRenameView(generic.BulkRenameView):
queryset = VirtualCircuitType.objects.all()
@register_model_view(VirtualCircuitType, 'bulk_delete', path='delete', detail=False) @register_model_view(VirtualCircuitType, 'bulk_delete', path='delete', detail=False)
class VirtualCircuitTypeBulkDeleteView(generic.BulkDeleteView): class VirtualCircuitTypeBulkDeleteView(generic.BulkDeleteView):
queryset = VirtualCircuitType.objects.annotate( queryset = VirtualCircuitType.objects.annotate(
@@ -697,6 +736,12 @@ class VirtualCircuitBulkEditView(generic.BulkEditView):
form = forms.VirtualCircuitBulkEditForm form = forms.VirtualCircuitBulkEditForm
@register_model_view(VirtualCircuit, 'bulk_rename', path='rename', detail=False)
class VirtualCircuitulkRenameView(generic.BulkRenameView):
queryset = VirtualCircuit.objects.all()
field_name = 'cid'
class VirtualCircuitBulkDeleteView(generic.BulkDeleteView): class VirtualCircuitBulkDeleteView(generic.BulkDeleteView):
queryset = VirtualCircuit.objects.annotate( queryset = VirtualCircuit.objects.annotate(
termination_count=count_related(VirtualCircuitTermination, 'virtual_circuit') termination_count=count_related(VirtualCircuitTermination, 'virtual_circuit')
@@ -714,6 +759,7 @@ class VirtualCircuitTerminationListView(generic.ObjectListView):
filterset = filtersets.VirtualCircuitTerminationFilterSet filterset = filtersets.VirtualCircuitTerminationFilterSet
filterset_form = forms.VirtualCircuitTerminationFilterForm filterset_form = forms.VirtualCircuitTerminationFilterForm
table = tables.VirtualCircuitTerminationTable table = tables.VirtualCircuitTerminationTable
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
@register_model_view(VirtualCircuitTermination) @register_model_view(VirtualCircuitTermination)

View File

@@ -1,4 +1,5 @@
from .serializers_.change_logging import * from .serializers_.change_logging import *
from .serializers_.data import * from .serializers_.data import *
from .serializers_.jobs import * from .serializers_.jobs import *
from .serializers_.object_types import *
from .serializers_.tasks import * from .serializers_.tasks import *

View File

@@ -23,6 +23,6 @@ class JobSerializer(BaseModelSerializer):
model = Job model = Job
fields = [ fields = [
'id', 'url', 'display_url', 'display', 'object_type', 'object_id', 'name', 'status', 'created', 'scheduled', 'id', 'url', 'display_url', 'display', 'object_type', 'object_id', 'name', 'status', 'created', 'scheduled',
'interval', 'started', 'completed', 'user', 'data', 'error', 'job_id', 'interval', 'started', 'completed', 'user', 'data', 'error', 'job_id', 'log_entries',
] ]
brief_fields = ('url', 'created', 'completed', 'user', 'status') brief_fields = ('url', 'created', 'completed', 'user', 'status')

View File

@@ -0,0 +1,47 @@
import inspect
from django.urls import NoReverseMatch, reverse
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import extend_schema_field
from rest_framework import serializers
from core.models import ObjectType
from netbox.api.serializers import BaseModelSerializer
from utilities.views import get_viewname
__all__ = (
'ObjectTypeSerializer',
)
class ObjectTypeSerializer(BaseModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:objecttype-detail')
app_name = serializers.CharField(source='app_verbose_name', read_only=True)
model_name = serializers.CharField(source='model_verbose_name', read_only=True)
model_name_plural = serializers.CharField(source='model_verbose_name_plural', read_only=True)
is_plugin_model = serializers.BooleanField(read_only=True)
rest_api_endpoint = serializers.SerializerMethodField()
description = serializers.SerializerMethodField()
class Meta:
model = ObjectType
fields = [
'id', 'url', 'display', 'app_label', 'app_name', 'model', 'model_name', 'model_name_plural',
'is_plugin_model', 'rest_api_endpoint', 'description',
]
@extend_schema_field(OpenApiTypes.STR)
def get_rest_api_endpoint(self, obj):
if not (model := obj.model_class()):
return
if viewname := get_viewname(model, action='list', rest_api=True):
try:
return reverse(viewname)
except NoReverseMatch:
return
@extend_schema_field(OpenApiTypes.STR)
def get_description(self, obj):
if not (model := obj.model_class()):
return
return inspect.getdoc(model)

View File

@@ -10,6 +10,7 @@ router.register('data-sources', views.DataSourceViewSet)
router.register('data-files', views.DataFileViewSet) router.register('data-files', views.DataFileViewSet)
router.register('jobs', views.JobViewSet) router.register('jobs', views.JobViewSet)
router.register('object-changes', views.ObjectChangeViewSet) router.register('object-changes', views.ObjectChangeViewSet)
router.register('object-types', views.ObjectTypeViewSet)
router.register('background-queues', views.BackgroundQueueViewSet, basename='rqqueue') router.register('background-queues', views.BackgroundQueueViewSet, basename='rqqueue')
router.register('background-workers', views.BackgroundWorkerViewSet, basename='rqworker') router.register('background-workers', views.BackgroundWorkerViewSet, basename='rqworker')
router.register('background-tasks', views.BackgroundTaskViewSet, basename='rqtask') router.register('background-tasks', views.BackgroundTaskViewSet, basename='rqtask')

View File

@@ -17,6 +17,7 @@ from core.utils import delete_rq_job, enqueue_rq_job, get_rq_jobs, requeue_rq_jo
from django_rq.queues import get_redis_connection from django_rq.queues import get_redis_connection
from django_rq.utils import get_statistics from django_rq.utils import get_statistics
from django_rq.settings import QUEUES_LIST from django_rq.settings import QUEUES_LIST
from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
from netbox.api.metadata import ContentTypeMetadata from netbox.api.metadata import ContentTypeMetadata
from netbox.api.pagination import LimitOffsetListPagination from netbox.api.pagination import LimitOffsetListPagination
from netbox.api.viewsets import NetBoxModelViewSet, NetBoxReadOnlyModelViewSet from netbox.api.viewsets import NetBoxModelViewSet, NetBoxReadOnlyModelViewSet
@@ -85,6 +86,16 @@ class ObjectChangeViewSet(ReadOnlyModelViewSet):
filterset_class = filtersets.ObjectChangeFilterSet filterset_class = filtersets.ObjectChangeFilterSet
class ObjectTypeViewSet(ReadOnlyModelViewSet):
"""
Read-only list of ObjectTypes.
"""
permission_classes = [IsAuthenticatedOrLoginNotRequired]
queryset = ObjectType.objects.order_by('app_label', 'model')
serializer_class = serializers.ObjectTypeSerializer
filterset_class = filtersets.ObjectTypeFilterSet
class BaseRQViewSet(viewsets.ViewSet): class BaseRQViewSet(viewsets.ViewSet):
""" """
Base class for RQ view sets. Provides a list() method. Subclasses must implement get_data(). Base class for RQ view sets. Provides a list() method. Subclasses must implement get_data().

View File

@@ -4,23 +4,31 @@ from django.utils.translation import gettext_lazy as _
from rq.job import JobStatus from rq.job import JobStatus
__all__ = ( __all__ = (
'JOB_LOG_ENTRY_LEVELS',
'RQ_TASK_STATUSES', 'RQ_TASK_STATUSES',
) )
@dataclass @dataclass
class Status: class Badge:
label: str label: str
color: str color: str
RQ_TASK_STATUSES = { RQ_TASK_STATUSES = {
JobStatus.QUEUED: Status(_('Queued'), 'cyan'), JobStatus.QUEUED: Badge(_('Queued'), 'cyan'),
JobStatus.FINISHED: Status(_('Finished'), 'green'), JobStatus.FINISHED: Badge(_('Finished'), 'green'),
JobStatus.FAILED: Status(_('Failed'), 'red'), JobStatus.FAILED: Badge(_('Failed'), 'red'),
JobStatus.STARTED: Status(_('Started'), 'blue'), JobStatus.STARTED: Badge(_('Started'), 'blue'),
JobStatus.DEFERRED: Status(_('Deferred'), 'gray'), JobStatus.DEFERRED: Badge(_('Deferred'), 'gray'),
JobStatus.SCHEDULED: Status(_('Scheduled'), 'purple'), JobStatus.SCHEDULED: Badge(_('Scheduled'), 'purple'),
JobStatus.STOPPED: Status(_('Stopped'), 'orange'), JobStatus.STOPPED: Badge(_('Stopped'), 'orange'),
JobStatus.CANCELED: Status(_('Cancelled'), 'yellow'), JobStatus.CANCELED: Badge(_('Cancelled'), 'yellow'),
}
JOB_LOG_ENTRY_LEVELS = {
'debug': Badge(_('Debug'), 'gray'),
'info': Badge(_('Info'), 'blue'),
'warning': Badge(_('Warning'), 'orange'),
'error': Badge(_('Error'), 'red'),
} }

View File

@@ -0,0 +1,21 @@
import logging
from dataclasses import dataclass, field
from datetime import datetime
from django.utils import timezone
__all__ = (
'JobLogEntry',
)
@dataclass
class JobLogEntry:
level: str
message: str
timestamp: datetime = field(default_factory=timezone.now)
@classmethod
def from_logrecord(cls, record: logging.LogRecord):
return cls(record.levelname.lower(), record.msg)

View File

@@ -1,9 +1,8 @@
import django_filters
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db.models import Q from django.db.models import Q
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
import django_filters
from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet from netbox.filtersets import BaseFilterSet, ChangeLoggedModelFilterSet, NetBoxModelFilterSet
from netbox.utils import get_data_backend_choices from netbox.utils import get_data_backend_choices
from users.models import User from users.models import User
@@ -17,6 +16,7 @@ __all__ = (
'DataSourceFilterSet', 'DataSourceFilterSet',
'JobFilterSet', 'JobFilterSet',
'ObjectChangeFilterSet', 'ObjectChangeFilterSet',
'ObjectTypeFilterSet',
) )
@@ -134,6 +134,25 @@ class JobFilterSet(BaseFilterSet):
) )
class ObjectTypeFilterSet(django_filters.FilterSet):
q = django_filters.CharFilter(
method='search',
label=_('Search'),
)
class Meta:
model = ObjectType
fields = ('id', 'app_label', 'model')
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(app_label__icontains=value) |
Q(model__icontains=value)
)
class ObjectChangeFilterSet(BaseFilterSet): class ObjectChangeFilterSet(BaseFilterSet):
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',

View File

@@ -1,13 +1,20 @@
import logging import logging
import requests
import sys import sys
from datetime import timedelta
from importlib import import_module
import requests
from django.conf import settings from django.conf import settings
from django.core.cache import cache
from django.utils import timezone
from packaging import version
from core.models import Job, ObjectChange
from netbox.config import Config
from netbox.jobs import JobRunner, system_job from netbox.jobs import JobRunner, system_job
from netbox.search.backends import search_backend from netbox.search.backends import search_backend
from utilities.proxy import resolve_proxies from utilities.proxy import resolve_proxies
from .choices import DataSourceStatusChoices, JobIntervalChoices from .choices import DataSourceStatusChoices, JobIntervalChoices
from .exceptions import SyncError
from .models import DataSource from .models import DataSource
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -23,19 +30,23 @@ class SyncDataSourceJob(JobRunner):
def run(self, *args, **kwargs): def run(self, *args, **kwargs):
datasource = DataSource.objects.get(pk=self.job.object_id) datasource = DataSource.objects.get(pk=self.job.object_id)
self.logger.debug(f"Found DataSource ID {datasource.pk}")
try: try:
self.logger.info(f"Syncing data source {datasource}")
datasource.sync() datasource.sync()
# Update the search cache for DataFiles belonging to this source # Update the search cache for DataFiles belonging to this source
self.logger.debug("Updating search cache for data files")
search_backend.cache(datasource.datafiles.iterator()) search_backend.cache(datasource.datafiles.iterator())
except Exception as e: except Exception as e:
self.logger.error(f"Error syncing data source: {e}")
DataSource.objects.filter(pk=datasource.pk).update(status=DataSourceStatusChoices.FAILED) DataSource.objects.filter(pk=datasource.pk).update(status=DataSourceStatusChoices.FAILED)
if type(e) is SyncError:
logging.error(e)
raise e raise e
self.logger.info("Syncing completed successfully")
@system_job(interval=JobIntervalChoices.INTERVAL_DAILY) @system_job(interval=JobIntervalChoices.INTERVAL_DAILY)
class SystemHousekeepingJob(JobRunner): class SystemHousekeepingJob(JobRunner):
@@ -50,16 +61,23 @@ class SystemHousekeepingJob(JobRunner):
if settings.DEBUG or 'test' in sys.argv: if settings.DEBUG or 'test' in sys.argv:
return return
# TODO: Migrate other housekeeping functions from the `housekeeping` management command.
self.send_census_report() self.send_census_report()
self.clear_expired_sessions()
self.prune_changelog()
self.delete_expired_jobs()
self.check_for_new_releases()
@staticmethod @staticmethod
def send_census_report(): def send_census_report():
""" """
Send a census report (if enabled). Send a census report (if enabled).
""" """
# Skip if census reporting is disabled logging.info("Reporting census data...")
if settings.ISOLATED_DEPLOYMENT or not settings.CENSUS_REPORTING_ENABLED: if settings.ISOLATED_DEPLOYMENT:
logging.info("ISOLATED_DEPLOYMENT is enabled; skipping")
return
if not settings.CENSUS_REPORTING_ENABLED:
logging.info("CENSUS_REPORTING_ENABLED is disabled; skipping")
return return
census_data = { census_data = {
@@ -76,3 +94,94 @@ class SystemHousekeepingJob(JobRunner):
) )
except requests.exceptions.RequestException: except requests.exceptions.RequestException:
pass pass
@staticmethod
def clear_expired_sessions():
"""
Clear any expired sessions from the database.
"""
logging.info("Clearing expired sessions...")
engine = import_module(settings.SESSION_ENGINE)
try:
engine.SessionStore.clear_expired()
logging.info("Sessions cleared.")
except NotImplementedError:
logging.warning(
f"The configured session engine ({settings.SESSION_ENGINE}) does not support "
f"clearing sessions; skipping."
)
@staticmethod
def prune_changelog():
"""
Delete any ObjectChange records older than the configured changelog retention time (if any).
"""
logging.info("Pruning old changelog entries...")
config = Config()
if not config.CHANGELOG_RETENTION:
logging.info("No retention period specified; skipping.")
return
cutoff = timezone.now() - timedelta(days=config.CHANGELOG_RETENTION)
logging.debug(f"Retention period: {config.CHANGELOG_RETENTION} days")
logging.debug(f"Cut-off time: {cutoff}")
count = ObjectChange.objects.filter(time__lt=cutoff).delete()[0]
logging.info(f"Deleted {count} expired records")
@staticmethod
def delete_expired_jobs():
"""
Delete any jobs older than the configured retention period (if any).
"""
logging.info("Deleting expired jobs...")
config = Config()
if not config.JOB_RETENTION:
logging.info("No retention period specified; skipping.")
return
cutoff = timezone.now() - timedelta(days=config.JOB_RETENTION)
logging.debug(f"Retention period: {config.CHANGELOG_RETENTION} days")
logging.debug(f"Cut-off time: {cutoff}")
count = Job.objects.filter(created__lt=cutoff).delete()[0]
logging.info(f"Deleted {count} expired records")
@staticmethod
def check_for_new_releases():
"""
Check for new releases and cache the latest release.
"""
logging.info("Checking for new releases...")
if settings.ISOLATED_DEPLOYMENT:
logging.info("ISOLATED_DEPLOYMENT is enabled; skipping")
return
if not settings.RELEASE_CHECK_URL:
logging.info("RELEASE_CHECK_URL is not set; skipping")
return
# Fetch the latest releases
logging.debug(f"Release check URL: {settings.RELEASE_CHECK_URL}")
try:
response = requests.get(
url=settings.RELEASE_CHECK_URL,
headers={'Accept': 'application/vnd.github.v3+json'},
proxies=resolve_proxies(url=settings.RELEASE_CHECK_URL)
)
response.raise_for_status()
except requests.exceptions.RequestException as exc:
logging.error(f"Error fetching release: {exc}")
return
# Determine the most recent stable release
releases = []
for release in response.json():
if 'tag_name' not in release or release.get('devrelease') or release.get('prerelease'):
continue
releases.append((version.parse(release['tag_name']), release.get('html_url')))
latest_release = max(releases)
logging.debug(f"Found {len(response.json())} releases; {len(releases)} usable")
logging.info(f"Latest release: {latest_release[0]}")
# Cache the most recent release
cache.set('latest_release', latest_release, None)

View File

@@ -0,0 +1,28 @@
import django.contrib.postgres.fields
import django.core.serializers.json
from django.db import migrations, models
import utilities.json
class Migration(migrations.Migration):
dependencies = [
('core', '0015_remove_redundant_indexes'),
]
operations = [
migrations.AddField(
model_name='job',
name='log_entries',
field=django.contrib.postgres.fields.ArrayField(
base_field=models.JSONField(
decoder=utilities.json.JobLogDecoder,
encoder=django.core.serializers.json.DjangoJSONEncoder
),
blank=True,
default=list,
size=None
),
),
]

View File

@@ -1,7 +1,9 @@
from django.contrib.contenttypes.models import ContentType, ContentTypeManager from django.contrib.contenttypes.models import ContentType, ContentTypeManager
from django.db.models import Q from django.db.models import Q
from netbox.plugins import PluginConfig
from netbox.registry import registry from netbox.registry import registry
from utilities.string import title
__all__ = ( __all__ = (
'ObjectType', 'ObjectType',
@@ -48,3 +50,29 @@ class ObjectType(ContentType):
class Meta: class Meta:
proxy = True proxy = True
@property
def app_labeled_name(self):
# Override ContentType's "app | model" representation style.
return f"{self.app_verbose_name} > {title(self.model_verbose_name)}"
@property
def app_verbose_name(self):
if model := self.model_class():
return model._meta.app_config.verbose_name
@property
def model_verbose_name(self):
if model := self.model_class():
return model._meta.verbose_name
@property
def model_verbose_name_plural(self):
if model := self.model_class():
return model._meta.verbose_name_plural
@property
def is_plugin_model(self):
if not (model := self.model_class()):
return # Return null if model class is invalid
return isinstance(model._meta.app_config, PluginConfig)

View File

@@ -1,9 +1,12 @@
import logging
import uuid import uuid
from dataclasses import asdict
from functools import partial from functools import partial
import django_rq import django_rq
from django.conf import settings from django.conf import settings
from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.postgres.fields import ArrayField
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.serializers.json import DjangoJSONEncoder from django.core.serializers.json import DjangoJSONEncoder
from django.core.validators import MinValueValidator from django.core.validators import MinValueValidator
@@ -14,8 +17,10 @@ from django.utils.translation import gettext as _
from rq.exceptions import InvalidJobOperation from rq.exceptions import InvalidJobOperation
from core.choices import JobStatusChoices from core.choices import JobStatusChoices
from core.dataclasses import JobLogEntry
from core.models import ObjectType from core.models import ObjectType
from core.signals import job_end, job_start from core.signals import job_end, job_start
from utilities.json import JobLogDecoder
from utilities.querysets import RestrictedQuerySet from utilities.querysets import RestrictedQuerySet
from utilities.rqworker import get_queue_for_model from utilities.rqworker import get_queue_for_model
@@ -104,6 +109,15 @@ class Job(models.Model):
verbose_name=_('job ID'), verbose_name=_('job ID'),
unique=True unique=True
) )
log_entries = ArrayField(
verbose_name=_('log entries'),
base_field=models.JSONField(
encoder=DjangoJSONEncoder,
decoder=JobLogDecoder,
),
blank=True,
default=list,
)
objects = RestrictedQuerySet.as_manager() objects = RestrictedQuerySet.as_manager()
@@ -116,7 +130,7 @@ class Job(models.Model):
verbose_name_plural = _('jobs') verbose_name_plural = _('jobs')
def __str__(self): def __str__(self):
return str(self.job_id) return self.name
def get_absolute_url(self): def get_absolute_url(self):
# TODO: Employ dynamic registration # TODO: Employ dynamic registration
@@ -204,6 +218,13 @@ class Job(models.Model):
# Send signal # Send signal
job_end.send(self) job_end.send(self)
def log(self, record: logging.LogRecord):
"""
Record a LogRecord from Python's native logging in the job's log.
"""
entry = JobLogEntry.from_logrecord(record)
self.log_entries.append(asdict(entry))
@classmethod @classmethod
def enqueue( def enqueue(
cls, cls,

View File

@@ -0,0 +1,18 @@
from django.utils.translation import gettext as _
from netbox.object_actions import ObjectAction
__all__ = (
'BulkSync',
)
class BulkSync(ObjectAction):
"""
Synchronize multiple objects at once.
"""
name = 'bulk_sync'
label = _('Sync Data')
multi = True
permissions_required = {'sync'}
template_name = 'core/buttons/bulk_sync.html'

View File

@@ -1,12 +1,11 @@
import django_tables2 as tables import django_tables2 as tables
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from core.constants import RQ_TASK_STATUSES
from netbox.registry import registry from netbox.registry import registry
__all__ = ( __all__ = (
'BackendTypeColumn', 'BackendTypeColumn',
'RQJobStatusColumn', 'BadgeColumn',
) )
@@ -23,14 +22,21 @@ class BackendTypeColumn(tables.Column):
return value return value
class RQJobStatusColumn(tables.Column): class BadgeColumn(tables.Column):
""" """
Render a colored label for the status of an RQ job. Render a colored badge for a value.
Args:
badges: A dictionary mapping of values to core.constants.Badge instances.
""" """
def __init__(self, badges, *args, **kwargs):
super().__init__(*args, **kwargs)
self.badges = badges
def render(self, value): def render(self, value):
status = RQ_TASK_STATUSES.get(value) badge = self.badges.get(value)
return mark_safe(f'<span class="badge text-bg-{status.color}">{status.label}</span>') return mark_safe(f'<span class="badge text-bg-{badge.color}">{badge.label}</span>')
def value(self, value): def value(self, value):
status = RQ_TASK_STATUSES.get(value) badge = self.badges.get(value)
return status.label return badge.label

View File

@@ -1,8 +1,10 @@
import django_tables2 as tables import django_tables2 as tables
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from netbox.tables import NetBoxTable, columns from netbox.tables import BaseTable, NetBoxTable, columns
from ..models import Job from core.constants import JOB_LOG_ENTRY_LEVELS
from core.models import Job
from core.tables.columns import BadgeColumn
class JobTable(NetBoxTable): class JobTable(NetBoxTable):
@@ -40,6 +42,9 @@ class JobTable(NetBoxTable):
completed = columns.DateTimeColumn( completed = columns.DateTimeColumn(
verbose_name=_('Completed'), verbose_name=_('Completed'),
) )
log_entries = tables.Column(
verbose_name=_('Log Entries'),
)
actions = columns.ActionsColumn( actions = columns.ActionsColumn(
actions=('delete',) actions=('delete',)
) )
@@ -53,3 +58,24 @@ class JobTable(NetBoxTable):
default_columns = ( default_columns = (
'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'started', 'completed', 'user', 'pk', 'id', 'object_type', 'object', 'name', 'status', 'created', 'started', 'completed', 'user',
) )
def render_log_entries(self, value):
return len(value)
class JobLogEntryTable(BaseTable):
timestamp = columns.DateTimeColumn(
timespec='milliseconds',
verbose_name=_('Time'),
)
level = BadgeColumn(
badges=JOB_LOG_ENTRY_LEVELS,
verbose_name=_('Level'),
)
message = tables.Column(
verbose_name=_('Message'),
)
class Meta(BaseTable.Meta):
empty_text = _('No log entries')
fields = ('timestamp', 'level', 'message')

View File

@@ -2,7 +2,8 @@ import django_tables2 as tables
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django_tables2.utils import A from django_tables2.utils import A
from core.tables.columns import RQJobStatusColumn from core.constants import RQ_TASK_STATUSES
from core.tables.columns import BadgeColumn
from netbox.tables import BaseTable, columns from netbox.tables import BaseTable, columns
@@ -84,7 +85,8 @@ class BackgroundTaskTable(BaseTable):
ended_at = columns.DateTimeColumn( ended_at = columns.DateTimeColumn(
verbose_name=_("Ended") verbose_name=_("Ended")
) )
status = RQJobStatusColumn( status = BadgeColumn(
badges=RQ_TASK_STATUSES,
verbose_name=_("Status"), verbose_name=_("Status"),
accessor='get_status' accessor='get_status'
) )

View File

@@ -7,6 +7,7 @@ from django.utils import timezone
from rq.job import Job as RQ_Job, JobStatus from rq.job import Job as RQ_Job, JobStatus
from rq.registry import FailedJobRegistry, StartedJobRegistry from rq.registry import FailedJobRegistry, StartedJobRegistry
from rest_framework import status
from users.models import Token, User from users.models import Token, User
from utilities.testing import APITestCase, APIViewTestCases, TestCase from utilities.testing import APITestCase, APIViewTestCases, TestCase
from utilities.testing.utils import disable_logging from utilities.testing.utils import disable_logging
@@ -101,6 +102,22 @@ class DataFileTest(
DataFile.objects.bulk_create(data_files) DataFile.objects.bulk_create(data_files)
class ObjectTypeTest(APITestCase):
def test_list_objects(self):
object_type_count = ObjectType.objects.count()
response = self.client.get(reverse('extras-api:objecttype-list'), **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['count'], object_type_count)
def test_get_object(self):
object_type = ObjectType.objects.first()
url = reverse('extras-api:objecttype-detail', kwargs={'pk': object_type.pk})
self.assertHttpStatus(self.client.get(url, **self.header), status.HTTP_200_OK)
class BackgroundTaskTestCase(TestCase): class BackgroundTaskTestCase(TestCase):
user_permissions = () user_permissions = ()

View File

@@ -22,6 +22,7 @@ from rq.worker_registration import clean_worker_registry
from core.utils import delete_rq_job, enqueue_rq_job, get_rq_jobs_from_status, requeue_rq_job, stop_rq_job from core.utils import delete_rq_job, enqueue_rq_job, get_rq_jobs_from_status, requeue_rq_job, stop_rq_job
from netbox.config import get_config, PARAMS from netbox.config import get_config, PARAMS
from netbox.object_actions import AddObject, BulkDelete, BulkExport, DeleteObject
from netbox.registry import registry from netbox.registry import registry
from netbox.views import generic from netbox.views import generic
from netbox.views.generic.base import BaseObjectView from netbox.views.generic.base import BaseObjectView
@@ -31,13 +32,13 @@ from utilities.forms import ConfirmationForm
from utilities.htmx import htmx_partial from utilities.htmx import htmx_partial
from utilities.json import ConfigJSONEncoder from utilities.json import ConfigJSONEncoder
from utilities.query import count_related from utilities.query import count_related
from utilities.views import ContentTypePermissionRequiredMixin, GetRelatedModelsMixin, register_model_view from utilities.views import ContentTypePermissionRequiredMixin, GetRelatedModelsMixin, ViewTab, register_model_view
from . import filtersets, forms, tables from . import filtersets, forms, tables
from .choices import DataSourceStatusChoices from .choices import DataSourceStatusChoices
from .jobs import SyncDataSourceJob from .jobs import SyncDataSourceJob
from .models import * from .models import *
from .plugins import get_catalog_plugins, get_local_plugins from .plugins import get_catalog_plugins, get_local_plugins
from .tables import CatalogPluginTable, PluginVersionTable from .tables import CatalogPluginTable, JobLogEntryTable, PluginVersionTable
# #
@@ -119,6 +120,11 @@ class DataSourceBulkEditView(generic.BulkEditView):
form = forms.DataSourceBulkEditForm form = forms.DataSourceBulkEditForm
@register_model_view(DataSource, 'bulk_rename', path='rename', detail=False)
class DataSourceBulkRenameView(generic.BulkRenameView):
queryset = DataSource.objects.all()
@register_model_view(DataSource, 'bulk_delete', path='delete', detail=False) @register_model_view(DataSource, 'bulk_delete', path='delete', detail=False)
class DataSourceBulkDeleteView(generic.BulkDeleteView): class DataSourceBulkDeleteView(generic.BulkDeleteView):
queryset = DataSource.objects.annotate( queryset = DataSource.objects.annotate(
@@ -138,14 +144,13 @@ class DataFileListView(generic.ObjectListView):
filterset = filtersets.DataFileFilterSet filterset = filtersets.DataFileFilterSet
filterset_form = forms.DataFileFilterForm filterset_form = forms.DataFileFilterForm
table = tables.DataFileTable table = tables.DataFileTable
actions = { actions = (BulkDelete,)
'bulk_delete': {'delete'},
}
@register_model_view(DataFile) @register_model_view(DataFile)
class DataFileView(generic.ObjectView): class DataFileView(generic.ObjectView):
queryset = DataFile.objects.all() queryset = DataFile.objects.all()
actions = (DeleteObject,)
@register_model_view(DataFile, 'delete') @register_model_view(DataFile, 'delete')
@@ -170,15 +175,32 @@ class JobListView(generic.ObjectListView):
filterset = filtersets.JobFilterSet filterset = filtersets.JobFilterSet
filterset_form = forms.JobFilterForm filterset_form = forms.JobFilterForm
table = tables.JobTable table = tables.JobTable
actions = { actions = (BulkExport, BulkDelete)
'export': {'view'},
'bulk_delete': {'delete'},
}
@register_model_view(Job) @register_model_view(Job)
class JobView(generic.ObjectView): class JobView(generic.ObjectView):
queryset = Job.objects.all() queryset = Job.objects.all()
actions = (DeleteObject,)
@register_model_view(Job, 'log')
class JobLogView(generic.ObjectView):
queryset = Job.objects.all()
actions = (DeleteObject,)
template_name = 'core/job/log.html'
tab = ViewTab(
label=_('Log'),
badge=lambda obj: len(obj.log_entries),
weight=500,
)
def get_extra_context(self, request, instance):
table = JobLogEntryTable(instance.log_entries)
table.configure(request)
return {
'table': table,
}
@register_model_view(Job, 'delete') @register_model_view(Job, 'delete')
@@ -204,9 +226,7 @@ class ObjectChangeListView(generic.ObjectListView):
filterset_form = forms.ObjectChangeFilterForm filterset_form = forms.ObjectChangeFilterForm
table = tables.ObjectChangeTable table = tables.ObjectChangeTable
template_name = 'core/objectchange_list.html' template_name = 'core/objectchange_list.html'
actions = { actions = (BulkExport,)
'export': {'view'},
}
@register_model_view(ObjectChange) @register_model_view(ObjectChange)
@@ -274,6 +294,7 @@ class ConfigRevisionListView(generic.ObjectListView):
filterset = filtersets.ConfigRevisionFilterSet filterset = filtersets.ConfigRevisionFilterSet
filterset_form = forms.ConfigRevisionFilterForm filterset_form = forms.ConfigRevisionFilterForm
table = tables.ConfigRevisionTable table = tables.ConfigRevisionTable
actions = (AddObject, BulkExport)
@register_model_view(ConfigRevision) @register_model_view(ConfigRevision)

View File

@@ -1507,7 +1507,7 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
tx_power = forms.IntegerField( tx_power = forms.IntegerField(
required=False, required=False,
label=_('Transmit power (dBm)'), label=_('Transmit power (dBm)'),
min_value=0, min_value=-40,
max_value=127 max_value=127
) )
vrf_id = DynamicModelMultipleChoiceField( vrf_id = DynamicModelMultipleChoiceField(

View File

@@ -0,0 +1,54 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0207_remove_redundant_indexes'),
('extras', '0129_fix_script_paths'),
]
operations = [
migrations.AlterField(
model_name='platform',
name='name',
field=models.CharField(max_length=100),
),
migrations.AlterField(
model_name='platform',
name='slug',
field=models.SlugField(max_length=100),
),
migrations.AddConstraint(
model_name='platform',
constraint=models.UniqueConstraint(
fields=('manufacturer', 'name'),
name='dcim_platform_manufacturer_name'
),
),
migrations.AddConstraint(
model_name='platform',
constraint=models.UniqueConstraint(
condition=models.Q(('manufacturer__isnull', True)),
fields=('name',),
name='dcim_platform_name',
violation_error_message='Platform name must be unique.'
),
),
migrations.AddConstraint(
model_name='platform',
constraint=models.UniqueConstraint(
fields=('manufacturer', 'slug'),
name='dcim_platform_manufacturer_slug'
),
),
migrations.AddConstraint(
model_name='platform',
constraint=models.UniqueConstraint(
condition=models.Q(('manufacturer__isnull', True)),
fields=('slug',),
name='dcim_platform_slug',
violation_error_message='Platform slug must be unique.'
),
),
]

View File

@@ -0,0 +1,24 @@
import django.core.validators
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('dcim', '0208_platform_manufacturer_uniqueness'),
]
operations = [
migrations.AlterField(
model_name='interface',
name='tx_power',
field=models.SmallIntegerField(
blank=True,
null=True,
validators=[
django.core.validators.MinValueValidator(-40),
django.core.validators.MaxValueValidator(127)
]
),
),
]

View File

@@ -4,7 +4,7 @@ from django.db import migrations, models
class Migration(migrations.Migration): class Migration(migrations.Migration):
dependencies = [ dependencies = [
('dcim', '0207_remove_redundant_indexes'), ('dcim', '0209_interface_tx_power_negative'),
('extras', '0129_fix_script_paths'), ('extras', '0129_fix_script_paths'),
] ]

View File

@@ -719,10 +719,13 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
verbose_name=('channel width (MHz)'), verbose_name=('channel width (MHz)'),
help_text=_("Populated by selected channel (if set)") help_text=_("Populated by selected channel (if set)")
) )
tx_power = models.PositiveSmallIntegerField( tx_power = models.SmallIntegerField(
blank=True, blank=True,
null=True, null=True,
validators=(MaxValueValidator(127),), validators=(
MinValueValidator(-40),
MaxValueValidator(127),
),
verbose_name=_('transmit power (dBm)') verbose_name=_('transmit power (dBm)')
) )
poe_mode = models.CharField( poe_mode = models.CharField(

View File

@@ -437,6 +437,15 @@ class Platform(OrganizationalModel):
null=True, null=True,
help_text=_('Optionally limit this platform to devices of a certain manufacturer') help_text=_('Optionally limit this platform to devices of a certain manufacturer')
) )
# Override name & slug from OrganizationalModel to not enforce uniqueness
name = models.CharField(
verbose_name=_('name'),
max_length=100
)
slug = models.SlugField(
verbose_name=_('slug'),
max_length=100
)
config_template = models.ForeignKey( config_template = models.ForeignKey(
to='extras.ConfigTemplate', to='extras.ConfigTemplate',
on_delete=models.PROTECT, on_delete=models.PROTECT,
@@ -449,6 +458,28 @@ class Platform(OrganizationalModel):
ordering = ('name',) ordering = ('name',)
verbose_name = _('platform') verbose_name = _('platform')
verbose_name_plural = _('platforms') verbose_name_plural = _('platforms')
constraints = (
models.UniqueConstraint(
fields=('manufacturer', 'name'),
name='%(app_label)s_%(class)s_manufacturer_name',
),
models.UniqueConstraint(
fields=('name',),
name='%(app_label)s_%(class)s_name',
condition=Q(manufacturer__isnull=True),
violation_error_message=_("Platform name must be unique.")
),
models.UniqueConstraint(
fields=('manufacturer', 'slug'),
name='%(app_label)s_%(class)s_manufacturer_slug',
),
models.UniqueConstraint(
fields=('slug',),
name='%(app_label)s_%(class)s_slug',
condition=Q(manufacturer__isnull=True),
violation_error_message=_("Platform slug must be unique.")
),
)
class Device( class Device(

View File

@@ -0,0 +1,38 @@
from django.utils.translation import gettext as _
from netbox.object_actions import ObjectAction
__all__ = (
'BulkAddComponents',
'BulkDisconnect',
)
class BulkAddComponents(ObjectAction):
"""
Add components to the selected devices.
"""
label = _('Add Components')
multi = True
permissions_required = {'change'}
template_name = 'dcim/buttons/bulk_add_components.html'
@classmethod
def get_context(cls, context, obj):
return {
'perms': context.get('perms'),
'request': context.get('request'),
'formaction': context.get('formaction'),
'label': cls.label,
}
class BulkDisconnect(ObjectAction):
"""
Disconnect each of a set of objects to which a cable is connected.
"""
name = 'bulk_disconnect'
label = _('Disconnect Selected')
multi = True
permissions_required = {'change'}
template_name = 'dcim/buttons/bulk_disconnect.html'

View File

@@ -15,7 +15,7 @@ from circuits.models import Circuit, CircuitTermination
from extras.views import ObjectConfigContextView, ObjectRenderConfigView from extras.views import ObjectConfigContextView, ObjectRenderConfigView
from ipam.models import ASN, IPAddress, Prefix, VLANGroup, VLAN from ipam.models import ASN, IPAddress, Prefix, VLANGroup, VLAN
from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable
from netbox.constants import DEFAULT_ACTION_PERMISSIONS from netbox.object_actions import *
from netbox.views import generic from netbox.views import generic
from utilities.forms import ConfirmationForm from utilities.forms import ConfirmationForm
from utilities.paginator import EnhancedPaginator, get_paginate_count from utilities.paginator import EnhancedPaginator, get_paginate_count
@@ -34,6 +34,7 @@ from wireless.models import WirelessLAN
from . import filtersets, forms, tables from . import filtersets, forms, tables
from .choices import DeviceFaceChoices, InterfaceModeChoices from .choices import DeviceFaceChoices, InterfaceModeChoices
from .models import * from .models import *
from .object_actions import BulkAddComponents, BulkDisconnect
CABLE_TERMINATION_TYPES = { CABLE_TERMINATION_TYPES = {
'dcim.consoleport': ConsolePort, 'dcim.consoleport': ConsolePort,
@@ -49,11 +50,6 @@ CABLE_TERMINATION_TYPES = {
class DeviceComponentsView(generic.ObjectChildrenView): class DeviceComponentsView(generic.ObjectChildrenView):
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
'bulk_disconnect': {'change'},
}
queryset = Device.objects.all() queryset = Device.objects.all()
def get_children(self, request, parent): def get_children(self, request, parent):
@@ -61,12 +57,8 @@ class DeviceComponentsView(generic.ObjectChildrenView):
class DeviceTypeComponentsView(generic.ObjectChildrenView): class DeviceTypeComponentsView(generic.ObjectChildrenView):
actions = { actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDelete)
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
}
queryset = DeviceType.objects.all() queryset = DeviceType.objects.all()
template_name = 'dcim/devicetype/component_templates.html'
viewname = None # Used for return_url resolution viewname = None # Used for return_url resolution
def get_children(self, request, parent): def get_children(self, request, parent):
@@ -78,9 +70,9 @@ class DeviceTypeComponentsView(generic.ObjectChildrenView):
} }
class ModuleTypeComponentsView(DeviceComponentsView): class ModuleTypeComponentsView(generic.ObjectChildrenView):
queryset = ModuleType.objects.all() queryset = ModuleType.objects.all()
template_name = 'dcim/moduletype/component_templates.html' actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDelete)
viewname = None # Used for return_url resolution viewname = None # Used for return_url resolution
def get_children(self, request, parent): def get_children(self, request, parent):
@@ -300,6 +292,11 @@ class RegionBulkEditView(generic.BulkEditView):
form = forms.RegionBulkEditForm form = forms.RegionBulkEditForm
@register_model_view(Region, 'bulk_rename', path='rename', detail=False)
class RegionBulkRenameView(generic.BulkRenameView):
queryset = Region.objects.all()
@register_model_view(Region, 'bulk_delete', path='delete', detail=False) @register_model_view(Region, 'bulk_delete', path='delete', detail=False)
class RegionBulkDeleteView(generic.BulkDeleteView): class RegionBulkDeleteView(generic.BulkDeleteView):
queryset = Region.objects.add_related_count( queryset = Region.objects.add_related_count(
@@ -426,6 +423,11 @@ class SiteGroupBulkEditView(generic.BulkEditView):
form = forms.SiteGroupBulkEditForm form = forms.SiteGroupBulkEditForm
@register_model_view(SiteGroup, 'bulk_rename', path='rename', detail=False)
class SiteGroupBulkRenameView(generic.BulkRenameView):
queryset = SiteGroup.objects.all()
@register_model_view(SiteGroup, 'bulk_delete', path='delete', detail=False) @register_model_view(SiteGroup, 'bulk_delete', path='delete', detail=False)
class SiteGroupBulkDeleteView(generic.BulkDeleteView): class SiteGroupBulkDeleteView(generic.BulkDeleteView):
queryset = SiteGroup.objects.add_related_count( queryset = SiteGroup.objects.add_related_count(
@@ -511,6 +513,11 @@ class SiteBulkEditView(generic.BulkEditView):
form = forms.SiteBulkEditForm form = forms.SiteBulkEditForm
@register_model_view(Site, 'bulk_rename', path='rename', detail=False)
class SiteBulkRenameView(generic.BulkRenameView):
queryset = Site.objects.all()
@register_model_view(Site, 'bulk_delete', path='delete', detail=False) @register_model_view(Site, 'bulk_delete', path='delete', detail=False)
class SiteBulkDeleteView(generic.BulkDeleteView): class SiteBulkDeleteView(generic.BulkDeleteView):
queryset = Site.objects.all() queryset = Site.objects.all()
@@ -615,6 +622,11 @@ class LocationBulkEditView(generic.BulkEditView):
form = forms.LocationBulkEditForm form = forms.LocationBulkEditForm
@register_model_view(Location, 'bulk_rename', path='rename', detail=False)
class LocationBulkRenameView(generic.BulkRenameView):
queryset = Location.objects.all()
@register_model_view(Location, 'bulk_delete', path='delete', detail=False) @register_model_view(Location, 'bulk_delete', path='delete', detail=False)
class LocationBulkDeleteView(generic.BulkDeleteView): class LocationBulkDeleteView(generic.BulkDeleteView):
queryset = Location.objects.add_related_count( queryset = Location.objects.add_related_count(
@@ -680,6 +692,11 @@ class RackRoleBulkEditView(generic.BulkEditView):
form = forms.RackRoleBulkEditForm form = forms.RackRoleBulkEditForm
@register_model_view(RackRole, 'bulk_rename', path='rename', detail=False)
class RackRoleBulkRenameView(generic.BulkRenameView):
queryset = RackRole.objects.all()
@register_model_view(RackRole, 'bulk_delete', path='delete', detail=False) @register_model_view(RackRole, 'bulk_delete', path='delete', detail=False)
class RackRoleBulkDeleteView(generic.BulkDeleteView): class RackRoleBulkDeleteView(generic.BulkDeleteView):
queryset = RackRole.objects.annotate( queryset = RackRole.objects.annotate(
@@ -739,6 +756,12 @@ class RackTypeBulkEditView(generic.BulkEditView):
form = forms.RackTypeBulkEditForm form = forms.RackTypeBulkEditForm
@register_model_view(RackType, 'bulk_rename', path='rename', detail=False)
class RackTypeBulkRenameView(generic.BulkRenameView):
queryset = RackType.objects.all()
field_name = 'model'
@register_model_view(RackType, 'bulk_delete', path='delete', detail=False) @register_model_view(RackType, 'bulk_delete', path='delete', detail=False)
class RackTypeBulkDeleteView(generic.BulkDeleteView): class RackTypeBulkDeleteView(generic.BulkDeleteView):
queryset = RackType.objects.all() queryset = RackType.objects.all()
@@ -918,6 +941,11 @@ class RackBulkEditView(generic.BulkEditView):
form = forms.RackBulkEditForm form = forms.RackBulkEditForm
@register_model_view(Rack, 'bulk_rename', path='rename', detail=False)
class RackBulkRenameView(generic.BulkRenameView):
queryset = Rack.objects.all()
@register_model_view(Rack, 'bulk_delete', path='delete', detail=False) @register_model_view(Rack, 'bulk_delete', path='delete', detail=False)
class RackBulkDeleteView(generic.BulkDeleteView): class RackBulkDeleteView(generic.BulkDeleteView):
queryset = Rack.objects.all() queryset = Rack.objects.all()
@@ -935,6 +963,7 @@ class RackReservationListView(generic.ObjectListView):
filterset = filtersets.RackReservationFilterSet filterset = filtersets.RackReservationFilterSet
filterset_form = forms.RackReservationFilterForm filterset_form = forms.RackReservationFilterForm
table = tables.RackReservationTable table = tables.RackReservationTable
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
@register_model_view(RackReservation) @register_model_view(RackReservation)
@@ -1051,6 +1080,11 @@ class ManufacturerBulkEditView(generic.BulkEditView):
form = forms.ManufacturerBulkEditForm form = forms.ManufacturerBulkEditForm
@register_model_view(Manufacturer, 'bulk_rename', path='rename', detail=False)
class ManufacturerBulkRenameView(generic.BulkRenameView):
queryset = Manufacturer.objects.all()
@register_model_view(Manufacturer, 'bulk_delete', path='delete', detail=False) @register_model_view(Manufacturer, 'bulk_delete', path='delete', detail=False)
class ManufacturerBulkDeleteView(generic.BulkDeleteView): class ManufacturerBulkDeleteView(generic.BulkDeleteView):
queryset = Manufacturer.objects.annotate( queryset = Manufacturer.objects.annotate(
@@ -1298,6 +1332,12 @@ class DeviceTypeBulkEditView(generic.BulkEditView):
form = forms.DeviceTypeBulkEditForm form = forms.DeviceTypeBulkEditForm
@register_model_view(DeviceType, 'bulk_rename', path='rename', detail=False)
class DeviceTypeBulkRenameView(generic.BulkRenameView):
queryset = DeviceType.objects.all()
field_name = 'model'
@register_model_view(DeviceType, 'bulk_delete', path='delete', detail=False) @register_model_view(DeviceType, 'bulk_delete', path='delete', detail=False)
class DeviceTypeBulkDeleteView(generic.BulkDeleteView): class DeviceTypeBulkDeleteView(generic.BulkDeleteView):
queryset = DeviceType.objects.annotate( queryset = DeviceType.objects.annotate(
@@ -1354,6 +1394,11 @@ class ModuleTypeProfileBulkEditView(generic.BulkEditView):
form = forms.ModuleTypeProfileBulkEditForm form = forms.ModuleTypeProfileBulkEditForm
@register_model_view(ModuleTypeProfile, 'bulk_rename', path='rename', detail=False)
class ModuleTypeProfileBulkRenameView(generic.BulkRenameView):
queryset = ModuleTypeProfile.objects.all()
@register_model_view(ModuleTypeProfile, 'bulk_delete', path='delete', detail=False) @register_model_view(ModuleTypeProfile, 'bulk_delete', path='delete', detail=False)
class ModuleTypeProfileBulkDeleteView(generic.BulkDeleteView): class ModuleTypeProfileBulkDeleteView(generic.BulkDeleteView):
queryset = ModuleTypeProfile.objects.annotate( queryset = ModuleTypeProfile.objects.annotate(
@@ -1564,6 +1609,11 @@ class ModuleTypeBulkEditView(generic.BulkEditView):
form = forms.ModuleTypeBulkEditForm form = forms.ModuleTypeBulkEditForm
@register_model_view(ModuleType, 'bulk_rename', path='rename', detail=False)
class ModuleTypeBulkRenameView(generic.BulkRenameView):
queryset = ModuleType.objects.all()
@register_model_view(ModuleType, 'bulk_delete', path='delete', detail=False) @register_model_view(ModuleType, 'bulk_delete', path='delete', detail=False)
class ModuleTypeBulkDeleteView(generic.BulkDeleteView): class ModuleTypeBulkDeleteView(generic.BulkDeleteView):
queryset = ModuleType.objects.annotate( queryset = ModuleType.objects.annotate(
@@ -2038,6 +2088,11 @@ class DeviceRoleBulkEditView(generic.BulkEditView):
form = forms.DeviceRoleBulkEditForm form = forms.DeviceRoleBulkEditForm
@register_model_view(DeviceRole, 'bulk_rename', path='rename', detail=False)
class DeviceRoleBulkRenameView(generic.BulkRenameView):
queryset = DeviceRole.objects.all()
@register_model_view(DeviceRole, 'bulk_delete', path='delete', detail=False) @register_model_view(DeviceRole, 'bulk_delete', path='delete', detail=False)
class DeviceRoleBulkDeleteView(generic.BulkDeleteView): class DeviceRoleBulkDeleteView(generic.BulkDeleteView):
queryset = DeviceRole.objects.annotate( queryset = DeviceRole.objects.annotate(
@@ -2099,6 +2154,11 @@ class PlatformBulkEditView(generic.BulkEditView):
form = forms.PlatformBulkEditForm form = forms.PlatformBulkEditForm
@register_model_view(Platform, 'bulk_rename', path='rename', detail=False)
class PlatformBulkRenameView(generic.BulkRenameView):
queryset = Platform.objects.all()
@register_model_view(Platform, 'bulk_delete', path='delete', detail=False) @register_model_view(Platform, 'bulk_delete', path='delete', detail=False)
class PlatformBulkDeleteView(generic.BulkDeleteView): class PlatformBulkDeleteView(generic.BulkDeleteView):
queryset = Platform.objects.all() queryset = Platform.objects.all()
@@ -2116,7 +2176,7 @@ class DeviceListView(generic.ObjectListView):
filterset = filtersets.DeviceFilterSet filterset = filtersets.DeviceFilterSet
filterset_form = forms.DeviceFilterForm filterset_form = forms.DeviceFilterForm
table = tables.DeviceTable table = tables.DeviceTable
template_name = 'dcim/device_list.html' actions = (AddObject, BulkImport, BulkExport, BulkAddComponents, BulkEdit, BulkRename, BulkDelete)
@register_model_view(Device) @register_model_view(Device)
@@ -2157,7 +2217,7 @@ class DeviceConsolePortsView(DeviceComponentsView):
table = tables.DeviceConsolePortTable table = tables.DeviceConsolePortTable
filterset = filtersets.ConsolePortFilterSet filterset = filtersets.ConsolePortFilterSet
filterset_form = forms.ConsolePortFilterForm filterset_form = forms.ConsolePortFilterForm
template_name = 'dcim/device/consoleports.html', actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete)
tab = ViewTab( tab = ViewTab(
label=_('Console Ports'), label=_('Console Ports'),
badge=lambda obj: obj.console_port_count, badge=lambda obj: obj.console_port_count,
@@ -2173,7 +2233,7 @@ class DeviceConsoleServerPortsView(DeviceComponentsView):
table = tables.DeviceConsoleServerPortTable table = tables.DeviceConsoleServerPortTable
filterset = filtersets.ConsoleServerPortFilterSet filterset = filtersets.ConsoleServerPortFilterSet
filterset_form = forms.ConsoleServerPortFilterForm filterset_form = forms.ConsoleServerPortFilterForm
template_name = 'dcim/device/consoleserverports.html' actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete)
tab = ViewTab( tab = ViewTab(
label=_('Console Server Ports'), label=_('Console Server Ports'),
badge=lambda obj: obj.console_server_port_count, badge=lambda obj: obj.console_server_port_count,
@@ -2189,7 +2249,7 @@ class DevicePowerPortsView(DeviceComponentsView):
table = tables.DevicePowerPortTable table = tables.DevicePowerPortTable
filterset = filtersets.PowerPortFilterSet filterset = filtersets.PowerPortFilterSet
filterset_form = forms.PowerPortFilterForm filterset_form = forms.PowerPortFilterForm
template_name = 'dcim/device/powerports.html' actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete)
tab = ViewTab( tab = ViewTab(
label=_('Power Ports'), label=_('Power Ports'),
badge=lambda obj: obj.power_port_count, badge=lambda obj: obj.power_port_count,
@@ -2205,7 +2265,7 @@ class DevicePowerOutletsView(DeviceComponentsView):
table = tables.DevicePowerOutletTable table = tables.DevicePowerOutletTable
filterset = filtersets.PowerOutletFilterSet filterset = filtersets.PowerOutletFilterSet
filterset_form = forms.PowerOutletFilterForm filterset_form = forms.PowerOutletFilterForm
template_name = 'dcim/device/poweroutlets.html' actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete)
tab = ViewTab( tab = ViewTab(
label=_('Power Outlets'), label=_('Power Outlets'),
badge=lambda obj: obj.power_outlet_count, badge=lambda obj: obj.power_outlet_count,
@@ -2221,6 +2281,7 @@ class DeviceInterfacesView(DeviceComponentsView):
table = tables.DeviceInterfaceTable table = tables.DeviceInterfaceTable
filterset = filtersets.InterfaceFilterSet filterset = filtersets.InterfaceFilterSet
filterset_form = forms.InterfaceFilterForm filterset_form = forms.InterfaceFilterForm
actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete)
template_name = 'dcim/device/interfaces.html' template_name = 'dcim/device/interfaces.html'
tab = ViewTab( tab = ViewTab(
label=_('Interfaces'), label=_('Interfaces'),
@@ -2243,7 +2304,7 @@ class DeviceFrontPortsView(DeviceComponentsView):
table = tables.DeviceFrontPortTable table = tables.DeviceFrontPortTable
filterset = filtersets.FrontPortFilterSet filterset = filtersets.FrontPortFilterSet
filterset_form = forms.FrontPortFilterForm filterset_form = forms.FrontPortFilterForm
template_name = 'dcim/device/frontports.html' actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete)
tab = ViewTab( tab = ViewTab(
label=_('Front Ports'), label=_('Front Ports'),
badge=lambda obj: obj.front_port_count, badge=lambda obj: obj.front_port_count,
@@ -2259,7 +2320,7 @@ class DeviceRearPortsView(DeviceComponentsView):
table = tables.DeviceRearPortTable table = tables.DeviceRearPortTable
filterset = filtersets.RearPortFilterSet filterset = filtersets.RearPortFilterSet
filterset_form = forms.RearPortFilterForm filterset_form = forms.RearPortFilterForm
template_name = 'dcim/device/rearports.html' actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete)
tab = ViewTab( tab = ViewTab(
label=_('Rear Ports'), label=_('Rear Ports'),
badge=lambda obj: obj.rear_port_count, badge=lambda obj: obj.rear_port_count,
@@ -2275,11 +2336,7 @@ class DeviceModuleBaysView(DeviceComponentsView):
table = tables.DeviceModuleBayTable table = tables.DeviceModuleBayTable
filterset = filtersets.ModuleBayFilterSet filterset = filtersets.ModuleBayFilterSet
filterset_form = forms.ModuleBayFilterForm filterset_form = forms.ModuleBayFilterForm
template_name = 'dcim/device/modulebays.html' actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDelete)
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
}
tab = ViewTab( tab = ViewTab(
label=_('Module Bays'), label=_('Module Bays'),
badge=lambda obj: obj.module_bay_count, badge=lambda obj: obj.module_bay_count,
@@ -2295,11 +2352,7 @@ class DeviceDeviceBaysView(DeviceComponentsView):
table = tables.DeviceDeviceBayTable table = tables.DeviceDeviceBayTable
filterset = filtersets.DeviceBayFilterSet filterset = filtersets.DeviceBayFilterSet
filterset_form = forms.DeviceBayFilterForm filterset_form = forms.DeviceBayFilterForm
template_name = 'dcim/device/devicebays.html' actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDelete)
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
}
tab = ViewTab( tab = ViewTab(
label=_('Device Bays'), label=_('Device Bays'),
badge=lambda obj: obj.device_bay_count, badge=lambda obj: obj.device_bay_count,
@@ -2315,11 +2368,7 @@ class DeviceInventoryView(DeviceComponentsView):
table = tables.DeviceInventoryItemTable table = tables.DeviceInventoryItemTable
filterset = filtersets.InventoryItemFilterSet filterset = filtersets.InventoryItemFilterSet
filterset_form = forms.InventoryItemFilterForm filterset_form = forms.InventoryItemFilterForm
template_name = 'dcim/device/inventory.html' actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDelete)
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
}
tab = ViewTab( tab = ViewTab(
label=_('Inventory Items'), label=_('Inventory Items'),
badge=lambda obj: obj.inventory_item_count, badge=lambda obj: obj.inventory_item_count,
@@ -2393,16 +2442,16 @@ class DeviceBulkEditView(generic.BulkEditView):
form = forms.DeviceBulkEditForm form = forms.DeviceBulkEditForm
@register_model_view(Device, 'bulk_delete', path='delete', detail=False) @register_model_view(Device, 'bulk_rename', path='rename', detail=False)
class DeviceBulkDeleteView(generic.BulkDeleteView): class DeviceBulkRenameView(generic.BulkRenameView):
queryset = Device.objects.prefetch_related('device_type__manufacturer') queryset = Device.objects.all()
filterset = filtersets.DeviceFilterSet filterset = filtersets.DeviceFilterSet
table = tables.DeviceTable table = tables.DeviceTable
@register_model_view(Device, 'bulk_rename', path='rename', detail=False) @register_model_view(Device, 'bulk_delete', path='delete', detail=False)
class DeviceBulkRenameView(generic.BulkRenameView): class DeviceBulkDeleteView(generic.BulkDeleteView):
queryset = Device.objects.all() queryset = Device.objects.prefetch_related('device_type__manufacturer')
filterset = filtersets.DeviceFilterSet filterset = filtersets.DeviceFilterSet
table = tables.DeviceTable table = tables.DeviceTable
@@ -2417,6 +2466,7 @@ class ModuleListView(generic.ObjectListView):
filterset = filtersets.ModuleFilterSet filterset = filtersets.ModuleFilterSet
filterset_form = forms.ModuleFilterForm filterset_form = forms.ModuleFilterForm
table = tables.ModuleTable table = tables.ModuleTable
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
@register_model_view(Module) @register_model_view(Module)
@@ -2472,11 +2522,6 @@ class ConsolePortListView(generic.ObjectListView):
filterset = filtersets.ConsolePortFilterSet filterset = filtersets.ConsolePortFilterSet
filterset_form = forms.ConsolePortFilterForm filterset_form = forms.ConsolePortFilterForm
table = tables.ConsolePortTable table = tables.ConsolePortTable
template_name = 'dcim/component_list.html'
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
}
@register_model_view(ConsolePort) @register_model_view(ConsolePort)
@@ -2547,11 +2592,6 @@ class ConsoleServerPortListView(generic.ObjectListView):
filterset = filtersets.ConsoleServerPortFilterSet filterset = filtersets.ConsoleServerPortFilterSet
filterset_form = forms.ConsoleServerPortFilterForm filterset_form = forms.ConsoleServerPortFilterForm
table = tables.ConsoleServerPortTable table = tables.ConsoleServerPortTable
template_name = 'dcim/component_list.html'
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
}
@register_model_view(ConsoleServerPort) @register_model_view(ConsoleServerPort)
@@ -2622,11 +2662,6 @@ class PowerPortListView(generic.ObjectListView):
filterset = filtersets.PowerPortFilterSet filterset = filtersets.PowerPortFilterSet
filterset_form = forms.PowerPortFilterForm filterset_form = forms.PowerPortFilterForm
table = tables.PowerPortTable table = tables.PowerPortTable
template_name = 'dcim/component_list.html'
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
}
@register_model_view(PowerPort) @register_model_view(PowerPort)
@@ -2697,11 +2732,6 @@ class PowerOutletListView(generic.ObjectListView):
filterset = filtersets.PowerOutletFilterSet filterset = filtersets.PowerOutletFilterSet
filterset_form = forms.PowerOutletFilterForm filterset_form = forms.PowerOutletFilterForm
table = tables.PowerOutletTable table = tables.PowerOutletTable
template_name = 'dcim/component_list.html'
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
}
@register_model_view(PowerOutlet) @register_model_view(PowerOutlet)
@@ -2772,11 +2802,6 @@ class InterfaceListView(generic.ObjectListView):
filterset = filtersets.InterfaceFilterSet filterset = filtersets.InterfaceFilterSet
filterset_form = forms.InterfaceFilterForm filterset_form = forms.InterfaceFilterForm
table = tables.InterfaceTable table = tables.InterfaceTable
template_name = 'dcim/component_list.html'
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
}
@register_model_view(Interface) @register_model_view(Interface)
@@ -2920,11 +2945,6 @@ class FrontPortListView(generic.ObjectListView):
filterset = filtersets.FrontPortFilterSet filterset = filtersets.FrontPortFilterSet
filterset_form = forms.FrontPortFilterForm filterset_form = forms.FrontPortFilterForm
table = tables.FrontPortTable table = tables.FrontPortTable
template_name = 'dcim/component_list.html'
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
}
@register_model_view(FrontPort) @register_model_view(FrontPort)
@@ -2995,11 +3015,6 @@ class RearPortListView(generic.ObjectListView):
filterset = filtersets.RearPortFilterSet filterset = filtersets.RearPortFilterSet
filterset_form = forms.RearPortFilterForm filterset_form = forms.RearPortFilterForm
table = tables.RearPortTable table = tables.RearPortTable
template_name = 'dcim/component_list.html'
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
}
@register_model_view(RearPort) @register_model_view(RearPort)
@@ -3070,11 +3085,6 @@ class ModuleBayListView(generic.ObjectListView):
filterset = filtersets.ModuleBayFilterSet filterset = filtersets.ModuleBayFilterSet
filterset_form = forms.ModuleBayFilterForm filterset_form = forms.ModuleBayFilterForm
table = tables.ModuleBayTable table = tables.ModuleBayTable
template_name = 'dcim/component_list.html'
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
}
@register_model_view(ModuleBay) @register_model_view(ModuleBay)
@@ -3136,11 +3146,6 @@ class DeviceBayListView(generic.ObjectListView):
filterset = filtersets.DeviceBayFilterSet filterset = filtersets.DeviceBayFilterSet
filterset_form = forms.DeviceBayFilterForm filterset_form = forms.DeviceBayFilterForm
table = tables.DeviceBayTable table = tables.DeviceBayTable
template_name = 'dcim/component_list.html'
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
}
@register_model_view(DeviceBay) @register_model_view(DeviceBay)
@@ -3283,11 +3288,6 @@ class InventoryItemListView(generic.ObjectListView):
filterset = filtersets.InventoryItemFilterSet filterset = filtersets.InventoryItemFilterSet
filterset_form = forms.InventoryItemFilterForm filterset_form = forms.InventoryItemFilterForm
table = tables.InventoryItemTable table = tables.InventoryItemTable
template_name = 'dcim/component_list.html'
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_rename': {'change'},
}
@register_model_view(InventoryItem) @register_model_view(InventoryItem)
@@ -3410,6 +3410,11 @@ class InventoryItemRoleBulkEditView(generic.BulkEditView):
form = forms.InventoryItemRoleBulkEditForm form = forms.InventoryItemRoleBulkEditForm
@register_model_view(InventoryItemRole, 'bulk_rename', path='rename', detail=False)
class InventoryItemRoleBulkRenameView(generic.BulkRenameView):
queryset = InventoryItemRole.objects.all()
@register_model_view(InventoryItemRole, 'bulk_delete', path='delete', detail=False) @register_model_view(InventoryItemRole, 'bulk_delete', path='delete', detail=False)
class InventoryItemRoleBulkDeleteView(generic.BulkDeleteView): class InventoryItemRoleBulkDeleteView(generic.BulkDeleteView):
queryset = InventoryItemRole.objects.annotate( queryset = InventoryItemRole.objects.annotate(
@@ -3607,6 +3612,12 @@ class CableBulkEditView(generic.BulkEditView):
form = forms.CableBulkEditForm form = forms.CableBulkEditForm
@register_model_view(Cable, 'bulk_rename', path='rename', detail=False)
class CableBulkRenameView(generic.BulkRenameView):
queryset = Cable.objects.all()
field_name = 'label'
@register_model_view(Cable, 'bulk_delete', path='delete', detail=False) @register_model_view(Cable, 'bulk_delete', path='delete', detail=False)
class CableBulkDeleteView(generic.BulkDeleteView): class CableBulkDeleteView(generic.BulkDeleteView):
queryset = Cable.objects.prefetch_related( queryset = Cable.objects.prefetch_related(
@@ -3627,9 +3638,7 @@ class ConsoleConnectionsListView(generic.ObjectListView):
filterset_form = forms.ConsoleConnectionFilterForm filterset_form = forms.ConsoleConnectionFilterForm
table = tables.ConsoleConnectionTable table = tables.ConsoleConnectionTable
template_name = 'dcim/connections_list.html' template_name = 'dcim/connections_list.html'
actions = { actions = (BulkExport,)
'export': {'view'},
}
def get_extra_context(self, request): def get_extra_context(self, request):
return { return {
@@ -3643,9 +3652,7 @@ class PowerConnectionsListView(generic.ObjectListView):
filterset_form = forms.PowerConnectionFilterForm filterset_form = forms.PowerConnectionFilterForm
table = tables.PowerConnectionTable table = tables.PowerConnectionTable
template_name = 'dcim/connections_list.html' template_name = 'dcim/connections_list.html'
actions = { actions = (BulkExport,)
'export': {'view'},
}
def get_extra_context(self, request): def get_extra_context(self, request):
return { return {
@@ -3659,9 +3666,7 @@ class InterfaceConnectionsListView(generic.ObjectListView):
filterset_form = forms.InterfaceConnectionFilterForm filterset_form = forms.InterfaceConnectionFilterForm
table = tables.InterfaceConnectionTable table = tables.InterfaceConnectionTable
template_name = 'dcim/connections_list.html' template_name = 'dcim/connections_list.html'
actions = { actions = (BulkExport,)
'export': {'view'},
}
def get_extra_context(self, request): def get_extra_context(self, request):
return { return {
@@ -3905,6 +3910,11 @@ class VirtualChassisBulkEditView(generic.BulkEditView):
form = forms.VirtualChassisBulkEditForm form = forms.VirtualChassisBulkEditForm
@register_model_view(VirtualChassis, 'bulk_rename', path='rename', detail=False)
class VirtualChassisBulkRenameView(generic.BulkRenameView):
queryset = VirtualChassis.objects.all()
@register_model_view(VirtualChassis, 'bulk_delete', path='delete', detail=False) @register_model_view(VirtualChassis, 'bulk_delete', path='delete', detail=False)
class VirtualChassisBulkDeleteView(generic.BulkDeleteView): class VirtualChassisBulkDeleteView(generic.BulkDeleteView):
queryset = VirtualChassis.objects.all() queryset = VirtualChassis.objects.all()
@@ -3962,6 +3972,11 @@ class PowerPanelBulkEditView(generic.BulkEditView):
form = forms.PowerPanelBulkEditForm form = forms.PowerPanelBulkEditForm
@register_model_view(PowerPanel, 'bulk_rename', path='rename', detail=False)
class PowerPanelBulkRenameView(generic.BulkRenameView):
queryset = PowerPanel.objects.all()
@register_model_view(PowerPanel, 'bulk_delete', path='delete', detail=False) @register_model_view(PowerPanel, 'bulk_delete', path='delete', detail=False)
class PowerPanelBulkDeleteView(generic.BulkDeleteView): class PowerPanelBulkDeleteView(generic.BulkDeleteView):
queryset = PowerPanel.objects.annotate( queryset = PowerPanel.objects.annotate(
@@ -4014,6 +4029,11 @@ class PowerFeedBulkEditView(generic.BulkEditView):
form = forms.PowerFeedBulkEditForm form = forms.PowerFeedBulkEditForm
@register_model_view(PowerFeed, 'bulk_rename', path='rename', detail=False)
class PowerFeedBulkRenameView(generic.BulkRenameView):
queryset = PowerFeed.objects.all()
@register_model_view(PowerFeed, 'bulk_disconnect', path='disconnect', detail=False) @register_model_view(PowerFeed, 'bulk_disconnect', path='disconnect', detail=False)
class PowerFeedBulkDisconnectView(BulkDisconnectView): class PowerFeedBulkDisconnectView(BulkDisconnectView):
queryset = PowerFeed.objects.all() queryset = PowerFeed.objects.all()
@@ -4042,6 +4062,7 @@ class VirtualDeviceContextListView(generic.ObjectListView):
filterset = filtersets.VirtualDeviceContextFilterSet filterset = filtersets.VirtualDeviceContextFilterSet
filterset_form = forms.VirtualDeviceContextFilterForm filterset_form = forms.VirtualDeviceContextFilterForm
table = tables.VirtualDeviceContextTable table = tables.VirtualDeviceContextTable
actions = (AddObject, BulkImport, BulkEdit, BulkRename, BulkExport, BulkDelete)
@register_model_view(VirtualDeviceContext) @register_model_view(VirtualDeviceContext)
@@ -4086,6 +4107,11 @@ class VirtualDeviceContextBulkEditView(generic.BulkEditView):
form = forms.VirtualDeviceContextBulkEditForm form = forms.VirtualDeviceContextBulkEditForm
@register_model_view(VirtualDeviceContext, 'bulk_rename', path='rename', detail=False)
class VirtualDeviceContextBulkRenameView(generic.BulkRenameView):
queryset = VirtualDeviceContext.objects.all()
@register_model_view(VirtualDeviceContext, 'bulk_delete', path='delete', detail=False) @register_model_view(VirtualDeviceContext, 'bulk_delete', path='delete', detail=False)
class VirtualDeviceContextBulkDeleteView(generic.BulkDeleteView): class VirtualDeviceContextBulkDeleteView(generic.BulkDeleteView):
queryset = VirtualDeviceContext.objects.all() queryset = VirtualDeviceContext.objects.all()
@@ -4103,6 +4129,7 @@ class MACAddressListView(generic.ObjectListView):
filterset = filtersets.MACAddressFilterSet filterset = filtersets.MACAddressFilterSet
filterset_form = forms.MACAddressFilterForm filterset_form = forms.MACAddressFilterForm
table = tables.MACAddressTable table = tables.MACAddressTable
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
@register_model_view(MACAddress) @register_model_view(MACAddress)

View File

@@ -1,4 +1,3 @@
from .serializers_.objecttypes import *
from .serializers_.attachments import * from .serializers_.attachments import *
from .serializers_.bookmarks import * from .serializers_.bookmarks import *
from .serializers_.customfields import * from .serializers_.customfields import *

View File

@@ -1,16 +0,0 @@
from rest_framework import serializers
from core.models import ObjectType
from netbox.api.serializers import BaseModelSerializer
__all__ = (
'ObjectTypeSerializer',
)
class ObjectTypeSerializer(BaseModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='extras-api:objecttype-detail')
class Meta:
model = ObjectType
fields = ['id', 'url', 'display', 'app_label', 'model']

View File

@@ -1,5 +1,6 @@
from django.urls import include, path from django.urls import include, path
from core.api.views import ObjectTypeViewSet
from netbox.api.routers import NetBoxRouter from netbox.api.routers import NetBoxRouter
from . import views from . import views
@@ -26,7 +27,9 @@ router.register('journal-entries', views.JournalEntryViewSet)
router.register('config-contexts', views.ConfigContextViewSet) router.register('config-contexts', views.ConfigContextViewSet)
router.register('config-templates', views.ConfigTemplateViewSet) router.register('config-templates', views.ConfigTemplateViewSet)
router.register('scripts', views.ScriptViewSet, basename='script') router.register('scripts', views.ScriptViewSet, basename='script')
router.register('object-types', views.ObjectTypeViewSet)
# TODO: Remove in NetBox v4.5
router.register('object-types', ObjectTypeViewSet)
app_name = 'extras-api' app_name = 'extras-api'
urlpatterns = [ urlpatterns = [

View File

@@ -10,10 +10,9 @@ from rest_framework.mixins import ListModelMixin, RetrieveModelMixin
from rest_framework.renderers import JSONRenderer from rest_framework.renderers import JSONRenderer
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.routers import APIRootView from rest_framework.routers import APIRootView
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet from rest_framework.viewsets import ModelViewSet
from rq import Worker from rq import Worker
from core.models import ObjectType
from extras import filtersets from extras import filtersets
from extras.jobs import ScriptJob from extras.jobs import ScriptJob
from extras.models import * from extras.models import *
@@ -314,20 +313,6 @@ class ScriptViewSet(ModelViewSet):
return Response(input_serializer.errors, status=status.HTTP_400_BAD_REQUEST) return Response(input_serializer.errors, status=status.HTTP_400_BAD_REQUEST)
#
# Object types
#
class ObjectTypeViewSet(ReadOnlyModelViewSet):
"""
Read-only list of ObjectTypes.
"""
permission_classes = [IsAuthenticatedOrLoginNotRequired]
queryset = ObjectType.objects.order_by('app_label', 'model')
serializer_class = serializers.ObjectTypeSerializer
filterset_class = filtersets.ObjectTypeFilterSet
# #
# User dashboard # User dashboard
# #

View File

@@ -29,7 +29,6 @@ __all__ = (
'JournalEntryFilterSet', 'JournalEntryFilterSet',
'LocalConfigContextFilterSet', 'LocalConfigContextFilterSet',
'NotificationGroupFilterSet', 'NotificationGroupFilterSet',
'ObjectTypeFilterSet',
'SavedFilterFilterSet', 'SavedFilterFilterSet',
'ScriptFilterSet', 'ScriptFilterSet',
'TableConfigFilterSet', 'TableConfigFilterSet',
@@ -788,26 +787,3 @@ class LocalConfigContextFilterSet(django_filters.FilterSet):
def _local_context_data(self, queryset, name, value): def _local_context_data(self, queryset, name, value):
return queryset.exclude(local_context_data__isnull=value) return queryset.exclude(local_context_data__isnull=value)
#
# ContentTypes
#
class ObjectTypeFilterSet(django_filters.FilterSet):
q = django_filters.CharFilter(
method='search',
label=_('Search'),
)
class Meta:
model = ObjectType
fields = ('id', 'app_label', 'model')
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
Q(app_label__icontains=value) |
Q(model__icontains=value)
)

View File

@@ -90,7 +90,10 @@ class ScriptJob(JobRunner):
request: The WSGI request associated with this execution (if any) request: The WSGI request associated with this execution (if any)
commit: Passed through to Script.run() commit: Passed through to Script.run()
""" """
script = ScriptModel.objects.get(pk=self.job.object_id).python_class() script_model = ScriptModel.objects.get(pk=self.job.object_id)
self.logger.debug(f"Found ScriptModel ID {script_model.pk}")
script = script_model.python_class()
self.logger.debug(f"Loaded script {script.full_name}")
# Add files to form data # Add files to form data
if request: if request:
@@ -100,6 +103,7 @@ class ScriptJob(JobRunner):
# Add the current request as a property of the script # Add the current request as a property of the script
script.request = request script.request = request
self.logger.debug(f"Request ID: {request.id}")
# Execute the script. If commit is True, wrap it with the event_tracking context manager to ensure we process # Execute the script. If commit is True, wrap it with the event_tracking context manager to ensure we process
# change logging, event rules, etc. # change logging, event rules, etc.

View File

@@ -14,9 +14,16 @@ from utilities.proxy import resolve_proxies
class Command(BaseCommand): class Command(BaseCommand):
help = "Perform nightly housekeeping tasks. (This command can be run at any time.)" help = "Perform nightly housekeeping tasks [DEPRECATED]"
def handle(self, *args, **options): def handle(self, *args, **options):
self.stdout.write(
"Running this command is no longer necessary: All housekeeping tasks\n"
"are addressed automatically via NetBox's built-in job scheduler. It\n"
"will be removed in a future release.",
self.style.WARNING
)
config = Config() config = Config()
# Clear expired authentication sessions (essentially replicating the `clearsessions` command) # Clear expired authentication sessions (essentially replicating the `clearsessions` command)

View File

@@ -3,7 +3,6 @@ import datetime
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.urls import reverse from django.urls import reverse
from django.utils.timezone import make_aware, now from django.utils.timezone import make_aware, now
from rest_framework import status
from core.choices import ManagedFileRootPathChoices from core.choices import ManagedFileRootPathChoices
from core.events import * from core.events import *
@@ -921,22 +920,6 @@ class CreatedUpdatedFilterTest(APITestCase):
self.assertEqual(response.data['results'][0]['id'], rack2.pk) self.assertEqual(response.data['results'][0]['id'], rack2.pk)
class ObjectTypeTest(APITestCase):
def test_list_objects(self):
object_type_count = ObjectType.objects.count()
response = self.client.get(reverse('extras-api:objecttype-list'), **self.header)
self.assertHttpStatus(response, status.HTTP_200_OK)
self.assertEqual(response.data['count'], object_type_count)
def test_get_object(self):
object_type = ObjectType.objects.first()
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): class SubscriptionTest(APIViewTestCases.APIViewTestCase):
model = Subscription model = Subscription
brief_fields = ['display', 'id', 'object_id', 'object_type', 'url', 'user'] brief_fields = ['display', 'id', 'object_id', 'object_type', 'url', 'user']

View File

@@ -14,12 +14,13 @@ from jinja2.exceptions import TemplateError
from core.choices import ManagedFileRootPathChoices from core.choices import ManagedFileRootPathChoices
from core.models import Job from core.models import Job
from core.object_actions import BulkSync
from dcim.models import Device, DeviceRole, Platform from dcim.models import Device, DeviceRole, Platform
from extras.choices import LogLevelChoices from extras.choices import LogLevelChoices
from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm
from extras.dashboard.utils import get_widget_class from extras.dashboard.utils import get_widget_class
from extras.utils import SharedObjectViewMixin from extras.utils import SharedObjectViewMixin
from netbox.constants import DEFAULT_ACTION_PERMISSIONS from netbox.object_actions import *
from netbox.views import generic from netbox.views import generic
from netbox.views.generic.mixins import TableMixin from netbox.views.generic.mixins import TableMixin
from utilities.forms import ConfirmationForm, get_field_value from utilities.forms import ConfirmationForm, get_field_value
@@ -96,6 +97,11 @@ class CustomFieldBulkEditView(generic.BulkEditView):
form = forms.CustomFieldBulkEditForm form = forms.CustomFieldBulkEditForm
@register_model_view(CustomField, 'bulk_rename', path='rename', detail=False)
class CustomFieldBulkRenameView(generic.BulkRenameView):
queryset = CustomField.objects.all()
@register_model_view(CustomField, 'bulk_delete', path='delete', detail=False) @register_model_view(CustomField, 'bulk_delete', path='delete', detail=False)
class CustomFieldBulkDeleteView(generic.BulkDeleteView): class CustomFieldBulkDeleteView(generic.BulkDeleteView):
queryset = CustomField.objects.select_related('choice_set') queryset = CustomField.objects.select_related('choice_set')
@@ -165,6 +171,11 @@ class CustomFieldChoiceSetBulkEditView(generic.BulkEditView):
form = forms.CustomFieldChoiceSetBulkEditForm form = forms.CustomFieldChoiceSetBulkEditForm
@register_model_view(CustomFieldChoiceSet, 'bulk_rename', path='rename', detail=False)
class CustomFieldChoiceSetBulkRenameView(generic.BulkRenameView):
queryset = CustomFieldChoiceSet.objects.all()
@register_model_view(CustomFieldChoiceSet, 'bulk_delete', path='delete', detail=False) @register_model_view(CustomFieldChoiceSet, 'bulk_delete', path='delete', detail=False)
class CustomFieldChoiceSetBulkDeleteView(generic.BulkDeleteView): class CustomFieldChoiceSetBulkDeleteView(generic.BulkDeleteView):
queryset = CustomFieldChoiceSet.objects.all() queryset = CustomFieldChoiceSet.objects.all()
@@ -215,6 +226,11 @@ class CustomLinkBulkEditView(generic.BulkEditView):
form = forms.CustomLinkBulkEditForm form = forms.CustomLinkBulkEditForm
@register_model_view(CustomLink, 'bulk_rename', path='rename', detail=False)
class CustomLinkBulkRenameView(generic.BulkRenameView):
queryset = CustomLink.objects.all()
@register_model_view(CustomLink, 'bulk_delete', path='delete', detail=False) @register_model_view(CustomLink, 'bulk_delete', path='delete', detail=False)
class CustomLinkBulkDeleteView(generic.BulkDeleteView): class CustomLinkBulkDeleteView(generic.BulkDeleteView):
queryset = CustomLink.objects.all() queryset = CustomLink.objects.all()
@@ -232,11 +248,7 @@ class ExportTemplateListView(generic.ObjectListView):
filterset = filtersets.ExportTemplateFilterSet filterset = filtersets.ExportTemplateFilterSet
filterset_form = forms.ExportTemplateFilterForm filterset_form = forms.ExportTemplateFilterForm
table = tables.ExportTemplateTable table = tables.ExportTemplateTable
template_name = 'extras/exporttemplate_list.html' actions = (AddObject, BulkImport, BulkSync, BulkExport, BulkEdit, BulkRename, BulkDelete)
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_sync': {'sync'},
}
@register_model_view(ExportTemplate) @register_model_view(ExportTemplate)
@@ -270,6 +282,11 @@ class ExportTemplateBulkEditView(generic.BulkEditView):
form = forms.ExportTemplateBulkEditForm form = forms.ExportTemplateBulkEditForm
@register_model_view(ExportTemplate, 'bulk_rename', path='rename', detail=False)
class ExportTemplateBulkRenameView(generic.BulkRenameView):
queryset = ExportTemplate.objects.all()
@register_model_view(ExportTemplate, 'bulk_delete', path='delete', detail=False) @register_model_view(ExportTemplate, 'bulk_delete', path='delete', detail=False)
class ExportTemplateBulkDeleteView(generic.BulkDeleteView): class ExportTemplateBulkDeleteView(generic.BulkDeleteView):
queryset = ExportTemplate.objects.all() queryset = ExportTemplate.objects.all()
@@ -330,6 +347,11 @@ class SavedFilterBulkEditView(SharedObjectViewMixin, generic.BulkEditView):
form = forms.SavedFilterBulkEditForm form = forms.SavedFilterBulkEditForm
@register_model_view(SavedFilter, 'bulk_rename', path='rename', detail=False)
class SavedFilterBulkRenameView(generic.BulkRenameView):
queryset = SavedFilter.objects.all()
@register_model_view(SavedFilter, 'bulk_delete', path='delete', detail=False) @register_model_view(SavedFilter, 'bulk_delete', path='delete', detail=False)
class SavedFilterBulkDeleteView(SharedObjectViewMixin, generic.BulkDeleteView): class SavedFilterBulkDeleteView(SharedObjectViewMixin, generic.BulkDeleteView):
queryset = SavedFilter.objects.all() queryset = SavedFilter.objects.all()
@@ -347,9 +369,7 @@ class TableConfigListView(SharedObjectViewMixin, generic.ObjectListView):
filterset = filtersets.TableConfigFilterSet filterset = filtersets.TableConfigFilterSet
filterset_form = forms.TableConfigFilterForm filterset_form = forms.TableConfigFilterForm
table = tables.TableConfigTable table = tables.TableConfigTable
actions = { actions = (BulkExport, BulkEdit, BulkRename, BulkDelete)
'export': {'view'},
}
@register_model_view(TableConfig) @register_model_view(TableConfig)
@@ -389,6 +409,11 @@ class TableConfigBulkEditView(SharedObjectViewMixin, generic.BulkEditView):
form = forms.TableConfigBulkEditForm form = forms.TableConfigBulkEditForm
@register_model_view(TableConfig, 'bulk_rename', path='rename', detail=False)
class TableConfigBulkRenameView(generic.BulkRenameView):
queryset = TableConfig.objects.all()
@register_model_view(TableConfig, 'bulk_delete', path='delete', detail=False) @register_model_view(TableConfig, 'bulk_delete', path='delete', detail=False)
class TableConfigBulkDeleteView(SharedObjectViewMixin, generic.BulkDeleteView): class TableConfigBulkDeleteView(SharedObjectViewMixin, generic.BulkDeleteView):
queryset = TableConfig.objects.all() queryset = TableConfig.objects.all()
@@ -470,6 +495,11 @@ class NotificationGroupBulkEditView(generic.BulkEditView):
form = forms.NotificationGroupBulkEditForm form = forms.NotificationGroupBulkEditForm
@register_model_view(NotificationGroup, 'bulk_rename', path='rename', detail=False)
class NotificationGroupBulkRenameView(generic.BulkRenameView):
queryset = NotificationGroup.objects.all()
@register_model_view(NotificationGroup, 'bulk_delete', path='delete', detail=False) @register_model_view(NotificationGroup, 'bulk_delete', path='delete', detail=False)
class NotificationGroupBulkDeleteView(generic.BulkDeleteView): class NotificationGroupBulkDeleteView(generic.BulkDeleteView):
queryset = NotificationGroup.objects.all() queryset = NotificationGroup.objects.all()
@@ -616,6 +646,11 @@ class WebhookBulkEditView(generic.BulkEditView):
form = forms.WebhookBulkEditForm form = forms.WebhookBulkEditForm
@register_model_view(Webhook, 'bulk_rename', path='rename', detail=False)
class WebhookBulkRenameView(generic.BulkRenameView):
queryset = Webhook.objects.all()
@register_model_view(Webhook, 'bulk_delete', path='delete', detail=False) @register_model_view(Webhook, 'bulk_delete', path='delete', detail=False)
class WebhookBulkDeleteView(generic.BulkDeleteView): class WebhookBulkDeleteView(generic.BulkDeleteView):
queryset = Webhook.objects.all() queryset = Webhook.objects.all()
@@ -666,6 +701,11 @@ class EventRuleBulkEditView(generic.BulkEditView):
form = forms.EventRuleBulkEditForm form = forms.EventRuleBulkEditForm
@register_model_view(EventRule, 'bulk_rename', path='rename', detail=False)
class EventRuleBulkRenameView(generic.BulkRenameView):
queryset = EventRule.objects.all()
@register_model_view(EventRule, 'bulk_delete', path='delete', detail=False) @register_model_view(EventRule, 'bulk_delete', path='delete', detail=False)
class EventRuleBulkDeleteView(generic.BulkDeleteView): class EventRuleBulkDeleteView(generic.BulkDeleteView):
queryset = EventRule.objects.all() queryset = EventRule.objects.all()
@@ -740,6 +780,11 @@ class TagBulkEditView(generic.BulkEditView):
form = forms.TagBulkEditForm form = forms.TagBulkEditForm
@register_model_view(Tag, 'bulk_rename', path='rename', detail=False)
class TagBulkRenameView(generic.BulkRenameView):
queryset = Tag.objects.all()
@register_model_view(Tag, 'bulk_delete', path='delete', detail=False) @register_model_view(Tag, 'bulk_delete', path='delete', detail=False)
class TagBulkDeleteView(generic.BulkDeleteView): class TagBulkDeleteView(generic.BulkDeleteView):
queryset = Tag.objects.annotate( queryset = Tag.objects.annotate(
@@ -758,13 +803,7 @@ class ConfigContextListView(generic.ObjectListView):
filterset = filtersets.ConfigContextFilterSet filterset = filtersets.ConfigContextFilterSet
filterset_form = forms.ConfigContextFilterForm filterset_form = forms.ConfigContextFilterForm
table = tables.ConfigContextTable table = tables.ConfigContextTable
template_name = 'extras/configcontext_list.html' actions = (AddObject, BulkSync, BulkEdit, BulkRename, BulkDelete)
actions = {
'add': {'add'},
'bulk_edit': {'change'},
'bulk_delete': {'delete'},
'bulk_sync': {'sync'},
}
@register_model_view(ConfigContext) @register_model_view(ConfigContext)
@@ -825,6 +864,11 @@ class ConfigContextBulkEditView(generic.BulkEditView):
form = forms.ConfigContextBulkEditForm form = forms.ConfigContextBulkEditForm
@register_model_view(ConfigContext, 'bulk_rename', path='rename', detail=False)
class ConfigContextBulkRenameView(generic.BulkRenameView):
queryset = ConfigContext.objects.all()
@register_model_view(ConfigContext, 'bulk_delete', path='delete', detail=False) @register_model_view(ConfigContext, 'bulk_delete', path='delete', detail=False)
class ConfigContextBulkDeleteView(generic.BulkDeleteView): class ConfigContextBulkDeleteView(generic.BulkDeleteView):
queryset = ConfigContext.objects.all() queryset = ConfigContext.objects.all()
@@ -877,11 +921,7 @@ class ConfigTemplateListView(generic.ObjectListView):
filterset = filtersets.ConfigTemplateFilterSet filterset = filtersets.ConfigTemplateFilterSet
filterset_form = forms.ConfigTemplateFilterForm filterset_form = forms.ConfigTemplateFilterForm
table = tables.ConfigTemplateTable table = tables.ConfigTemplateTable
template_name = 'extras/configtemplate_list.html' actions = (AddObject, BulkImport, BulkExport, BulkSync, BulkEdit, BulkRename, BulkDelete)
actions = {
**DEFAULT_ACTION_PERMISSIONS,
'bulk_sync': {'sync'},
}
@register_model_view(ConfigTemplate) @register_model_view(ConfigTemplate)
@@ -915,6 +955,11 @@ class ConfigTemplateBulkEditView(generic.BulkEditView):
form = forms.ConfigTemplateBulkEditForm form = forms.ConfigTemplateBulkEditForm
@register_model_view(ConfigTemplate, 'bulk_rename', path='rename', detail=False)
class ConfigTemplateBulkRenameView(generic.BulkRenameView):
queryset = ConfigTemplate.objects.all()
@register_model_view(ConfigTemplate, 'bulk_delete', path='delete', detail=False) @register_model_view(ConfigTemplate, 'bulk_delete', path='delete', detail=False)
class ConfigTemplateBulkDeleteView(generic.BulkDeleteView): class ConfigTemplateBulkDeleteView(generic.BulkDeleteView):
queryset = ConfigTemplate.objects.all() queryset = ConfigTemplate.objects.all()
@@ -992,9 +1037,7 @@ class ImageAttachmentListView(generic.ObjectListView):
filterset = filtersets.ImageAttachmentFilterSet filterset = filtersets.ImageAttachmentFilterSet
filterset_form = forms.ImageAttachmentFilterForm filterset_form = forms.ImageAttachmentFilterForm
table = tables.ImageAttachmentTable table = tables.ImageAttachmentTable
actions = { actions = (BulkExport,)
'export': {'view'},
}
@register_model_view(ImageAttachment, 'add', detail=False) @register_model_view(ImageAttachment, 'add', detail=False)
@@ -1038,12 +1081,7 @@ class JournalEntryListView(generic.ObjectListView):
filterset = filtersets.JournalEntryFilterSet filterset = filtersets.JournalEntryFilterSet
filterset_form = forms.JournalEntryFilterForm filterset_form = forms.JournalEntryFilterForm
table = tables.JournalEntryTable table = tables.JournalEntryTable
actions = { actions = (BulkImport, BulkEdit, BulkDelete)
'export': {'view'},
'bulk_import': {'add'},
'bulk_edit': {'change'},
'bulk_delete': {'delete'},
}
@register_model_view(JournalEntry) @register_model_view(JournalEntry)

View File

@@ -10,6 +10,7 @@ from dcim.filtersets import InterfaceFilterSet
from dcim.forms import InterfaceFilterForm from dcim.forms import InterfaceFilterForm
from dcim.models import Device, Interface, Site from dcim.models import Device, Interface, Site
from ipam.tables import VLANTranslationRuleTable from ipam.tables import VLANTranslationRuleTable
from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport
from netbox.views import generic from netbox.views import generic
from utilities.query import count_related from utilities.query import count_related
from utilities.tables import get_table_ordering from utilities.tables import get_table_ordering
@@ -86,6 +87,11 @@ class VRFBulkEditView(generic.BulkEditView):
form = forms.VRFBulkEditForm form = forms.VRFBulkEditForm
@register_model_view(VRF, 'bulk_rename', path='rename', detail=False)
class VRFBulkRenameView(generic.BulkRenameView):
queryset = VRF.objects.all()
@register_model_view(VRF, 'bulk_delete', path='delete', detail=False) @register_model_view(VRF, 'bulk_delete', path='delete', detail=False)
class VRFBulkDeleteView(generic.BulkDeleteView): class VRFBulkDeleteView(generic.BulkDeleteView):
queryset = VRF.objects.all() queryset = VRF.objects.all()
@@ -136,6 +142,11 @@ class RouteTargetBulkEditView(generic.BulkEditView):
form = forms.RouteTargetBulkEditForm form = forms.RouteTargetBulkEditForm
@register_model_view(RouteTarget, 'bulk_rename', path='rename', detail=False)
class RouteTargetBulkRenameView(generic.BulkRenameView):
queryset = RouteTarget.objects.all()
@register_model_view(RouteTarget, 'bulk_delete', path='delete', detail=False) @register_model_view(RouteTarget, 'bulk_delete', path='delete', detail=False)
class RouteTargetBulkDeleteView(generic.BulkDeleteView): class RouteTargetBulkDeleteView(generic.BulkDeleteView):
queryset = RouteTarget.objects.all() queryset = RouteTarget.objects.all()
@@ -195,6 +206,11 @@ class RIRBulkEditView(generic.BulkEditView):
form = forms.RIRBulkEditForm form = forms.RIRBulkEditForm
@register_model_view(RIR, 'bulk_rename', path='rename', detail=False)
class RIRBulkRenameView(generic.BulkRenameView):
queryset = RIR.objects.all()
@register_model_view(RIR, 'bulk_delete', path='delete', detail=False) @register_model_view(RIR, 'bulk_delete', path='delete', detail=False)
class RIRBulkDeleteView(generic.BulkDeleteView): class RIRBulkDeleteView(generic.BulkDeleteView):
queryset = RIR.objects.annotate( queryset = RIR.objects.annotate(
@@ -268,6 +284,11 @@ class ASNRangeBulkEditView(generic.BulkEditView):
form = forms.ASNRangeBulkEditForm form = forms.ASNRangeBulkEditForm
@register_model_view(ASNRange, 'bulk_rename', path='rename', detail=False)
class ASNRangeBulkRenameView(generic.BulkRenameView):
queryset = ASNRange.objects.all()
@register_model_view(ASNRange, 'bulk_delete', path='delete', detail=False) @register_model_view(ASNRange, 'bulk_delete', path='delete', detail=False)
class ASNRangeBulkDeleteView(generic.BulkDeleteView): class ASNRangeBulkDeleteView(generic.BulkDeleteView):
queryset = ASNRange.objects.annotate_asn_counts() queryset = ASNRange.objects.annotate_asn_counts()
@@ -335,6 +356,11 @@ class ASNBulkEditView(generic.BulkEditView):
form = forms.ASNBulkEditForm form = forms.ASNBulkEditForm
@register_model_view(ASN, 'bulk_rename', path='rename', detail=False)
class ASNBulkRenameView(generic.BulkRenameView):
queryset = ASN.objects.all()
@register_model_view(ASN, 'bulk_delete', path='delete', detail=False) @register_model_view(ASN, 'bulk_delete', path='delete', detail=False)
class ASNBulkDeleteView(generic.BulkDeleteView): class ASNBulkDeleteView(generic.BulkDeleteView):
queryset = ASN.objects.annotate( queryset = ASN.objects.annotate(
@@ -356,6 +382,7 @@ class AggregateListView(generic.ObjectListView):
filterset = filtersets.AggregateFilterSet filterset = filtersets.AggregateFilterSet
filterset_form = forms.AggregateFilterForm filterset_form = forms.AggregateFilterForm
table = tables.AggregateTable table = tables.AggregateTable
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
@register_model_view(Aggregate) @register_model_view(Aggregate)
@@ -488,6 +515,11 @@ class RoleBulkEditView(generic.BulkEditView):
form = forms.RoleBulkEditForm form = forms.RoleBulkEditForm
@register_model_view(Role, 'bulk_rename', path='rename', detail=False)
class RoleBulkRenameView(generic.BulkRenameView):
queryset = Role.objects.all()
@register_model_view(Role, 'bulk_delete', path='delete', detail=False) @register_model_view(Role, 'bulk_delete', path='delete', detail=False)
class RoleBulkDeleteView(generic.BulkDeleteView): class RoleBulkDeleteView(generic.BulkDeleteView):
queryset = Role.objects.all() queryset = Role.objects.all()
@@ -506,6 +538,7 @@ class PrefixListView(generic.ObjectListView):
filterset_form = forms.PrefixFilterForm filterset_form = forms.PrefixFilterForm
table = tables.PrefixTable table = tables.PrefixTable
template_name = 'ipam/prefix_list.html' template_name = 'ipam/prefix_list.html'
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
@register_model_view(Prefix) @register_model_view(Prefix)
@@ -766,6 +799,11 @@ class IPRangeBulkEditView(generic.BulkEditView):
form = forms.IPRangeBulkEditForm form = forms.IPRangeBulkEditForm
@register_model_view(IPRange, 'bulk_rename', path='rename', detail=False)
class IPRangeBulkRenameView(generic.BulkRenameView):
queryset = IPRange.objects.all()
@register_model_view(IPRange, 'bulk_delete', path='delete', detail=False) @register_model_view(IPRange, 'bulk_delete', path='delete', detail=False)
class IPRangeBulkDeleteView(generic.BulkDeleteView): class IPRangeBulkDeleteView(generic.BulkDeleteView):
queryset = IPRange.objects.all() queryset = IPRange.objects.all()
@@ -783,6 +821,7 @@ class IPAddressListView(generic.ObjectListView):
filterset = filtersets.IPAddressFilterSet filterset = filtersets.IPAddressFilterSet
filterset_form = forms.IPAddressFilterForm filterset_form = forms.IPAddressFilterForm
table = tables.IPAddressTable table = tables.IPAddressTable
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
@register_model_view(IPAddress) @register_model_view(IPAddress)
@@ -1006,6 +1045,11 @@ class VLANGroupBulkEditView(generic.BulkEditView):
form = forms.VLANGroupBulkEditForm form = forms.VLANGroupBulkEditForm
@register_model_view(VLANGroup, 'bulk_rename', path='rename', detail=False)
class VLANGroupBulkRenameView(generic.BulkRenameView):
queryset = VLANGroup.objects.all()
@register_model_view(VLANGroup, 'bulk_delete', path='delete', detail=False) @register_model_view(VLANGroup, 'bulk_delete', path='delete', detail=False)
class VLANGroupBulkDeleteView(generic.BulkDeleteView): class VLANGroupBulkDeleteView(generic.BulkDeleteView):
queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags') queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags')
@@ -1095,6 +1139,11 @@ class VLANTranslationPolicyBulkEditView(generic.BulkEditView):
form = forms.VLANTranslationPolicyBulkEditForm form = forms.VLANTranslationPolicyBulkEditForm
@register_model_view(VLANTranslationPolicy, 'bulk_rename', path='rename', detail=False)
class VLANTranslationPolicyBulkRenameView(generic.BulkRenameView):
queryset = VLANTranslationPolicy.objects.all()
@register_model_view(VLANTranslationPolicy, 'bulk_delete', path='delete', detail=False) @register_model_view(VLANTranslationPolicy, 'bulk_delete', path='delete', detail=False)
class VLANTranslationPolicyBulkDeleteView(generic.BulkDeleteView): class VLANTranslationPolicyBulkDeleteView(generic.BulkDeleteView):
queryset = VLANTranslationPolicy.objects.all() queryset = VLANTranslationPolicy.objects.all()
@@ -1112,6 +1161,7 @@ class VLANTranslationRuleListView(generic.ObjectListView):
filterset = filtersets.VLANTranslationRuleFilterSet filterset = filtersets.VLANTranslationRuleFilterSet
filterset_form = forms.VLANTranslationRuleFilterForm filterset_form = forms.VLANTranslationRuleFilterForm
table = tables.VLANTranslationRuleTable table = tables.VLANTranslationRuleTable
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
@register_model_view(VLANTranslationRule) @register_model_view(VLANTranslationRule)
@@ -1244,6 +1294,11 @@ class FHRPGroupBulkEditView(generic.BulkEditView):
form = forms.FHRPGroupBulkEditForm form = forms.FHRPGroupBulkEditForm
@register_model_view(FHRPGroup, 'bulk_rename', path='rename', detail=False)
class FHRPGroupBulkRenameView(generic.BulkRenameView):
queryset = FHRPGroup.objects.all()
@register_model_view(FHRPGroup, 'bulk_delete', path='delete', detail=False) @register_model_view(FHRPGroup, 'bulk_delete', path='delete', detail=False)
class FHRPGroupBulkDeleteView(generic.BulkDeleteView): class FHRPGroupBulkDeleteView(generic.BulkDeleteView):
queryset = FHRPGroup.objects.all() queryset = FHRPGroup.objects.all()
@@ -1371,6 +1426,11 @@ class VLANBulkEditView(generic.BulkEditView):
form = forms.VLANBulkEditForm form = forms.VLANBulkEditForm
@register_model_view(VLAN, 'bulk_rename', path='rename', detail=False)
class VLANBulkRenameView(generic.BulkRenameView):
queryset = VLAN.objects.all()
@register_model_view(VLAN, 'bulk_delete', path='delete', detail=False) @register_model_view(VLAN, 'bulk_delete', path='delete', detail=False)
class VLANBulkDeleteView(generic.BulkDeleteView): class VLANBulkDeleteView(generic.BulkDeleteView):
queryset = VLAN.objects.all() queryset = VLAN.objects.all()
@@ -1421,6 +1481,11 @@ class ServiceTemplateBulkEditView(generic.BulkEditView):
form = forms.ServiceTemplateBulkEditForm form = forms.ServiceTemplateBulkEditForm
@register_model_view(ServiceTemplate, 'bulk_rename', path='rename', detail=False)
class ServiceTemplateBulkRenameView(generic.BulkRenameView):
queryset = ServiceTemplate.objects.all()
@register_model_view(ServiceTemplate, 'bulk_delete', path='delete', detail=False) @register_model_view(ServiceTemplate, 'bulk_delete', path='delete', detail=False)
class ServiceTemplateBulkDeleteView(generic.BulkDeleteView): class ServiceTemplateBulkDeleteView(generic.BulkDeleteView):
queryset = ServiceTemplate.objects.all() queryset = ServiceTemplate.objects.all()
@@ -1488,6 +1553,11 @@ class ServiceBulkEditView(generic.BulkEditView):
form = forms.ServiceBulkEditForm form = forms.ServiceBulkEditForm
@register_model_view(Service, 'bulk_rename', path='rename', detail=False)
class ServiceBulkRenameView(generic.BulkRenameView):
queryset = Service.objects.all()
@register_model_view(Service, 'bulk_delete', path='delete', detail=False) @register_model_view(Service, 'bulk_delete', path='delete', detail=False)
class ServiceBulkDeleteView(generic.BulkDeleteView): class ServiceBulkDeleteView(generic.BulkDeleteView):
queryset = Service.objects.prefetch_related('parent') queryset = Service.objects.prefetch_related('parent')

View File

@@ -28,7 +28,8 @@ ADVISORY_LOCK_KEYS = {
'job-schedules': 110100, 'job-schedules': 110100,
} }
# Default view action permission mapping # TODO: Remove in NetBox v4.6
# Legacy default view action permission mapping
DEFAULT_ACTION_PERMISSIONS = { DEFAULT_ACTION_PERMISSIONS = {
'add': {'add'}, 'add': {'add'},
'export': {'view'}, 'export': {'view'},
@@ -43,3 +44,10 @@ CENSOR_TOKEN_CHANGED = '***CHANGED***'
# Placeholder text for empty tables # Placeholder text for empty tables
EMPTY_TABLE_TEXT = 'No results found' EMPTY_TABLE_TEXT = 'No results found'
# CSV delimiters
CSV_DELIMITERS = {
'comma': ',',
'semicolon': ';',
'pipe': '|',
}

View File

@@ -8,12 +8,16 @@ from django_pglocks import advisory_lock
from rq.timeouts import JobTimeoutException from rq.timeouts import JobTimeoutException
from core.choices import JobStatusChoices from core.choices import JobStatusChoices
from core.events import JOB_COMPLETED, JOB_FAILED
from core.exceptions import JobFailed from core.exceptions import JobFailed
from core.models import Job, ObjectType from core.models import Job, ObjectType
from extras.models import Notification
from netbox.constants import ADVISORY_LOCK_KEYS from netbox.constants import ADVISORY_LOCK_KEYS
from netbox.registry import registry from netbox.registry import registry
from utilities.request import apply_request_processors
__all__ = ( __all__ = (
'AsyncViewJob',
'JobRunner', 'JobRunner',
'system_job', 'system_job',
) )
@@ -35,6 +39,19 @@ def system_job(interval):
return _wrapper return _wrapper
class JobLogHandler(logging.Handler):
"""
A logging handler which records entries on a Job.
"""
def __init__(self, job, *args, **kwargs):
super().__init__(*args, **kwargs)
self.job = job
def emit(self, record):
# Enter the record in the log of the associated Job
self.job.log(record)
class JobRunner(ABC): class JobRunner(ABC):
""" """
Background Job helper class. Background Job helper class.
@@ -53,6 +70,11 @@ class JobRunner(ABC):
""" """
self.job = job self.job = job
# Initiate the system logger
self.logger = logging.getLogger(f"netbox.jobs.{self.__class__.__name__}")
self.logger.setLevel(logging.DEBUG)
self.logger.addHandler(JobLogHandler(job))
@classproperty @classproperty
def name(cls): def name(cls):
return getattr(cls.Meta, 'name', cls.__name__) return getattr(cls.Meta, 'name', cls.__name__)
@@ -161,3 +183,35 @@ class JobRunner(ABC):
job.delete() job.delete()
return cls.enqueue(instance=instance, schedule_at=schedule_at, interval=interval, *args, **kwargs) return cls.enqueue(instance=instance, schedule_at=schedule_at, interval=interval, *args, **kwargs)
class AsyncViewJob(JobRunner):
"""
Execute a view as a background job.
"""
class Meta:
name = 'Async View'
def run(self, view_cls, request, **kwargs):
view = view_cls.as_view()
# Apply all registered request processors (e.g. event_tracking)
with apply_request_processors(request):
data = view(request)
self.job.data = {
'log': data.log,
'errors': data.errors,
}
# Notify the user
notification = Notification(
user=request.user,
object=self.job,
event_type=JOB_COMPLETED if not data.errors else JOB_FAILED,
)
notification.save()
# TODO: Waiting on fix for bug #19806
# if errors:
# raise JobFailed()

View File

@@ -1,8 +1,5 @@
from contextlib import ExitStack
import logging import logging
import uuid import uuid
import warnings
from django.conf import settings from django.conf import settings
from django.contrib import auth, messages from django.contrib import auth, messages
@@ -13,10 +10,10 @@ from django.db.utils import InternalError
from django.http import Http404, HttpResponseRedirect from django.http import Http404, HttpResponseRedirect
from netbox.config import clear_config, get_config from netbox.config import clear_config, get_config
from netbox.registry import registry
from netbox.views import handler_500 from netbox.views import handler_500
from utilities.api import is_api_request from utilities.api import is_api_request
from utilities.error_handlers import handle_rest_api_exception from utilities.error_handlers import handle_rest_api_exception
from utilities.request import apply_request_processors
__all__ = ( __all__ = (
'CoreMiddleware', 'CoreMiddleware',
@@ -36,12 +33,7 @@ class CoreMiddleware:
request.id = uuid.uuid4() request.id = uuid.uuid4()
# Apply all registered request processors # Apply all registered request processors
with ExitStack() as stack: with apply_request_processors(request):
for request_processor in registry['request_processors']:
try:
stack.enter_context(request_processor(request))
except Exception as e:
warnings.warn(f'Failed to initialize request processor {request_processor}: {e}')
response = self.get_response(request) response = self.get_response(request)
# Check if language cookie should be renewed # Check if language cookie should be renewed

View File

@@ -0,0 +1,180 @@
from django.urls import reverse
from django.urls.exceptions import NoReverseMatch
from django.utils.translation import gettext as _
from core.models import ObjectType
from extras.models import ExportTemplate
from utilities.querydict import prepare_cloned_fields
__all__ = (
'AddObject',
'BulkDelete',
'BulkEdit',
'BulkExport',
'BulkImport',
'BulkRename',
'CloneObject',
'DeleteObject',
'EditObject',
'ObjectAction',
)
class ObjectAction:
"""
Base class for single- and multi-object operations.
Params:
name: The action name appended to the module for view resolution
label: Human-friendly label for the rendered button
multi: Set to True if this action is performed by selecting multiple objects (i.e. using a table)
permissions_required: The set of permissions a user must have to perform the action
url_kwargs: The set of URL keyword arguments to pass when resolving the view's URL
"""
name = ''
label = None
multi = False
permissions_required = set()
url_kwargs = []
@classmethod
def get_url(cls, obj):
viewname = f'{obj._meta.app_label}:{obj._meta.model_name}_{cls.name}'
kwargs = {
kwarg: getattr(obj, kwarg) for kwarg in cls.url_kwargs
}
try:
return reverse(viewname, kwargs=kwargs)
except NoReverseMatch:
return
@classmethod
def get_context(cls, context, obj):
return {
'url': cls.get_url(obj),
'label': cls.label,
}
class AddObject(ObjectAction):
"""
Create a new object.
"""
name = 'add'
label = _('Add')
permissions_required = {'add'}
template_name = 'buttons/add.html'
class CloneObject(ObjectAction):
"""
Populate the new object form with select details from an existing object.
"""
name = 'add'
label = _('Clone')
permissions_required = {'add'}
template_name = 'buttons/clone.html'
@classmethod
def get_context(cls, context, obj):
param_string = prepare_cloned_fields(obj).urlencode()
url = f'{cls.get_url(obj)}?{param_string}' if param_string else None
return {
'url': url,
'label': cls.label,
}
class EditObject(ObjectAction):
"""
Edit a single object.
"""
name = 'edit'
label = _('Edit')
permissions_required = {'change'}
url_kwargs = ['pk']
template_name = 'buttons/edit.html'
class DeleteObject(ObjectAction):
"""
Delete a single object.
"""
name = 'delete'
label = _('Delete')
permissions_required = {'delete'}
url_kwargs = ['pk']
template_name = 'buttons/delete.html'
class BulkImport(ObjectAction):
"""
Import multiple objects at once.
"""
name = 'bulk_import'
label = _('Import')
permissions_required = {'add'}
template_name = 'buttons/import.html'
class BulkExport(ObjectAction):
"""
Export multiple objects at once.
"""
name = 'export'
label = _('Export')
permissions_required = {'view'}
template_name = 'buttons/export.html'
@classmethod
def get_context(cls, context, model):
object_type = ObjectType.objects.get_for_model(model)
user = context['request'].user
# Determine if the "all data" export returns CSV or YAML
data_format = 'YAML' if hasattr(object_type.model_class(), 'to_yaml') else 'CSV'
# Retrieve all export templates for this model
export_templates = ExportTemplate.objects.restrict(user, 'view').filter(object_types=object_type)
return {
'label': cls.label,
'perms': context['perms'],
'object_type': object_type,
'url_params': context['request'].GET.urlencode() if context['request'].GET else '',
'export_templates': export_templates,
'data_format': data_format,
}
class BulkEdit(ObjectAction):
"""
Change the value of one or more fields on a set of objects.
"""
name = 'bulk_edit'
label = _('Edit Selected')
multi = True
permissions_required = {'change'}
template_name = 'buttons/bulk_edit.html'
class BulkRename(ObjectAction):
"""
Rename multiple objects at once.
"""
name = 'bulk_rename'
label = _('Rename Selected')
multi = True
permissions_required = {'change'}
template_name = 'buttons/bulk_rename.html'
class BulkDelete(ObjectAction):
"""
Delete each of a set of objects.
"""
name = 'bulk_delete'
label = _('Delete Selected')
multi = True
permissions_required = {'delete'}
template_name = 'buttons/bulk_delete.html'

View File

@@ -72,6 +72,16 @@ PREFERENCES = {
), ),
description=_('The preferred syntax for displaying generic data within the UI') description=_('The preferred syntax for displaying generic data within the UI')
), ),
'csv_delimiter': UserPreference(
label=_('CSV delimiter'),
choices=(
('comma', 'Comma (,)'),
('semicolon', 'Semicolon (;)'),
('pipe', 'Pipe (|)'),
),
default='comma',
description=_('The character used to separate fields in CSV data')
),
} }

View File

@@ -16,6 +16,10 @@ class TestJobRunner(JobRunner):
def run(self, *args, **kwargs): def run(self, *args, **kwargs):
if kwargs.get('make_fail', False): if kwargs.get('make_fail', False):
raise JobFailed() raise JobFailed()
self.logger.debug("Debug message")
self.logger.info("Info message")
self.logger.warning("Warning message")
self.logger.error("Error message")
class JobRunnerTestCase(TestCase): class JobRunnerTestCase(TestCase):
@@ -51,8 +55,16 @@ class JobRunnerTest(JobRunnerTestCase):
def test_handle(self): def test_handle(self):
job = TestJobRunner.enqueue(immediate=True) job = TestJobRunner.enqueue(immediate=True)
# Check job status
self.assertEqual(job.status, JobStatusChoices.STATUS_COMPLETED) self.assertEqual(job.status, JobStatusChoices.STATUS_COMPLETED)
# Check logging
self.assertEqual(len(job.log_entries), 4)
self.assertEqual(job.log_entries[0]['message'], "Debug message")
self.assertEqual(job.log_entries[1]['message'], "Info message")
self.assertEqual(job.log_entries[2]['message'], "Warning message")
self.assertEqual(job.log_entries[3]['message'], "Error message")
def test_handle_failed(self): def test_handle_failed(self):
with disable_warnings('netbox.jobs'): with disable_warnings('netbox.jobs'):
job = TestJobRunner.enqueue(immediate=True, make_fail=True) job = TestJobRunner.enqueue(immediate=True, make_fail=True)

View File

@@ -15,18 +15,20 @@ from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse from django.urls import reverse
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from django_tables2.export import TableExport
from mptt.models import MPTTModel from mptt.models import MPTTModel
from core.models import ObjectType from core.models import ObjectType
from core.signals import clear_events from core.signals import clear_events
from extras.choices import CustomFieldUIEditableChoices from extras.choices import CustomFieldUIEditableChoices
from extras.models import CustomField, ExportTemplate from extras.models import CustomField, ExportTemplate
from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport, BulkRename
from utilities.error_handlers import handle_protectederror from utilities.error_handlers import handle_protectederror
from utilities.exceptions import AbortRequest, AbortTransaction, PermissionsViolation from utilities.exceptions import AbortRequest, AbortTransaction, PermissionsViolation
from utilities.export import TableExport
from utilities.forms import BulkRenameForm, ConfirmationForm, restrict_form_fields from utilities.forms import BulkRenameForm, ConfirmationForm, restrict_form_fields
from utilities.forms.bulk_import import BulkImportForm from utilities.forms.bulk_import import BulkImportForm
from utilities.htmx import htmx_partial from utilities.htmx import htmx_partial
from utilities.jobs import AsyncJobData, is_background_request, process_request_as_job
from utilities.permissions import get_permission_for_model from utilities.permissions import get_permission_for_model
from utilities.query import reapply_model_ordering from utilities.query import reapply_model_ordering
from utilities.request import safe_for_redirect from utilities.request import safe_for_redirect
@@ -54,12 +56,12 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
Attributes: Attributes:
filterset: A django-filter FilterSet that is applied to the queryset filterset: A django-filter FilterSet that is applied to the queryset
filterset_form: The form class used to render filter options 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 actions: An iterable of ObjectAction subclasses (see ActionsMixin)
action names must be prefixed with `bulk_`. (See ActionsMixin.)
""" """
template_name = 'generic/object_list.html' template_name = 'generic/object_list.html'
filterset = None filterset = None
filterset_form = None filterset_form = None
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkRename, BulkDelete)
def get_required_permission(self): def get_required_permission(self):
return get_permission_for_model(self.queryset.model, 'view') return get_permission_for_model(self.queryset.model, 'view')
@@ -76,7 +78,7 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
return '---\n'.join(yaml_data) return '---\n'.join(yaml_data)
def export_table(self, table, columns=None, filename=None): def export_table(self, table, columns=None, filename=None, delimiter=None):
""" """
Export all table data in CSV format. Export all table data in CSV format.
@@ -85,6 +87,7 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
columns: A list of specific columns to include. If None, all columns will be exported. columns: A list of specific columns to include. If None, all columns will be exported.
filename: The name of the file attachment sent to the client. If None, will be determined automatically filename: The name of the file attachment sent to the client. If None, will be determined automatically
from the queryset model name. from the queryset model name.
delimiter: The character used to separate columns (a comma is used by default)
""" """
exclude_columns = {'pk', 'actions'} exclude_columns = {'pk', 'actions'}
if columns: if columns:
@@ -95,7 +98,8 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
exporter = TableExport( exporter = TableExport(
export_format=TableExport.CSV, export_format=TableExport.CSV,
table=table, table=table,
exclude_columns=exclude_columns exclude_columns=exclude_columns,
delimiter=delimiter,
) )
return exporter.response( return exporter.response(
filename=filename or f'netbox_{self.queryset.model._meta.verbose_name_plural}.csv' filename=filename or f'netbox_{self.queryset.model._meta.verbose_name_plural}.csv'
@@ -150,15 +154,16 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
# Determine the available actions # Determine the available actions
actions = self.get_permitted_actions(request.user) actions = self.get_permitted_actions(request.user)
has_bulk_actions = any([a.startswith('bulk_') for a in actions]) has_table_actions = any(action.multi for action in actions)
if 'export' in request.GET: if 'export' in request.GET:
# Export the current table view # Export the current table view
if request.GET['export'] == 'table': if request.GET['export'] == 'table':
table = self.get_table(self.queryset, request, has_bulk_actions) table = self.get_table(self.queryset, request, has_table_actions)
columns = [name for name, _ in table.selected_columns] columns = [name for name, _ in table.selected_columns]
return self.export_table(table, columns) delimiter = request.user.config.get('csv_delimiter')
return self.export_table(table, columns, delimiter=delimiter)
# Render an ExportTemplate # Render an ExportTemplate
elif request.GET['export']: elif request.GET['export']:
@@ -174,11 +179,12 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
# Fall back to default table/YAML export # Fall back to default table/YAML export
else: else:
table = self.get_table(self.queryset, request, has_bulk_actions) table = self.get_table(self.queryset, request, has_table_actions)
return self.export_table(table) delimiter = request.user.config.get('csv_delimiter')
return self.export_table(table, delimiter=delimiter)
# Render the objects table # Render the objects table
table = self.get_table(self.queryset, request, has_bulk_actions) table = self.get_table(self.queryset, request, has_table_actions)
# If this is an HTMX request, return only the rendered table HTML # If this is an HTMX request, return only the rendered table HTML
if htmx_partial(request): if htmx_partial(request):
@@ -498,25 +504,32 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
if form.is_valid(): if form.is_valid():
logger.debug("Import form validation was successful") logger.debug("Import form validation was successful")
redirect_url = reverse(get_viewname(model, action='list'))
new_objects = []
# If indicated, defer this request to a background job & redirect the user
if form.cleaned_data['background_job']:
job_name = _('Bulk import {count} {object_type}').format(
count=len(form.cleaned_data['data']),
object_type=model._meta.verbose_name_plural,
)
if job := process_request_as_job(self.__class__, request, name=job_name):
msg = _('Created background job {job.pk}: <a href="{url}">{job.name}</a>').format(
url=job.get_absolute_url(),
job=job
)
messages.info(request, mark_safe(msg))
return redirect(redirect_url)
try: try:
# Iterate through data and bind each record to a new model form instance. # Iterate through data and bind each record to a new model form instance.
with transaction.atomic(using=router.db_for_write(model)): with transaction.atomic(using=router.db_for_write(model)):
new_objs = self.create_and_update_objects(form, request) new_objects = self.create_and_update_objects(form, request)
# Enforce object-level permissions # Enforce object-level permissions
if self.queryset.filter(pk__in=[obj.pk for obj in new_objs]).count() != len(new_objs): if self.queryset.filter(pk__in=[obj.pk for obj in new_objects]).count() != len(new_objects):
raise PermissionsViolation raise PermissionsViolation
if new_objs:
msg = f"Imported {len(new_objs)} {model._meta.verbose_name_plural}"
logger.info(msg)
messages.success(request, msg)
view_name = get_viewname(model, action='list')
results_url = f"{reverse(view_name)}?modified_by_request={request.id}"
return redirect(results_url)
except (AbortTransaction, ValidationError): except (AbortTransaction, ValidationError):
clear_events.send(sender=self) clear_events.send(sender=self)
@@ -525,6 +538,25 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
form.add_error(None, e.message) form.add_error(None, e.message)
clear_events.send(sender=self) clear_events.send(sender=self)
# If this request was executed via a background job, return the raw data for logging
if is_background_request(request):
return AsyncJobData(
log=[
_('Created {object}').format(object=str(obj))
for obj in new_objects
],
errors=form.errors
)
if new_objects:
msg = _("Imported {count} {object_type}").format(
count=len(new_objects),
object_type=model._meta.verbose_name_plural
)
logger.info(msg)
messages.success(request, msg)
return redirect(f"{redirect_url}?modified_by_request={request.id}")
else: else:
logger.debug("Form validation failed") logger.debug("Form validation failed")
@@ -729,7 +761,11 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView): class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView):
""" """
An extendable view for renaming objects in bulk. An extendable view for renaming objects in bulk.
Attributes:
field_name: The name of the object attribute for which the value is being updated (defaults to "name")
""" """
field_name = 'name'
template_name = 'generic/bulk_rename.html' template_name = 'generic/bulk_rename.html'
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
@@ -759,12 +795,12 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView):
replace = form.cleaned_data['replace'] replace = form.cleaned_data['replace']
if form.cleaned_data['use_regex']: if form.cleaned_data['use_regex']:
try: try:
obj.new_name = re.sub(find, replace, obj.name or '') obj.new_name = re.sub(find, replace, getattr(obj, self.field_name, ''))
# Catch regex group reference errors # Catch regex group reference errors
except re.error: except re.error:
obj.new_name = obj.name obj.new_name = getattr(obj, self.field_name)
else: else:
obj.new_name = (obj.name or '').replace(find, replace) obj.new_name = getattr(obj, self.field_name, '').replace(find, replace)
renamed_pks.append(obj.pk) renamed_pks.append(obj.pk)
return renamed_pks return renamed_pks
@@ -783,7 +819,7 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView):
if '_apply' in request.POST: if '_apply' in request.POST:
for obj in selected_objects: for obj in selected_objects:
obj.name = obj.new_name setattr(obj, self.field_name, obj.new_name)
obj.save() obj.save()
# Enforce constrained permissions # Enforce constrained permissions
@@ -813,6 +849,7 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView):
selected_objects = self.queryset.filter(pk__in=form.initial['pk']) selected_objects = self.queryset.filter(pk__in=form.initial['pk'])
return render(request, self.template_name, { return render(request, self.template_name, {
'field_name': self.field_name,
'form': form, 'form': form,
'obj_type_plural': self.queryset.model._meta.verbose_name_plural, 'obj_type_plural': self.queryset.model._meta.verbose_name_plural,
'selected_objects': selected_objects, 'selected_objects': selected_objects,

View File

@@ -1,7 +1,7 @@
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from extras.models import TableConfig from extras.models import TableConfig
from netbox.constants import DEFAULT_ACTION_PERMISSIONS from netbox import object_actions
from utilities.permissions import get_permission_for_model from utilities.permissions import get_permission_for_model
__all__ = ( __all__ = (
@@ -9,6 +9,18 @@ __all__ = (
'TableMixin', 'TableMixin',
) )
# TODO: Remove in NetBox v4.5
LEGACY_ACTIONS = {
'add': object_actions.AddObject,
'edit': object_actions.EditObject,
'delete': object_actions.DeleteObject,
'export': object_actions.BulkExport,
'bulk_import': object_actions.BulkImport,
'bulk_edit': object_actions.BulkEdit,
'bulk_rename': object_actions.BulkRename,
'bulk_delete': object_actions.BulkDelete,
}
class ActionsMixin: class ActionsMixin:
""" """
@@ -19,7 +31,24 @@ class ActionsMixin:
Standard actions include: add, import, export, bulk_edit, and bulk_delete. Some views extend this default map Standard actions include: add, import, export, bulk_edit, and bulk_delete. Some views extend this default map
with custom actions, such as bulk_sync. with custom actions, such as bulk_sync.
""" """
actions = DEFAULT_ACTION_PERMISSIONS actions = tuple()
# TODO: Remove in NetBox v4.5
def _convert_legacy_actions(self):
"""
Convert a legacy dictionary mapping action name to required permissions to a list of ObjectAction subclasses.
"""
if type(self.actions) is not dict:
return
actions = []
for name in self.actions.keys():
try:
actions.append(LEGACY_ACTIONS[name])
except KeyError:
raise ValueError(f"Unsupported legacy action: {name}")
self.actions = actions
def get_permitted_actions(self, user, model=None): def get_permitted_actions(self, user, model=None):
""" """
@@ -27,11 +56,15 @@ class ActionsMixin:
""" """
model = model or self.queryset.model model = model or self.queryset.model
# TODO: Remove in NetBox v4.5
# Handle legacy action sets
self._convert_legacy_actions()
# Resolve required permissions for each action # Resolve required permissions for each action
permitted_actions = [] permitted_actions = []
for action in self.actions: for action in self.actions:
required_permissions = [ required_permissions = [
get_permission_for_model(model, name) for name in self.actions.get(action, set()) get_permission_for_model(model, perm) for perm in action.permissions_required
] ]
if not required_permissions or user.has_perms(required_permissions): if not required_permissions or user.has_perms(required_permissions):
permitted_actions.append(action) permitted_actions.append(action)

View File

@@ -14,6 +14,9 @@ from django.utils.safestring import mark_safe
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from core.signals import clear_events from core.signals import clear_events
from netbox.object_actions import (
AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport, CloneObject, DeleteObject, EditObject,
)
from utilities.error_handlers import handle_protectederror from utilities.error_handlers import handle_protectederror
from utilities.exceptions import AbortRequest, PermissionsViolation from utilities.exceptions import AbortRequest, PermissionsViolation
from utilities.forms import ConfirmationForm, restrict_form_fields from utilities.forms import ConfirmationForm, restrict_form_fields
@@ -36,7 +39,7 @@ __all__ = (
) )
class ObjectView(BaseObjectView): class ObjectView(ActionsMixin, BaseObjectView):
""" """
Retrieve a single object for display. Retrieve a single object for display.
@@ -44,8 +47,10 @@ class ObjectView(BaseObjectView):
Attributes: Attributes:
tab: A ViewTab instance for the view tab: A ViewTab instance for the view
actions: An iterable of ObjectAction subclasses (see ActionsMixin)
""" """
tab = None tab = None
actions = (CloneObject, EditObject, DeleteObject)
def get_required_permission(self): def get_required_permission(self):
return get_permission_for_model(self.queryset.model, 'view') return get_permission_for_model(self.queryset.model, 'view')
@@ -72,9 +77,11 @@ class ObjectView(BaseObjectView):
request: The current request request: The current request
""" """
instance = self.get_object(**kwargs) instance = self.get_object(**kwargs)
actions = self.get_permitted_actions(request.user, model=instance)
return render(request, self.get_template_name(), { return render(request, self.get_template_name(), {
'object': instance, 'object': instance,
'actions': actions,
'tab': self.tab, 'tab': self.tab,
**self.get_extra_context(request, instance), **self.get_extra_context(request, instance),
}) })
@@ -90,13 +97,13 @@ class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin):
table: The django-tables2 Table class used to render the child objects list 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: A django-filter FilterSet that is applied to the queryset
filterset_form: The form class used to render filter options 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 actions: An iterable of ObjectAction subclasses (see ActionsMixin)
action names must be prefixed with `bulk_`. (See ActionsMixin.)
""" """
child_model = None child_model = None
table = None table = None
filterset = None filterset = None
filterset_form = None filterset_form = None
actions = (AddObject, BulkImport, BulkEdit, BulkExport, BulkDelete)
template_name = 'generic/object_children.html' template_name = 'generic/object_children.html'
def get_children(self, request, parent): def get_children(self, request, parent):
@@ -138,10 +145,10 @@ class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin):
# Determine the available actions # Determine the available actions
actions = self.get_permitted_actions(request.user, model=self.child_model) actions = self.get_permitted_actions(request.user, model=self.child_model)
has_bulk_actions = any([a.startswith('bulk_') for a in actions]) has_table_actions = any(action.multi for action in actions)
table_data = self.prep_table_data(request, child_objects, instance) table_data = self.prep_table_data(request, child_objects, instance)
table = self.get_table(table_data, request, has_bulk_actions) table = self.get_table(table_data, request, has_table_actions)
# If this is an HTMX request, return only the rendered table HTML # If this is an HTMX request, return only the rendered table HTML
if htmx_partial(request): if htmx_partial(request):

View File

@@ -0,0 +1,3 @@
<button type="submit" name="_sync" {% formaction %}="{{ url }}" class="btn btn-primary">
<i class="mdi mdi-sync" aria-hidden="true"></i> {{ label }}
</button>

View File

@@ -11,12 +11,6 @@
<li class="breadcrumb-item"><a href="{% url 'core:datafile_list' %}?source_id={{ object.source.pk }}">{{ object.source }}</a></li> <li class="breadcrumb-item"><a href="{% url 'core:datafile_list' %}?source_id={{ object.source.pk }}">{{ object.source }}</a></li>
{% endblock %} {% endblock %}
{% block control-buttons %}
{% if request.user|can_delete:object %}
{% delete_button object %}
{% endif %}
{% endblock control-buttons %}
{% block content %} {% block content %}
<div class="row mb-3"> <div class="row mb-3">
<div class="col"> <div class="col">

View File

@@ -1,33 +1,6 @@
{% extends 'generic/object.html' %} {% extends 'core/job/base.html' %}
{% load buttons %}
{% load helpers %}
{% load perms %}
{% load i18n %} {% load i18n %}
{% block breadcrumbs %}
{{ block.super }}
{% if object.object %}
<li class="breadcrumb-item">
<a href="{% url 'core:job_list' %}?object_type={{ object.object_type_id }}">{{ object.object|meta:"verbose_name_plural"|bettertitle }}</a>
</li>
{% with parent_jobs_viewname=object.object|viewname:"jobs" %}
<li class="breadcrumb-item">
<a href="{% url parent_jobs_viewname pk=object.object.pk %}">{{ object.object }}</a>
</li>
{% endwith %}
{% else %}
<li class="breadcrumb-item">
<a href="{% url 'core:job_list' %}?name={{ object.name|urlencode }}">{{ object.name }}</a>
</li>
{% endif %}
{% endblock breadcrumbs %}
{% block control-buttons %}
{% if request.user|can_delete:object %}
{% delete_button object %}
{% endif %}
{% endblock control-buttons %}
{% block content %} {% block content %}
<div class="row mb-3"> <div class="row mb-3">
<div class="col col-12 col-md-6"> <div class="col col-12 col-md-6">

View File

@@ -0,0 +1,23 @@
{% extends 'generic/object.html' %}
{% load buttons %}
{% load helpers %}
{% load perms %}
{% load i18n %}
{% block breadcrumbs %}
{{ block.super }}
{% if object.object %}
<li class="breadcrumb-item">
<a href="{% url 'core:job_list' %}?object_type={{ object.object_type_id }}">{{ object.object|meta:"verbose_name_plural"|bettertitle }}</a>
</li>
{% with parent_jobs_viewname=object.object|viewname:"jobs" %}
<li class="breadcrumb-item">
<a href="{% url parent_jobs_viewname pk=object.object.pk %}">{{ object.object }}</a>
</li>
{% endwith %}
{% else %}
<li class="breadcrumb-item">
<a href="{% url 'core:job_list' %}?name={{ object.name|urlencode }}">{{ object.name }}</a>
</li>
{% endif %}
{% endblock breadcrumbs %}

View File

@@ -0,0 +1,12 @@
{% extends 'core/job/base.html' %}
{% load render_table from django_tables2 %}
{% block content %}
<div class="row mb-3">
<div class="col">
<div class="card">
{% render_table table %}
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,71 @@
{% load i18n %}
<div class="btn-group">
<button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {{ label }}
</button>
<ul class="dropdown-menu">
{% if perms.dcim.add_consoleport %}
<li>
<button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_consoleport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
{% trans "Console Ports" %}
</button>
</li>
{% endif %}
{% if perms.dcim.add_consoleserverport %}
<li>
<button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_consoleserverport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item ">
{% trans "Console Server Ports" %}
</button>
</li>
{% endif %}
{% if perms.dcim.add_powerport %}
<li>
<button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_powerport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
{% trans "Power Ports" %}
</button>
</li>
{% endif %}
{% if perms.dcim.add_poweroutlet %}
<li>
<button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_poweroutlet' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
{% trans "Power Outlets" %}
</button>
</li>
{% endif %}
{% if perms.dcim.add_interface %}
<li>
<button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_interface' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
{% trans "Interfaces" %}
</button>
</li>
{% endif %}
{% if perms.dcim.add_rearport %}
<li>
<button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_rearport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
{% trans "Rear Ports" %}
</button>
</li>
{% endif %}
{% if perms.dcim.add_devicebay %}
<li>
<button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_devicebay' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
{% trans "Device Bays" %}
</button>
</li>
{% endif %}
{% if perms.dcim.add_modulebay %}
<li>
<button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_modulebay' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
{% trans "Module Bays" %}
</button>
</li>
{% endif %}
{% if perms.dcim.add_inventoryitem %}
<li>
<button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_inventoryitem' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
{% trans "Inventory Items" %}
</button>
</li>
{% endif %}
</ul>
</div>

View File

@@ -0,0 +1,3 @@
<button type="submit" name="_disconnect" {% formaction %}="{{ url }}" class="btn btn-red">
<i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i> {{ label }}
</button>

View File

@@ -1,22 +0,0 @@
{% extends 'generic/object_list.html' %}
{% load buttons %}
{% load helpers %}
{% load i18n %}
{% block bulk_buttons %}
<div class="btn-group" role="group">
{% if 'bulk_edit' in actions %}
{% bulk_edit_button model query_params=request.GET %}
{% endif %}
{% if 'bulk_rename' in actions %}
{% with bulk_rename_view=model|validated_viewname:"bulk_rename" %}
<button type="submit" name="_rename" {% formaction %}="{% url bulk_rename_view %}" class="btn btn-outline-warning btn-float">
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> {% trans "Rename Selected" %}
</button>
{% endwith %}
{% endif %}
</div>
{% if 'bulk_delete' in actions %}
{% bulk_delete_button model query_params=request.GET %}
{% endif %}
{% endblock %}

View File

@@ -1,23 +0,0 @@
{% extends 'generic/object_children.html' %}
{% load helpers %}
{% block bulk_edit_controls %}
{% with bulk_edit_view=child_model|validated_viewname:"bulk_edit" %}
{% if 'bulk_edit' in actions and bulk_edit_view %}
<button type="submit" name="_edit"
{% formaction %}="{% url bulk_edit_view %}?device={{ object.pk }}&return_url={{ return_url }}"
class="btn btn-warning">
<i class="mdi mdi-pencil" aria-hidden="true"></i> Edit Selected
</button>
{% endif %}
{% endwith %}
{% with bulk_rename_view=child_model|validated_viewname:"bulk_rename" %}
{% if 'bulk_rename' in actions and bulk_rename_view %}
<button type="submit" name="_rename"
{% formaction %}="{% url bulk_rename_view %}?return_url={{ return_url }}"
class="btn btn-outline-warning">
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
</button>
{% endif %}
{% endwith %}
{% endblock bulk_edit_controls %}

View File

@@ -1,28 +0,0 @@
{% extends 'dcim/device/components_base.html' %}
{% load helpers %}
{% load i18n %}
{% block bulk_delete_controls %}
{{ block.super }}
{% with bulk_disconnect_view=child_model|validated_viewname:"bulk_disconnect" %}
{% if 'bulk_disconnect' in actions and bulk_disconnect_view %}
<button type="submit" name="_disconnect"
{% formaction %}="{% url bulk_disconnect_view %}?return_url={{ return_url }}"
class="btn btn-outline-danger">
<span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
</button>
{% endif %}
{% endwith %}
{% endblock bulk_delete_controls %}
{% block bulk_extra_controls %}
{{ block.super }}
{% if perms.dcim.add_consoleport %}
<div class="btn-group" role="group">
<a href="{% url 'dcim:consoleport_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_consoleports' pk=object.pk %}"
class="btn btn-primary">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Console Ports" %}
</a>
</div>
{% endif %}
{% endblock bulk_extra_controls %}

View File

@@ -1,28 +0,0 @@
{% extends 'dcim/device/components_base.html' %}
{% load helpers %}
{% load i18n %}
{% block bulk_delete_controls %}
{{ block.super }}
{% with bulk_disconnect_view=child_model|validated_viewname:"bulk_disconnect" %}
{% if 'bulk_disconnect' in actions and bulk_disconnect_view %}
<button type="submit" name="_disconnect"
{% formaction %}="{% url bulk_disconnect_view %}?return_url={{ return_url }}"
class="btn btn-outline-danger">
<span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
</button>
{% endif %}
{% endwith %}
{% endblock bulk_delete_controls %}
{% block bulk_extra_controls %}
{{ block.super }}
{% if perms.dcim.add_consoleserverport %}
<div class="btn-group" role="group">
<a href="{% url 'dcim:consoleserverport_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_consoleserverports' pk=object.pk %}"
class="btn btn-primary">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Console Server Ports" %}
</a>
</div>
{% endif %}
{% endblock bulk_extra_controls %}

View File

@@ -1,14 +0,0 @@
{% extends 'dcim/device/components_base.html' %}
{% load i18n %}
{% block bulk_extra_controls %}
{{ block.super }}
{% if perms.dcim.add_devicebay %}
<div class="btn-group" role="group">
<a href="{% url 'dcim:devicebay_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_devicebays' pk=object.pk %}"
class="btn btn-primary">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Device Bays" %}
</a>
</div>
{% endif %}
{% endblock bulk_extra_controls %}

View File

@@ -1,28 +0,0 @@
{% extends 'dcim/device/components_base.html' %}
{% load helpers %}
{% load i18n %}
{% block bulk_delete_controls %}
{{ block.super }}
{% with bulk_disconnect_view=child_model|validated_viewname:"bulk_disconnect" %}
{% if 'bulk_disconnect' in actions and bulk_disconnect_view %}
<button type="submit" name="_disconnect"
{% formaction %}="{% url bulk_disconnect_view %}?return_url={{ return_url }}"
class="btn btn-outline-danger">
<span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
</button>
{% endif %}
{% endwith %}
{% endblock bulk_delete_controls %}
{% block bulk_extra_controls %}
{{ block.super }}
{% if perms.dcim.add_frontport %}
<div class="btn-group" role="group">
<a href="{% url 'dcim:frontport_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_frontports' pk=object.pk %}"
class="btn btn-primary">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Front Ports" %}
</a>
</div>
{% endif %}
{% endblock bulk_extra_controls %}

View File

@@ -1,30 +1,5 @@
{% extends 'dcim/device/components_base.html' %} {% extends 'generic/object_children.html' %}
{% load helpers %}
{% load i18n %}
{% block table_controls %} {% block table_controls %}
{% include 'dcim/device/inc/interface_table_controls.html' with table_modal="DeviceInterfaceTable_config" %} {% include 'dcim/device/inc/interface_table_controls.html' with table_modal="DeviceInterfaceTable_config" %}
{% endblock table_controls %} {% endblock table_controls %}
{% block bulk_delete_controls %}
{{ block.super }}
{% with bulk_disconnect_view=child_model|validated_viewname:"bulk_disconnect" %}
{% if 'bulk_disconnect' in actions and bulk_disconnect_view %}
<button type="submit" name="_disconnect"
{% formaction %}="{% url bulk_disconnect_view %}?return_url={{ return_url }}"
class="btn btn-outline-danger">
<span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
</button>
{% endif %}
{% endwith %}
{% endblock bulk_delete_controls %}
{% block bulk_extra_controls %}
{{ block.super }}
{% if perms.dcim.add_interface %}
<a href="{% url 'dcim:interface_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_interfaces' pk=object.pk %}"
class="btn btn-primary">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Interfaces" %}
</a>
{% endif %}
{% endblock bulk_extra_controls %}

View File

@@ -1,14 +0,0 @@
{% extends 'dcim/device/components_base.html' %}
{% load i18n %}
{% block bulk_extra_controls %}
{{ block.super }}
{% if perms.dcim.add_inventoryitem %}
<div class="btn-group" role="group">
<a href="{% url 'dcim:inventoryitem_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_inventory' pk=object.pk %}"
class="btn btn-primary">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Inventory Item" %}
</a>
</div>
{% endif %}
{% endblock bulk_extra_controls %}

View File

@@ -1,14 +0,0 @@
{% extends 'dcim/device/components_base.html' %}
{% load i18n %}
{% block bulk_extra_controls %}
{{ block.super }}
{% if perms.dcim.add_modulebay %}
<div class="btn-group" role="group">
<a href="{% url 'dcim:modulebay_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_modulebays' pk=object.pk %}"
class="btn btn-primary">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Module Bays" %}
</a>
</div>
{% endif %}
{% endblock bulk_extra_controls %}

View File

@@ -1,28 +0,0 @@
{% extends 'dcim/device/components_base.html' %}
{% load helpers %}
{% load i18n %}
{% block bulk_delete_controls %}
{{ block.super }}
{% with bulk_disconnect_view=child_model|validated_viewname:"bulk_disconnect" %}
{% if 'bulk_disconnect' in actions and bulk_disconnect_view %}
<button type="submit" name="_disconnect"
{% formaction %}="{% url bulk_disconnect_view %}?return_url={{ return_url }}"
class="btn btn-outline-danger">
<span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
</button>
{% endif %}
{% endwith %}
{% endblock bulk_delete_controls %}
{% block bulk_extra_controls %}
{{ block.super }}
{% if perms.dcim.add_poweroutlet %}
<div class="btn-group" role="group">
<a href="{% url 'dcim:poweroutlet_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_poweroutlets' pk=object.pk %}"
class="btn btn-primary">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Power Outlets" %}
</a>
</div>
{% endif %}
{% endblock bulk_extra_controls %}

View File

@@ -1,28 +0,0 @@
{% extends 'dcim/device/components_base.html' %}
{% load helpers %}
{% load i18n %}
{% block bulk_delete_controls %}
{{ block.super }}
{% with bulk_disconnect_view=child_model|validated_viewname:"bulk_disconnect" %}
{% if 'bulk_disconnect' in actions and bulk_disconnect_view %}
<button type="submit" name="_disconnect"
{% formaction %}="{% url bulk_disconnect_view %}?return_url={{ return_url }}"
class="btn btn-outline-danger">
<span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
</button>
{% endif %}
{% endwith %}
{% endblock bulk_delete_controls %}
{% block bulk_extra_controls %}
{{ block.super }}
{% if perms.dcim.add_powerport %}
<div class="btn-group" role="group">
<a href="{% url 'dcim:powerport_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_powerports' pk=object.pk %}"
class="btn btn-primary">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Power Port" %}
</a>
</div>
{% endif %}
{% endblock bulk_extra_controls %}

View File

@@ -1,28 +0,0 @@
{% extends 'dcim/device/components_base.html' %}
{% load helpers %}
{% load i18n %}
{% block bulk_delete_controls %}
{{ block.super }}
{% with bulk_disconnect_view=child_model|validated_viewname:"bulk_disconnect" %}
{% if 'bulk_disconnect' in actions and bulk_disconnect_view %}
<button type="submit" name="_disconnect"
{% formaction %}="{% url bulk_disconnect_view %}?return_url={{ return_url }}"
class="btn btn-outline-danger">
<span class="mdi mdi-ethernet-cable-off" aria-hidden="true"></span> {% trans "Disconnect" %}
</button>
{% endif %}
{% endwith %}
{% endblock bulk_delete_controls %}
{% block bulk_extra_controls %}
{{ block.super }}
{% if perms.dcim.add_rearport %}
<div class="btn-group" role="group">
<a href="{% url 'dcim:rearport_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_rearports' pk=object.pk %}"
class="btn btn-primary">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Rear Ports" %}
</a>
</div>
{% endif %}
{% endblock bulk_extra_controls %}

View File

@@ -1,89 +0,0 @@
{% extends 'generic/object_list.html' %}
{% load buttons %}
{% load i18n %}
{% block bulk_buttons %}
{% if perms.dcim.change_device %}
<div class="dropdown">
<button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Components" %}
</button>
<ul class="dropdown-menu">
{% if perms.dcim.add_consoleport %}
<li>
<button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_consoleport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
{% trans "Console Ports" %}
</button>
</li>
{% endif %}
{% if perms.dcim.add_consoleserverport %}
<li>
<button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_consoleserverport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item ">
{% trans "Console Server Ports" %}
</button>
</li>
{% endif %}
{% if perms.dcim.add_powerport %}
<li>
<button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_powerport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
{% trans "Power Ports" %}
</button>
</li>
{% endif %}
{% if perms.dcim.add_poweroutlet %}
<li>
<button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_poweroutlet' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
{% trans "Power Outlets" %}
</button>
</li>
{% endif %}
{% if perms.dcim.add_interface %}
<li>
<button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_interface' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
{% trans "Interfaces" %}
</button>
</li>
{% endif %}
{% if perms.dcim.add_rearport %}
<li>
<button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_rearport' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
{% trans "Rear Ports" %}
</button>
</li>
{% endif %}
{% if perms.dcim.add_devicebay %}
<li>
<button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_devicebay' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
{% trans "Device Bays" %}
</button>
</li>
{% endif %}
{% if perms.dcim.add_modulebay %}
<li>
<button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_modulebay' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
{% trans "Module Bays" %}
</button>
</li>
{% endif %}
{% if perms.dcim.add_inventoryitem %}
<li>
<button type="submit" {% formaction %}="{% url 'dcim:device_bulk_add_inventoryitem' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
{% trans "Inventory Items" %}
</button>
</li>
{% endif %}
</ul>
</div>
{% endif %}
{% if 'bulk_edit' in actions %}
<div class="btn-group" role="group">
{% bulk_edit_button model query_params=request.GET %}
<button type="submit" name="_rename" {% formaction %}="{% url 'dcim:device_bulk_rename' %}?return_url={% url 'dcim:device_list' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-outline-warning btn-float">
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> {% trans "Rename" %}
</button>
</div>
{% endif %}
{% if 'bulk_delete' in actions %}
{% bulk_delete_button model query_params=request.GET %}
{% endif %}
{% endblock %}

View File

@@ -1,25 +0,0 @@
{% extends 'generic/object_children.html' %}
{% load helpers %}
{% load i18n %}
{% load perms %}
{% block bulk_edit_controls %}
{% with bulk_edit_view=child_model|validated_viewname:"bulk_edit" %}
{% if 'bulk_edit' in actions and bulk_edit_view %}
<button type="submit" name="_edit"
{% formaction %}="{% url bulk_edit_view %}?device={{ object.pk }}&return_url={{ return_url }}"
class="btn btn-warning">
<i class="mdi mdi-pencil" aria-hidden="true"></i> Edit Selected
</button>
{% endif %}
{% endwith %}
{% with bulk_rename_view=child_model|validated_viewname:"bulk_rename" %}
{% if 'bulk_rename' in actions and bulk_rename_view %}
<button type="submit" name="_rename"
{% formaction %}="{% url bulk_rename_view %}?return_url={{ return_url }}"
class="btn btn-outline-warning">
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename Selected
</button>
{% endif %}
{% endwith %}
{% endblock bulk_edit_controls %}

View File

@@ -1,30 +0,0 @@
{% extends 'generic/object_children.html' %}
{% load render_table from django_tables2 %}
{% load helpers %}
{% load i18n %}
{% block extra_controls %}
{% include 'dcim/inc/moduletype_buttons.html' %}
{% endblock %}
{% block bulk_edit_controls %}
{% with bulk_edit_view=child_model|validated_viewname:"bulk_edit" %}
{% if 'bulk_edit' in actions and bulk_edit_view %}
<button type="submit" name="_edit"
{% formaction %}="{% url bulk_edit_view %}?device={{ object.pk }}&return_url={{ return_url }}"
class="btn btn-warning">
<i class="mdi mdi-pencil" aria-hidden="true"></i> Edit Selected
</button>
{% endif %}
{% endwith %}
{% with bulk_rename_view=child_model|validated_viewname:"bulk_rename" %}
{% if 'bulk_rename' in actions and bulk_rename_view %}
<button type="submit" name="_rename"
{% formaction %}="{% url bulk_rename_view %}?return_url={{ return_url }}"
class="btn btn-outline-warning">
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename Selected
</button>
{% endif %}
{% endwith %}
{% endblock bulk_edit_controls %}

View File

@@ -1,18 +1,8 @@
{% extends 'generic/object.html' %} {% extends 'generic/object.html' %}
{% load buttons %}
{% load helpers %} {% load helpers %}
{% load plugins %} {% load plugins %}
{% load i18n %} {% load i18n %}
{% block buttons %}
{% if perms.dcim.change_virtualchassis %}
{% edit_button object %}
{% endif %}
{% if perms.dcim.delete_virtualchassis %}
{% delete_button object %}
{% endif %}
{% endblock %}
{% block content %} {% block content %}
<div class="row"> <div class="row">
<div class="col col-12 col-md-4"> <div class="col col-12 col-md-4">

View File

@@ -1,11 +0,0 @@
{% extends 'generic/object_list.html' %}
{% load i18n %}
{% block bulk_buttons %}
{% if perms.extras.sync_configcontext %}
<button type="submit" name="_sync" {% formaction %}="{% url 'extras:configcontext_bulk_sync' %}" class="btn btn-primary">
<i class="mdi mdi-sync" aria-hidden="true"></i> {% trans "Sync Data" %}
</button>
{% endif %}
{{ block.super }}
{% endblock %}

View File

@@ -1,11 +0,0 @@
{% extends 'generic/object_list.html' %}
{% load i18n %}
{% block bulk_buttons %}
{% if perms.extras.sync_configtemplate %}
<button type="submit" name="_sync" {% formaction %}="{% url 'extras:configtemplate_bulk_sync' %}" class="btn btn-primary">
<i class="mdi mdi-sync" aria-hidden="true"></i> {% trans "Sync Data" %}
</button>
{% endif %}
{{ block.super }}
{% endblock %}

View File

@@ -1,11 +0,0 @@
{% extends 'generic/object_list.html' %}
{% load i18n %}
{% block bulk_buttons %}
{% if perms.extras.sync_configcontext %}
<button type="submit" name="_sync" {% formaction %}="{% url 'extras:exporttemplate_bulk_sync' %}" class="btn btn-primary">
<i class="mdi mdi-sync" aria-hidden="true"></i> {% trans "Sync Data" %}
</button>
{% endif %}
{{ block.super }}
{% endblock %}

View File

@@ -50,6 +50,7 @@ Context:
{% render_field form.data %} {% render_field form.data %}
{% render_field form.format %} {% render_field form.format %}
{% render_field form.csv_delimiter %} {% render_field form.csv_delimiter %}
{% render_field form.background_job %}
<div class="form-group"> <div class="form-group">
<div class="col col-md-12 text-end"> <div class="col col-md-12 text-end">
{% if return_url %} {% if return_url %}
@@ -94,6 +95,7 @@ Context:
{% render_field form.data_file %} {% render_field form.data_file %}
{% render_field form.format %} {% render_field form.format %}
{% render_field form.csv_delimiter %} {% render_field form.csv_delimiter %}
{% render_field form.background_job %}
<div class="form-group"> <div class="form-group">
<div class="col col-md-12 text-end"> <div class="col col-md-12 text-end">
{% if return_url %} {% if return_url %}

View File

@@ -1,72 +0,0 @@
{% extends 'generic/_base.html' %}
{% load helpers %}
{% load render_table from django_tables2 %}
{% load i18n %}
{% comment %}
Blocks:
- title: Page title
- tabs: Page tabs
- content: Primary page content
Context:
- form: The bulk edit form class
- parent_obj: The parent object
- table: A table of objects being removed
- obj_type_plural: The plural form of the object type
- return_url: The URL to which the user is redirected after submitting the form
{% endcomment %}
{% block title %}
{% trans "Remove" %} {{ table.rows|length }} {{ obj_type_plural|bettertitle }}?
{% endblock %}
{% block tabs %}
<ul class="nav nav-tabs">
<li class="nav-item" role="presentation">
<button class="nav-link active" type="button" role="tab" aria-controls="edit-form" aria-selected="true">
{% trans "Bulk Remove" %}
</button>
</li>
</ul>
{% endblock tabs %}
{% block content %}
<div class="tab-pane show active" role="tabpanel">
<div class="alert alert-danger bg-danger-subtle" role="alert">
<div class="d-flex">
<div>
<i class="mdi mdi-alert-octagon p-2"></i>
</div>
<div>
<h4 class="alert-title">{% trans "Confirm Bulk Removal" %}</h4>
{% blocktrans trimmed with count=table.rows|length %}
The following operation will remove {{ count }} {{ obj_type_plural }} from {{ parent_obj }}. Please
carefully review the {{ obj_type_plural }} to be removed and confirm below.
{% endblocktrans %}
</div>
</div>
</div>
<div class="container-fluid px-0">
<div class="card">
<div class="table-responsive">
{% render_table table 'inc/table.html' %}
</div>
</div>
<form action="." method="post" class="form">
{% csrf_token %}
{% for field in form.hidden_fields %}
{{ field }}
{% endfor %}
<div class="text-end">
<a href="{{ return_url }}" class="btn btn-outline-secondary">{% trans "Cancel" %}</a>
<button type="submit" name="_confirm" class="btn btn-danger">
{% blocktrans trimmed with count=table.rows|length %}
Remove these {{ count }} {{ obj_type_plural }}
{% endblocktrans %}
</button>
</div>
</form>
</div>
</div>
{% endblock content %}

View File

@@ -42,10 +42,12 @@ Context:
</thead> </thead>
<tbody> <tbody>
{% for obj in selected_objects %} {% for obj in selected_objects %}
<tr{% if obj.new_name and obj.name != obj.new_name %} class="success"{% endif %}> {% with obj_name=obj|getattr:field_name %}
<td>{{ obj.name }}</td> <tr{% if obj.new_name and obj_name != obj.new_name %} class="success"{% endif %}>
<td>{{ obj.new_name }}</td> <td>{{ obj_name }}</td>
</tr> <td>{{ obj.new_name }}</td>
</tr>
{% endwith %}
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>

View File

@@ -80,15 +80,7 @@ Context:
{% if perms.extras.add_subscription and object.subscriptions %} {% if perms.extras.add_subscription and object.subscriptions %}
{% subscribe_button object %} {% subscribe_button object %}
{% endif %} {% endif %}
{% if request.user|can_add:object %} {% action_buttons actions object %}
{% clone_button object %}
{% endif %}
{% if request.user|can_change:object %}
{% edit_button object %}
{% endif %}
{% if request.user|can_delete:object %}
{% delete_button object %}
{% endif %}
{% endblock control-buttons %} {% endblock control-buttons %}
</div> </div>

View File

@@ -1,4 +1,5 @@
{% extends base_template %} {% extends base_template %}
{% load buttons %}
{% load helpers %} {% load helpers %}
{% load i18n %} {% load i18n %}
@@ -7,8 +8,6 @@ Blocks:
- content: Primary page content - content: Primary page content
- table_controls: Control elements for the child objects table - table_controls: Control elements for the child objects table
- bulk_controls: Bulk action buttons which appear beneath the child objects table - bulk_controls: Bulk action buttons which appear beneath the child objects table
- bulk_edit_controls: Bulk edit buttons
- bulk_delete_controls: Bulk delete buttons
- bulk_extra_controls: Other bulk action buttons - bulk_extra_controls: Other bulk action buttons
- modals: Any pre-loaded modals - modals: Any pre-loaded modals
@@ -36,36 +35,8 @@ Context:
</div> </div>
<div class="d-print-none mt-2"> <div class="d-print-none mt-2">
{% block bulk_controls %} {% block bulk_controls %}
<div class="btn-group" role="group"> {% action_buttons actions model multi=True %}
{# Bulk edit buttons #} {% block bulk_extra_controls %}{% endblock %}
{% block bulk_edit_controls %}
{% with bulk_edit_view=child_model|validated_viewname:"bulk_edit" %}
{% if 'bulk_edit' in actions and bulk_edit_view %}
<button type="submit" name="_edit"
{% formaction %}="{% url bulk_edit_view %}?return_url={{ return_url }}"
class="btn btn-warning">
<i class="mdi mdi-pencil" aria-hidden="true"></i> {% trans "Edit Selected" %}
</button>
{% endif %}
{% endwith %}
{% endblock bulk_edit_controls %}
</div>
<div class="btn-group" role="group">
{# Bulk delete buttons #}
{% block bulk_delete_controls %}
{% with bulk_delete_view=child_model|validated_viewname:"bulk_delete" %}
{% if 'bulk_delete' in actions and bulk_delete_view %}
<button type="submit"
{% formaction %}="{% url bulk_delete_view %}?return_url={{ return_url }}"
class="btn btn-danger">
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> {% trans "Delete Selected" %}
</button>
{% endif %}
{% endwith %}
{% endblock bulk_delete_controls %}
</div>
{# Other bulk action buttons #}
{% block bulk_extra_controls %}{% endblock %}
{% endblock bulk_controls %} {% endblock bulk_controls %}
</div> </div>
</form> </form>

View File

@@ -31,15 +31,7 @@ Context:
<div class="btn-list"> <div class="btn-list">
{% plugin_list_buttons model %} {% plugin_list_buttons model %}
{% block extra_controls %}{% endblock %} {% block extra_controls %}{% endblock %}
{% if 'add' in actions %} {% action_buttons actions model %}
{% add_button model %}
{% endif %}
{% if 'bulk_import' in actions %}
{% import_button model %}
{% endif %}
{% if 'export' in actions %}
{% export_button model %}
{% endif %}
</div> </div>
{% endblock controls %} {% endblock controls %}
@@ -91,12 +83,7 @@ Context:
</label> </label>
</div> </div>
<div class="bulk-action-buttons"> <div class="bulk-action-buttons">
{% if 'bulk_edit' in actions %} {% action_buttons actions model multi=True %}
{% bulk_edit_button model query_params=request.GET %}
{% endif %}
{% if 'bulk_delete' in actions %}
{% bulk_delete_button model query_params=request.GET %}
{% endif %}
</div> </div>
</div> </div>
</div> </div>
@@ -124,12 +111,7 @@ Context:
<div class="btn-list d-print-none"> <div class="btn-list d-print-none">
{% block bulk_buttons %} {% block bulk_buttons %}
<div class="bulk-action-buttons"> <div class="bulk-action-buttons">
{% if 'bulk_edit' in actions %} {% action_buttons actions model multi=True %}
{% bulk_edit_button model query_params=request.GET %}
{% endif %}
{% if 'bulk_delete' in actions %}
{% bulk_delete_button model query_params=request.GET %}
{% endif %}
</div> </div>
{% endblock %} {% endblock %}
</div> </div>

View File

@@ -27,12 +27,7 @@
{# Update the bulk action buttons with new query parameters #} {# Update the bulk action buttons with new query parameters #}
{% if actions %} {% if actions %}
<div class="bulk-action-buttons" hx-swap-oob="outerHTML:.bulk-action-buttons"> <div class="bulk-action-buttons" hx-swap-oob="outerHTML:.bulk-action-buttons">
{% if 'bulk_edit' in actions %} {% action_buttons actions model multi=True %}
{% bulk_edit_button model query_params=request.GET %}
{% endif %}
{% if 'bulk_delete' in actions %}
{% bulk_delete_button model query_params=request.GET %}
{% endif %}
</div> </div>
{% endif %} {% endif %}
{% endif %} {% endif %}

View File

@@ -0,0 +1,22 @@
{% load i18n %}
<div class="btn-group">
<button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Components" %}
</button>
<ul class="dropdown-menu">
{% if perms.virtualization.add_vminterface %}
<li>
<button type="submit" {% formaction %}="{% url 'virtualization:virtualmachine_bulk_add_vminterface' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
{% trans "Interfaces" %}
</button>
</li>
{% endif %}
{% if perms.virtualization.add_virtualdisk %}
<li>
<button type="submit" {% formaction %}="{% url 'virtualization:virtualmachine_bulk_add_virtualdisk' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
{% trans "Virtual Disks" %}
</button>
</li>
{% endif %}
</ul>
</div>

View File

@@ -1,13 +0,0 @@
{% extends 'generic/object_children.html' %}
{% load i18n %}
{% block bulk_delete_controls %}
{{ block.super }}
{% if 'bulk_remove_devices' in actions %}
<button type="submit" name="_remove"
{% formaction %}="{% url 'virtualization:cluster_remove_devices' pk=object.pk %}?return_url={{ return_url }}"
class="btn btn-danger">
<span class="mdi mdi-trash-can-outline" aria-hidden="true"></span> {% trans "Remove Selected" %}
</button>
{% endif %}
{% endblock bulk_delete_controls %}

View File

@@ -1,14 +0,0 @@
{% extends 'generic/object_children.html' %}
{% load helpers %}
{% load i18n %}
{% block bulk_edit_controls %}
{{ block.super }}
{% if 'bulk_rename' in actions %}
<button type="submit" name="_rename"
{% formaction %}="{% url 'virtualization:vminterface_bulk_rename' %}?return_url={{ return_url }}"
class="btn btn-outline-warning">
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> {% trans "Rename" %}
</button>
{% endif %}
{% endblock bulk_edit_controls %}

View File

@@ -1,14 +0,0 @@
{% extends 'generic/object_children.html' %}
{% load helpers %}
{% load i18n %}
{% block bulk_edit_controls %}
{{ block.super }}
{% if 'bulk_rename' in actions %}
<button type="submit" name="_rename"
{% formaction %}="{% url 'virtualization:virtualdisk_bulk_rename' %}?return_url={{ return_url }}"
class="btn btn-outline-warning">
<i class="mdi mdi-pencil-outline" aria-hidden="true"></i> {% trans "Rename" %}
</button>
{% endif %}
{% endblock bulk_edit_controls %}

View File

@@ -1,29 +0,0 @@
{% extends 'generic/object_list.html' %}
{% load i18n %}
{% block bulk_buttons %}
{% if perms.virtualization.change_virtualmachine %}
<div class="dropdown">
<button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Components" %}
</button>
<ul class="dropdown-menu">
{% if perms.virtualization.add_vminterface %}
<li>
<button type="submit" {% formaction %}="{% url 'virtualization:virtualmachine_bulk_add_vminterface' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
{% trans "Interfaces" %}
</button>
</li>
{% endif %}
{% if perms.virtualization.add_virtualdisk %}
<li>
<button type="submit" {% formaction %}="{% url 'virtualization:virtualmachine_bulk_add_virtualdisk' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
{% trans "Virtual Disks" %}
</button>
</li>
{% endif %}
</ul>
</div>
{% endif %}
{{ block.super }}
{% endblock %}

View File

@@ -1,6 +1,7 @@
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from netbox.object_actions import BulkDelete, BulkEdit, BulkExport, BulkImport
from netbox.views import generic from netbox.views import generic
from utilities.query import count_related from utilities.query import count_related
from utilities.views import GetRelatedModelsMixin, register_model_view from utilities.views import GetRelatedModelsMixin, register_model_view
@@ -70,6 +71,11 @@ class TenantGroupBulkEditView(generic.BulkEditView):
form = forms.TenantGroupBulkEditForm form = forms.TenantGroupBulkEditForm
@register_model_view(TenantGroup, 'bulk_rename', path='rename', detail=False)
class TenantGroupBulkRenameView(generic.BulkRenameView):
queryset = TenantGroup.objects.all()
@register_model_view(TenantGroup, 'bulk_delete', path='delete', detail=False) @register_model_view(TenantGroup, 'bulk_delete', path='delete', detail=False)
class TenantGroupBulkDeleteView(generic.BulkDeleteView): class TenantGroupBulkDeleteView(generic.BulkDeleteView):
queryset = TenantGroup.objects.add_related_count( queryset = TenantGroup.objects.add_related_count(
@@ -131,6 +137,11 @@ class TenantBulkEditView(generic.BulkEditView):
form = forms.TenantBulkEditForm form = forms.TenantBulkEditForm
@register_model_view(Tenant, 'bulk_rename', path='rename', detail=False)
class TenantBulkRenameView(generic.BulkRenameView):
queryset = Tenant.objects.all()
@register_model_view(Tenant, 'bulk_delete', path='delete', detail=False) @register_model_view(Tenant, 'bulk_delete', path='delete', detail=False)
class TenantBulkDeleteView(generic.BulkDeleteView): class TenantBulkDeleteView(generic.BulkDeleteView):
queryset = Tenant.objects.all() queryset = Tenant.objects.all()
@@ -206,6 +217,11 @@ class ContactGroupBulkEditView(generic.BulkEditView):
form = forms.ContactGroupBulkEditForm form = forms.ContactGroupBulkEditForm
@register_model_view(ContactGroup, 'bulk_rename', path='rename', detail=False)
class ContactGroupBulkRenameView(generic.BulkRenameView):
queryset = ContactGroup.objects.all()
@register_model_view(ContactGroup, 'bulk_delete', path='delete', detail=False) @register_model_view(ContactGroup, 'bulk_delete', path='delete', detail=False)
class ContactGroupBulkDeleteView(generic.BulkDeleteView): class ContactGroupBulkDeleteView(generic.BulkDeleteView):
queryset = ContactGroup.objects.add_related_count( queryset = ContactGroup.objects.add_related_count(
@@ -267,6 +283,11 @@ class ContactRoleBulkEditView(generic.BulkEditView):
form = forms.ContactRoleBulkEditForm form = forms.ContactRoleBulkEditForm
@register_model_view(ContactRole, 'bulk_rename', path='rename', detail=False)
class ContactRoleBulkRenameView(generic.BulkRenameView):
queryset = ContactRole.objects.all()
@register_model_view(ContactRole, 'bulk_delete', path='delete', detail=False) @register_model_view(ContactRole, 'bulk_delete', path='delete', detail=False)
class ContactRoleBulkDeleteView(generic.BulkDeleteView): class ContactRoleBulkDeleteView(generic.BulkDeleteView):
queryset = ContactRole.objects.all() queryset = ContactRole.objects.all()
@@ -330,6 +351,11 @@ class ContactBulkEditView(generic.BulkEditView):
obj.groups.remove(*form.cleaned_data['remove_groups']) obj.groups.remove(*form.cleaned_data['remove_groups'])
@register_model_view(Contact, 'bulk_rename', path='rename', detail=False)
class ContactBulkRenameView(generic.BulkRenameView):
queryset = Contact.objects.all()
@register_model_view(Contact, 'bulk_delete', path='delete', detail=False) @register_model_view(Contact, 'bulk_delete', path='delete', detail=False)
class ContactBulkDeleteView(generic.BulkDeleteView): class ContactBulkDeleteView(generic.BulkDeleteView):
queryset = Contact.objects.annotate( queryset = Contact.objects.annotate(
@@ -349,12 +375,7 @@ class ContactAssignmentListView(generic.ObjectListView):
filterset = filtersets.ContactAssignmentFilterSet filterset = filtersets.ContactAssignmentFilterSet
filterset_form = forms.ContactAssignmentFilterForm filterset_form = forms.ContactAssignmentFilterForm
table = tables.ContactAssignmentTable table = tables.ContactAssignmentTable
actions = { actions = (BulkExport, BulkImport, BulkEdit, BulkDelete)
'export': {'view'},
'bulk_import': {'add'},
'bulk_edit': {'change'},
'bulk_delete': {'delete'},
}
@register_model_view(ContactAssignment, 'add', detail=False) @register_model_view(ContactAssignment, 'add', detail=False)

View File

@@ -62,7 +62,7 @@ class UserConfigForm(forms.ModelForm, metaclass=UserConfigFormMetaclass):
'ui.tables.striping', 'ui.tables.striping',
name=_('User Interface') name=_('User Interface')
), ),
FieldSet('data_format', name=_('Miscellaneous')), FieldSet('data_format', 'csv_delimiter', name=_('Miscellaneous')),
) )
# List of clearable preferences # List of clearable preferences
pk = forms.MultipleChoiceField( pk = forms.MultipleChoiceField(

View File

@@ -2,6 +2,7 @@ from django.db.models import Count
from core.models import ObjectChange from core.models import ObjectChange
from core.tables import ObjectChangeTable from core.tables import ObjectChangeTable
from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport, BulkRename
from netbox.views import generic from netbox.views import generic
from utilities.views import register_model_view from utilities.views import register_model_view
from . import filtersets, forms, tables from . import filtersets, forms, tables
@@ -18,6 +19,7 @@ class TokenListView(generic.ObjectListView):
filterset = filtersets.TokenFilterSet filterset = filtersets.TokenFilterSet
filterset_form = forms.TokenFilterForm filterset_form = forms.TokenFilterForm
table = tables.TokenTable table = tables.TokenTable
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
@register_model_view(Token) @register_model_view(Token)
@@ -111,6 +113,12 @@ class UserBulkEditView(generic.BulkEditView):
form = forms.UserBulkEditForm form = forms.UserBulkEditForm
@register_model_view(User, 'bulk_rename', path='rename', detail=False)
class UserBulkRenameView(generic.BulkRenameView):
queryset = User.objects.all()
field_name = 'username'
@register_model_view(User, 'bulk_delete', path='delete', detail=False) @register_model_view(User, 'bulk_delete', path='delete', detail=False)
class UserBulkDeleteView(generic.BulkDeleteView): class UserBulkDeleteView(generic.BulkDeleteView):
queryset = User.objects.all() queryset = User.objects.all()
@@ -162,6 +170,11 @@ class GroupBulkEditView(generic.BulkEditView):
form = forms.GroupBulkEditForm form = forms.GroupBulkEditForm
@register_model_view(Group, 'bulk_rename', path='rename', detail=False)
class GroupBulkRenameView(generic.BulkRenameView):
queryset = Group.objects.all()
@register_model_view(Group, 'bulk_delete', path='delete', detail=False) @register_model_view(Group, 'bulk_delete', path='delete', detail=False)
class GroupBulkDeleteView(generic.BulkDeleteView): class GroupBulkDeleteView(generic.BulkDeleteView):
queryset = Group.objects.annotate(users_count=Count('user')).order_by('name') queryset = Group.objects.annotate(users_count=Count('user')).order_by('name')
@@ -179,6 +192,7 @@ class ObjectPermissionListView(generic.ObjectListView):
filterset = filtersets.ObjectPermissionFilterSet filterset = filtersets.ObjectPermissionFilterSet
filterset_form = forms.ObjectPermissionFilterForm filterset_form = forms.ObjectPermissionFilterForm
table = tables.ObjectPermissionTable table = tables.ObjectPermissionTable
actions = (AddObject, BulkExport, BulkEdit, BulkRename, BulkDelete)
@register_model_view(ObjectPermission) @register_model_view(ObjectPermission)
@@ -207,6 +221,11 @@ class ObjectPermissionBulkEditView(generic.BulkEditView):
form = forms.ObjectPermissionBulkEditForm form = forms.ObjectPermissionBulkEditForm
@register_model_view(ObjectPermission, 'bulk_rename', path='rename', detail=False)
class ObjectPermissionBulkRenameView(generic.BulkRenameView):
queryset = ObjectPermission.objects.all()
@register_model_view(ObjectPermission, 'bulk_delete', path='delete', detail=False) @register_model_view(ObjectPermission, 'bulk_delete', path='delete', detail=False)
class ObjectPermissionBulkDeleteView(generic.BulkDeleteView): class ObjectPermissionBulkDeleteView(generic.BulkDeleteView):
queryset = ObjectPermission.objects.all() queryset = ObjectPermission.objects.all()

View File

@@ -0,0 +1,26 @@
from django.utils.translation import gettext_lazy as _
from django_tables2.export import TableExport as TableExport_
from netbox.constants import CSV_DELIMITERS
__all__ = (
'TableExport',
)
class TableExport(TableExport_):
"""
A subclass of django-tables2's TableExport class which allows us to specify a delimiting
characters for CSV exports.
"""
def __init__(self, *args, delimiter=None, **kwargs):
if delimiter and delimiter not in CSV_DELIMITERS.keys():
raise ValueError(_("Invalid delimiter name: {name}").format(name=delimiter))
self.delimiter = delimiter or 'comma'
super().__init__(*args, **kwargs)
def export(self):
if self.format == self.CSV and self.delimiter is not None:
delimiter = CSV_DELIMITERS[self.delimiter]
return self.dataset.export(self.format, delimiter=delimiter)
return super().export()

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