mirror of
https://github.com/netbox-community/netbox.git
synced 2026-01-09 13:22:18 -06:00
Compare commits
39 Commits
v4.3.3
...
c9688e7fca
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9688e7fca | ||
|
|
05472f326f | ||
|
|
058c0674be | ||
|
|
f0a3f6462f | ||
|
|
76e0ee837b | ||
|
|
27a7263f7c | ||
|
|
ecdd4bf076 | ||
|
|
5f8a4f6c43 | ||
|
|
b88b5b0b1b | ||
|
|
6eeb382512 | ||
|
|
e5d6c71171 | ||
|
|
f777bfee2e | ||
|
|
8b63eb64c1 | ||
|
|
cff29f9551 | ||
|
|
a5c0cae112 | ||
|
|
2a27e475e4 | ||
|
|
44efa037cc | ||
|
|
6c17629159 | ||
|
|
f13d028c98 | ||
|
|
f5d32b1bf1 | ||
|
|
f05897d61a | ||
|
|
21a840c32e | ||
|
|
b5421f1cd6 | ||
|
|
23cc4f1c41 | ||
|
|
875a641687 | ||
|
|
6022433a40 | ||
|
|
878c624eaf | ||
|
|
9c2cd66162 | ||
|
|
f61a2964c8 | ||
|
|
90e8a61670 | ||
|
|
ee94fb0b94 | ||
|
|
8fb8f4c75b | ||
|
|
a1cd81ff35 | ||
|
|
e33793dc82 | ||
|
|
3b8841ee3b | ||
|
|
ce12de8b6d | ||
|
|
ea4c205a37 | ||
|
|
601a77ac73 | ||
|
|
2a5d3abafb |
@@ -15,7 +15,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v4.3.3
|
||||
placeholder: v4.3.4
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/02-bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/02-bug_report.yaml
vendored
@@ -27,7 +27,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox Version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v4.3.3
|
||||
placeholder: v4.3.4
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<a href="https://github.com/netbox-community/netbox/graphs/contributors"><img src="https://img.shields.io/github/contributors/netbox-community/netbox?color=blue" alt="Contributors" /></a>
|
||||
<a href="https://github.com/netbox-community/netbox/stargazers"><img src="https://img.shields.io/github/stars/netbox-community/netbox?style=flat" alt="GitHub stars" /></a>
|
||||
<a href="https://explore.transifex.com/netbox-community/netbox/"><img src="https://img.shields.io/badge/languages-15-blue" alt="Languages supported" /></a>
|
||||
<a href="https://github.com/netbox-community/netbox/actions/workflows/ci.yml"><img src="https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=main" alt="CI status" /></a>
|
||||
<a href="https://github.com/netbox-community/netbox/actions/workflows/ci.yml"><img src="https://github.com/netbox-community/netbox/actions/workflows/ci.yml/badge.svg" alt="CI status" /></a>
|
||||
<p>
|
||||
<strong><a href="https://netboxlabs.com/community/">NetBox Community</a></strong> |
|
||||
<strong><a href="https://netboxlabs.com/netbox-cloud/">NetBox Cloud</a></strong> |
|
||||
|
||||
@@ -14,6 +14,10 @@ django-debug-toolbar
|
||||
# https://github.com/carltongibson/django-filter/blob/main/CHANGES.rst
|
||||
django-filter
|
||||
|
||||
# Django Debug Toolbar extension for GraphiQL
|
||||
# https://github.com/flavors/django-graphiql-debug-toolbar/blob/main/CHANGES.rst
|
||||
django-graphiql-debug-toolbar
|
||||
|
||||
# HTMX utilities for Django
|
||||
# https://django-htmx.readthedocs.io/en/latest/changelog.html
|
||||
django-htmx
|
||||
@@ -108,6 +112,7 @@ nh3
|
||||
|
||||
# Fork of PIL (Python Imaging Library) for image processing
|
||||
# https://github.com/python-pillow/Pillow/releases
|
||||
# https://pillow.readthedocs.io/en/stable/releasenotes/
|
||||
Pillow
|
||||
|
||||
# PostgreSQL database adapter for Python
|
||||
@@ -126,14 +131,14 @@ requests
|
||||
# https://github.com/rq/rq/blob/master/CHANGES.md
|
||||
rq
|
||||
|
||||
# Social authentication framework
|
||||
# https://github.com/python-social-auth/social-core/blob/master/CHANGELOG.md
|
||||
social-auth-core
|
||||
|
||||
# Django app for social-auth-core
|
||||
# https://github.com/python-social-auth/social-app-django/blob/master/CHANGELOG.md
|
||||
social-auth-app-django
|
||||
|
||||
# Social authentication framework
|
||||
# https://github.com/python-social-auth/social-core/blob/master/CHANGELOG.md
|
||||
social-auth-core
|
||||
|
||||
# Strawberry GraphQL
|
||||
# https://github.com/strawberry-graphql/strawberry/blob/main/CHANGELOG.md
|
||||
strawberry-graphql
|
||||
|
||||
@@ -158,6 +158,8 @@ LOGGING = {
|
||||
* `netbox.<app>.<model>` - Generic form for model-specific log messages
|
||||
* `netbox.auth.*` - Authentication events
|
||||
* `netbox.api.views.*` - Views which handle business logic for the REST API
|
||||
* `netbox.event_rules` - Event rules
|
||||
* `netbox.jobs.*` - Background jobs
|
||||
* `netbox.reports.*` - Report execution (`module.name`)
|
||||
* `netbox.scripts.*` - Custom script execution (`module.name`)
|
||||
* `netbox.views.*` - Views which handle business logic for the web UI
|
||||
|
||||
@@ -147,7 +147,7 @@ For UI development you will need to review the [Web UI Development Guide](web-ui
|
||||
|
||||
## Populating Demo Data
|
||||
|
||||
Once you have your development environment up and running, it might be helpful to populate some "dummy" data to make interacting with the UI and APIs more convenient. Check out the [netbox-demo-data](https://github.com/netbox-community/netbox-demo-data) repo on GitHub, which houses a collection of sample data that can be easily imported to any new NetBox deployment. (This sample data is used to populate the public demo instance at <https://demo.netbox.dev>.)
|
||||
Once you have your development environment up and running, it might be helpful to populate some "dummy" data to make interacting with the UI and APIs more convenient. Check out the [netbox-demo-data](https://github.com/netbox-community/netbox-demo-data) repo on GitHub, which houses a collection of sample data that can be easily imported to any new NetBox deployment. This sample data is used to populate the [public demo instance](https://demo.netbox.dev).
|
||||
|
||||
The demo data is provided in JSON format and loaded into an empty database using Django's `loaddata` management command. Consult the demo data repo's `README` file for complete instructions on populating the data.
|
||||
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
NetBox includes the ability to execute certain functions as background tasks. These include:
|
||||
|
||||
* [Report](../customization/reports.md) execution
|
||||
* [Custom script](../customization/custom-scripts.md) execution
|
||||
* Synchronization of [remote data sources](../integrations/synchronized-data.md)
|
||||
* Housekeeping tasks
|
||||
|
||||
Additionally, NetBox plugins can enqueue their own background tasks. This is accomplished using the [Job model](../models/core/job.md). Background tasks are executed by the `rqworker` process(es).
|
||||
|
||||
|
||||
@@ -135,7 +135,7 @@ Check out the desired release by specifying its tag. For example:
|
||||
|
||||
```
|
||||
cd /opt/netbox && \
|
||||
sudo git fetch && \
|
||||
sudo git fetch --tags && \
|
||||
sudo git checkout v4.2.7
|
||||
```
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 24 KiB |
@@ -10,11 +10,11 @@ The assignment of platforms to devices is an optional feature, and may be disreg
|
||||
|
||||
### Name
|
||||
|
||||
A unique human-friendly name.
|
||||
A human-friendly name for the platform. Must be unique per manufacturer.
|
||||
|
||||
### 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
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ A background job implements a basic [Job](../../models/core/job.md) executor for
|
||||
```python title="jobs.py"
|
||||
from netbox.jobs import JobRunner
|
||||
|
||||
|
||||
class MyTestJob(JobRunner):
|
||||
class Meta:
|
||||
name = "My Test Job"
|
||||
@@ -25,6 +24,8 @@ class MyTestJob(JobRunner):
|
||||
# your logic goes here
|
||||
```
|
||||
|
||||
Completed jobs will have their status updated to "completed" by default, or "errored" if an unhandled exception was raised by the `run()` method. To intentionally mark a job as failed, raise the `core.exceptions.JobFailed` exception. (Note that "failed" differs from "errored" in that a failure may be expected under certain conditions, whereas an error is not.)
|
||||
|
||||
You can schedule the background job from within your code (e.g. from a model's `save()` method or a view) by calling `MyTestJob.enqueue()`. This method passes through all arguments to `Job.enqueue()`. However, no `name` argument must be passed, as the background job name will be used instead.
|
||||
|
||||
!!! tip
|
||||
@@ -38,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.
|
||||
|
||||
### 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
|
||||
|
||||
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()`.
|
||||
|
||||
@@ -64,6 +64,7 @@ Generic view classes (documented below) facilitate common operations, such as cr
|
||||
| `ObjectListView` | View a list of objects |
|
||||
| `BulkImportView` | Import a set of new objects |
|
||||
| `BulkEditView` | Edit multiple objects |
|
||||
| `BulkRenameView` | Rename multiple objects |
|
||||
| `BulkDeleteView` | Delete multiple objects |
|
||||
|
||||
!!! warning
|
||||
@@ -171,6 +172,10 @@ Below are the class definitions for NetBox's multi-object views. These views han
|
||||
options:
|
||||
members: false
|
||||
|
||||
::: netbox.views.generic.BulkRenameView
|
||||
options:
|
||||
members: false
|
||||
|
||||
::: netbox.views.generic.BulkDeleteView
|
||||
options:
|
||||
members:
|
||||
|
||||
@@ -1,5 +1,27 @@
|
||||
# NetBox v4.3
|
||||
|
||||
## v4.3.4 (2025-07-15)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#18811](https://github.com/netbox-community/netbox/issues/18811) - Match expanded form IPv6 addresses in global search
|
||||
* [#19550](https://github.com/netbox-community/netbox/issues/19550) - Enable lazy loading for rack elevations
|
||||
* [#19571](https://github.com/netbox-community/netbox/issues/19571) - Add a default module type profile for expansion cards
|
||||
* [#19793](https://github.com/netbox-community/netbox/issues/19793) - Support custom dynamic navigation menu links
|
||||
* [#19828](https://github.com/netbox-community/netbox/issues/19828) - Expose L2VPN termination in interface GraphQL response
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#19413](https://github.com/netbox-community/netbox/issues/19413) - Custom fields should be grouped in filter forms
|
||||
* [#19633](https://github.com/netbox-community/netbox/issues/19633) - Introduce InvalidCondition exception and log all evaluations of invalid event rule conditions
|
||||
* [#19800](https://github.com/netbox-community/netbox/issues/19800) - Module type bulk import should support profile assignment
|
||||
* [#19806](https://github.com/netbox-community/netbox/issues/19806) - Introduce JobFailed exception to allow marking background jobs as failed
|
||||
* [#19827](https://github.com/netbox-community/netbox/issues/19827) - Enforce uniqueness for device role names & slugs
|
||||
* [#19839](https://github.com/netbox-community/netbox/issues/19839) - Enable export of parent assignment for recursively nested objects
|
||||
* [#19876](https://github.com/netbox-community/netbox/issues/19876) - Remove Markdown rendering from CustomFieldChoiceSet description field
|
||||
|
||||
---
|
||||
|
||||
## v4.3.3 (2025-06-26)
|
||||
|
||||
### Enhancements
|
||||
|
||||
@@ -5,6 +5,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from dcim.views import PathTraceView
|
||||
from ipam.models import ASN
|
||||
from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport
|
||||
from netbox.views import generic
|
||||
from utilities.forms import ConfirmationForm
|
||||
from utilities.query import count_related
|
||||
@@ -79,6 +80,11 @@ class ProviderBulkEditView(generic.BulkEditView):
|
||||
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)
|
||||
class ProviderBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = Provider.objects.annotate(
|
||||
@@ -141,6 +147,11 @@ class ProviderAccountBulkEditView(generic.BulkEditView):
|
||||
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)
|
||||
class ProviderAccountBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = ProviderAccount.objects.annotate(
|
||||
@@ -212,6 +223,11 @@ class ProviderNetworkBulkEditView(generic.BulkEditView):
|
||||
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)
|
||||
class ProviderNetworkBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = ProviderNetwork.objects.all()
|
||||
@@ -271,6 +287,11 @@ class CircuitTypeBulkEditView(generic.BulkEditView):
|
||||
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)
|
||||
class CircuitTypeBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = CircuitType.objects.annotate(
|
||||
@@ -337,6 +358,12 @@ class CircuitBulkEditView(generic.BulkEditView):
|
||||
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)
|
||||
class CircuitBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = Circuit.objects.prefetch_related(
|
||||
@@ -432,6 +459,7 @@ class CircuitTerminationListView(generic.ObjectListView):
|
||||
filterset = filtersets.CircuitTerminationFilterSet
|
||||
filterset_form = forms.CircuitTerminationFilterForm
|
||||
table = tables.CircuitTerminationTable
|
||||
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
|
||||
|
||||
|
||||
@register_model_view(CircuitTermination)
|
||||
@@ -526,6 +554,11 @@ class CircuitGroupBulkEditView(generic.BulkEditView):
|
||||
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)
|
||||
class CircuitGroupBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = CircuitGroup.objects.all()
|
||||
@@ -543,6 +576,7 @@ class CircuitGroupAssignmentListView(generic.ObjectListView):
|
||||
filterset = filtersets.CircuitGroupAssignmentFilterSet
|
||||
filterset_form = forms.CircuitGroupAssignmentFilterForm
|
||||
table = tables.CircuitGroupAssignmentTable
|
||||
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
|
||||
|
||||
|
||||
@register_model_view(CircuitGroupAssignment)
|
||||
@@ -635,6 +669,11 @@ class VirtualCircuitTypeBulkEditView(generic.BulkEditView):
|
||||
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)
|
||||
class VirtualCircuitTypeBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = VirtualCircuitType.objects.annotate(
|
||||
@@ -697,6 +736,12 @@ class VirtualCircuitBulkEditView(generic.BulkEditView):
|
||||
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):
|
||||
queryset = VirtualCircuit.objects.annotate(
|
||||
termination_count=count_related(VirtualCircuitTermination, 'virtual_circuit')
|
||||
@@ -714,6 +759,7 @@ class VirtualCircuitTerminationListView(generic.ObjectListView):
|
||||
filterset = filtersets.VirtualCircuitTerminationFilterSet
|
||||
filterset_form = forms.VirtualCircuitTerminationFilterForm
|
||||
table = tables.VirtualCircuitTerminationTable
|
||||
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
|
||||
|
||||
|
||||
@register_model_view(VirtualCircuitTermination)
|
||||
|
||||
@@ -23,6 +23,6 @@ class JobSerializer(BaseModelSerializer):
|
||||
model = Job
|
||||
fields = [
|
||||
'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')
|
||||
|
||||
@@ -4,23 +4,31 @@ from django.utils.translation import gettext_lazy as _
|
||||
from rq.job import JobStatus
|
||||
|
||||
__all__ = (
|
||||
'JOB_LOG_ENTRY_LEVELS',
|
||||
'RQ_TASK_STATUSES',
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Status:
|
||||
class Badge:
|
||||
label: str
|
||||
color: str
|
||||
|
||||
|
||||
RQ_TASK_STATUSES = {
|
||||
JobStatus.QUEUED: Status(_('Queued'), 'cyan'),
|
||||
JobStatus.FINISHED: Status(_('Finished'), 'green'),
|
||||
JobStatus.FAILED: Status(_('Failed'), 'red'),
|
||||
JobStatus.STARTED: Status(_('Started'), 'blue'),
|
||||
JobStatus.DEFERRED: Status(_('Deferred'), 'gray'),
|
||||
JobStatus.SCHEDULED: Status(_('Scheduled'), 'purple'),
|
||||
JobStatus.STOPPED: Status(_('Stopped'), 'orange'),
|
||||
JobStatus.CANCELED: Status(_('Cancelled'), 'yellow'),
|
||||
JobStatus.QUEUED: Badge(_('Queued'), 'cyan'),
|
||||
JobStatus.FINISHED: Badge(_('Finished'), 'green'),
|
||||
JobStatus.FAILED: Badge(_('Failed'), 'red'),
|
||||
JobStatus.STARTED: Badge(_('Started'), 'blue'),
|
||||
JobStatus.DEFERRED: Badge(_('Deferred'), 'gray'),
|
||||
JobStatus.SCHEDULED: Badge(_('Scheduled'), 'purple'),
|
||||
JobStatus.STOPPED: Badge(_('Stopped'), 'orange'),
|
||||
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'),
|
||||
}
|
||||
|
||||
21
netbox/core/dataclasses.py
Normal file
21
netbox/core/dataclasses.py
Normal 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)
|
||||
@@ -1,9 +1,19 @@
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
|
||||
class SyncError(Exception):
|
||||
pass
|
||||
__all__ = (
|
||||
'IncompatiblePluginError',
|
||||
'JobFailed',
|
||||
'SyncError',
|
||||
)
|
||||
|
||||
|
||||
class IncompatiblePluginError(ImproperlyConfigured):
|
||||
pass
|
||||
|
||||
|
||||
class JobFailed(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class SyncError(Exception):
|
||||
pass
|
||||
|
||||
@@ -7,7 +7,6 @@ from netbox.jobs import JobRunner, system_job
|
||||
from netbox.search.backends import search_backend
|
||||
from utilities.proxy import resolve_proxies
|
||||
from .choices import DataSourceStatusChoices, JobIntervalChoices
|
||||
from .exceptions import SyncError
|
||||
from .models import DataSource
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -23,19 +22,23 @@ class SyncDataSourceJob(JobRunner):
|
||||
|
||||
def run(self, *args, **kwargs):
|
||||
datasource = DataSource.objects.get(pk=self.job.object_id)
|
||||
self.logger.debug(f"Found DataSource ID {datasource.pk}")
|
||||
|
||||
try:
|
||||
self.logger.info(f"Syncing data source {datasource}")
|
||||
datasource.sync()
|
||||
|
||||
# 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())
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error syncing data source: {e}")
|
||||
DataSource.objects.filter(pk=datasource.pk).update(status=DataSourceStatusChoices.FAILED)
|
||||
if type(e) is SyncError:
|
||||
logging.error(e)
|
||||
raise e
|
||||
|
||||
self.logger.info("Syncing completed successfully")
|
||||
|
||||
|
||||
@system_job(interval=JobIntervalChoices.INTERVAL_DAILY)
|
||||
class SystemHousekeepingJob(JobRunner):
|
||||
|
||||
28
netbox/core/migrations/0016_job_log_entries.py
Normal file
28
netbox/core/migrations/0016_job_log_entries.py
Normal 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
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -1,7 +1,9 @@
|
||||
from django.contrib.contenttypes.models import ContentType, ContentTypeManager
|
||||
from django.db.models import Q
|
||||
|
||||
from netbox.plugins import PluginConfig
|
||||
from netbox.registry import registry
|
||||
from utilities.string import title
|
||||
|
||||
__all__ = (
|
||||
'ObjectType',
|
||||
@@ -48,3 +50,29 @@ class ObjectType(ContentType):
|
||||
|
||||
class Meta:
|
||||
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)
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import logging
|
||||
import uuid
|
||||
from dataclasses import asdict
|
||||
from functools import partial
|
||||
|
||||
import django_rq
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.postgres.fields import ArrayField
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.core.validators import MinValueValidator
|
||||
@@ -14,8 +17,10 @@ from django.utils.translation import gettext as _
|
||||
from rq.exceptions import InvalidJobOperation
|
||||
|
||||
from core.choices import JobStatusChoices
|
||||
from core.dataclasses import JobLogEntry
|
||||
from core.models import ObjectType
|
||||
from core.signals import job_end, job_start
|
||||
from utilities.json import JobLogDecoder
|
||||
from utilities.querysets import RestrictedQuerySet
|
||||
from utilities.rqworker import get_queue_for_model
|
||||
|
||||
@@ -104,6 +109,15 @@ class Job(models.Model):
|
||||
verbose_name=_('job ID'),
|
||||
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()
|
||||
|
||||
@@ -116,7 +130,7 @@ class Job(models.Model):
|
||||
verbose_name_plural = _('jobs')
|
||||
|
||||
def __str__(self):
|
||||
return str(self.job_id)
|
||||
return self.name
|
||||
|
||||
def get_absolute_url(self):
|
||||
# TODO: Employ dynamic registration
|
||||
@@ -187,15 +201,14 @@ class Job(models.Model):
|
||||
"""
|
||||
Mark the job as completed, optionally specifying a particular termination status.
|
||||
"""
|
||||
valid_statuses = JobStatusChoices.TERMINAL_STATE_CHOICES
|
||||
if status not in valid_statuses:
|
||||
if status not in JobStatusChoices.TERMINAL_STATE_CHOICES:
|
||||
raise ValueError(
|
||||
_("Invalid status for job termination. Choices are: {choices}").format(
|
||||
choices=', '.join(valid_statuses)
|
||||
choices=', '.join(JobStatusChoices.TERMINAL_STATE_CHOICES)
|
||||
)
|
||||
)
|
||||
|
||||
# Mark the job as completed
|
||||
# Set the job's status and completion time
|
||||
self.status = status
|
||||
if error:
|
||||
self.error = error
|
||||
@@ -205,6 +218,13 @@ class Job(models.Model):
|
||||
# Send signal
|
||||
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
|
||||
def enqueue(
|
||||
cls,
|
||||
|
||||
18
netbox/core/object_actions.py
Normal file
18
netbox/core/object_actions.py
Normal 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'
|
||||
@@ -1,12 +1,11 @@
|
||||
import django_tables2 as tables
|
||||
from django.utils.safestring import mark_safe
|
||||
|
||||
from core.constants import RQ_TASK_STATUSES
|
||||
from netbox.registry import registry
|
||||
|
||||
__all__ = (
|
||||
'BackendTypeColumn',
|
||||
'RQJobStatusColumn',
|
||||
'BadgeColumn',
|
||||
)
|
||||
|
||||
|
||||
@@ -23,14 +22,21 @@ class BackendTypeColumn(tables.Column):
|
||||
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):
|
||||
status = RQ_TASK_STATUSES.get(value)
|
||||
return mark_safe(f'<span class="badge text-bg-{status.color}">{status.label}</span>')
|
||||
badge = self.badges.get(value)
|
||||
return mark_safe(f'<span class="badge text-bg-{badge.color}">{badge.label}</span>')
|
||||
|
||||
def value(self, value):
|
||||
status = RQ_TASK_STATUSES.get(value)
|
||||
return status.label
|
||||
badge = self.badges.get(value)
|
||||
return badge.label
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import django_tables2 as tables
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from netbox.tables import NetBoxTable, columns
|
||||
from ..models import Job
|
||||
from netbox.tables import BaseTable, NetBoxTable, columns
|
||||
from core.constants import JOB_LOG_ENTRY_LEVELS
|
||||
from core.models import Job
|
||||
from core.tables.columns import BadgeColumn
|
||||
|
||||
|
||||
class JobTable(NetBoxTable):
|
||||
@@ -40,6 +42,9 @@ class JobTable(NetBoxTable):
|
||||
completed = columns.DateTimeColumn(
|
||||
verbose_name=_('Completed'),
|
||||
)
|
||||
log_entries = tables.Column(
|
||||
verbose_name=_('Log Entries'),
|
||||
)
|
||||
actions = columns.ActionsColumn(
|
||||
actions=('delete',)
|
||||
)
|
||||
@@ -53,3 +58,24 @@ class JobTable(NetBoxTable):
|
||||
default_columns = (
|
||||
'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')
|
||||
|
||||
@@ -2,7 +2,8 @@ import django_tables2 as tables
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
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
|
||||
|
||||
|
||||
@@ -84,7 +85,8 @@ class BackgroundTaskTable(BaseTable):
|
||||
ended_at = columns.DateTimeColumn(
|
||||
verbose_name=_("Ended")
|
||||
)
|
||||
status = RQJobStatusColumn(
|
||||
status = BadgeColumn(
|
||||
badges=RQ_TASK_STATUSES,
|
||||
verbose_name=_("Status"),
|
||||
accessor='get_status'
|
||||
)
|
||||
|
||||
@@ -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 netbox.config import get_config, PARAMS
|
||||
from netbox.object_actions import AddObject, BulkDelete, BulkExport, DeleteObject
|
||||
from netbox.registry import registry
|
||||
from netbox.views import generic
|
||||
from netbox.views.generic.base import BaseObjectView
|
||||
@@ -31,13 +32,13 @@ from utilities.forms import ConfirmationForm
|
||||
from utilities.htmx import htmx_partial
|
||||
from utilities.json import ConfigJSONEncoder
|
||||
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 .choices import DataSourceStatusChoices
|
||||
from .jobs import SyncDataSourceJob
|
||||
from .models import *
|
||||
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
|
||||
|
||||
|
||||
@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)
|
||||
class DataSourceBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = DataSource.objects.annotate(
|
||||
@@ -138,14 +144,13 @@ class DataFileListView(generic.ObjectListView):
|
||||
filterset = filtersets.DataFileFilterSet
|
||||
filterset_form = forms.DataFileFilterForm
|
||||
table = tables.DataFileTable
|
||||
actions = {
|
||||
'bulk_delete': {'delete'},
|
||||
}
|
||||
actions = (BulkDelete,)
|
||||
|
||||
|
||||
@register_model_view(DataFile)
|
||||
class DataFileView(generic.ObjectView):
|
||||
queryset = DataFile.objects.all()
|
||||
actions = (DeleteObject,)
|
||||
|
||||
|
||||
@register_model_view(DataFile, 'delete')
|
||||
@@ -170,15 +175,32 @@ class JobListView(generic.ObjectListView):
|
||||
filterset = filtersets.JobFilterSet
|
||||
filterset_form = forms.JobFilterForm
|
||||
table = tables.JobTable
|
||||
actions = {
|
||||
'export': {'view'},
|
||||
'bulk_delete': {'delete'},
|
||||
}
|
||||
actions = (BulkExport, BulkDelete)
|
||||
|
||||
|
||||
@register_model_view(Job)
|
||||
class JobView(generic.ObjectView):
|
||||
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')
|
||||
@@ -204,9 +226,7 @@ class ObjectChangeListView(generic.ObjectListView):
|
||||
filterset_form = forms.ObjectChangeFilterForm
|
||||
table = tables.ObjectChangeTable
|
||||
template_name = 'core/objectchange_list.html'
|
||||
actions = {
|
||||
'export': {'view'},
|
||||
}
|
||||
actions = (BulkExport,)
|
||||
|
||||
|
||||
@register_model_view(ObjectChange)
|
||||
@@ -274,6 +294,7 @@ class ConfigRevisionListView(generic.ObjectListView):
|
||||
filterset = filtersets.ConfigRevisionFilterSet
|
||||
filterset_form = forms.ConfigRevisionFilterForm
|
||||
table = tables.ConfigRevisionTable
|
||||
actions = (AddObject, BulkExport)
|
||||
|
||||
|
||||
@register_model_view(ConfigRevision)
|
||||
|
||||
@@ -470,8 +470,8 @@ class ModuleTypeImportForm(NetBoxModelImportForm):
|
||||
class Meta:
|
||||
model = ModuleType
|
||||
fields = [
|
||||
'manufacturer', 'model', 'part_number', 'description', 'airflow', 'weight', 'weight_unit', 'comments',
|
||||
'tags',
|
||||
'manufacturer', 'model', 'part_number', 'description', 'airflow', 'weight', 'weight_unit', 'profile',
|
||||
'comments', 'tags'
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -1507,7 +1507,7 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
|
||||
tx_power = forms.IntegerField(
|
||||
required=False,
|
||||
label=_('Transmit power (dBm)'),
|
||||
min_value=0,
|
||||
min_value=-40,
|
||||
max_value=127
|
||||
)
|
||||
vrf_id = DynamicModelMultipleChoiceField(
|
||||
|
||||
@@ -33,6 +33,7 @@ if TYPE_CHECKING:
|
||||
from tenancy.graphql.types import TenantType
|
||||
from users.graphql.types import UserType
|
||||
from virtualization.graphql.types import ClusterType, VMInterfaceType, VirtualMachineType
|
||||
from vpn.graphql.types import L2VPNTerminationType
|
||||
from wireless.graphql.types import WirelessLANType, WirelessLinkType
|
||||
|
||||
__all__ = (
|
||||
@@ -440,6 +441,7 @@ class InterfaceType(IPAddressesMixin, ModularComponentType, CabledObjectMixin, P
|
||||
primary_mac_address: Annotated["MACAddressType", strawberry.lazy('dcim.graphql.types')] | None
|
||||
qinq_svlan: Annotated["VLANType", strawberry.lazy('ipam.graphql.types')] | None
|
||||
vlan_translation_policy: Annotated["VLANTranslationPolicyType", strawberry.lazy('ipam.graphql.types')] | None
|
||||
l2vpn_termination: Annotated["L2VPNTerminationType", strawberry.lazy('vpn.graphql.types')] | None
|
||||
|
||||
vdcs: List[Annotated["VirtualDeviceContextType", strawberry.lazy('dcim.graphql.types')]]
|
||||
tagged_vlans: List[Annotated["VLANType", strawberry.lazy('ipam.graphql.types')]]
|
||||
|
||||
@@ -19,7 +19,8 @@ def load_initial_data(apps, schema_editor):
|
||||
'gpu',
|
||||
'hard_disk',
|
||||
'memory',
|
||||
'power_supply'
|
||||
'power_supply',
|
||||
'expansion_card'
|
||||
)
|
||||
|
||||
for name in initial_profiles:
|
||||
|
||||
@@ -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.'
|
||||
),
|
||||
),
|
||||
]
|
||||
24
netbox/dcim/migrations/0209_interface_tx_power_negative.py
Normal file
24
netbox/dcim/migrations/0209_interface_tx_power_negative.py
Normal 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)
|
||||
]
|
||||
),
|
||||
),
|
||||
]
|
||||
44
netbox/dcim/migrations/0210_devicerole_uniqueness.py
Normal file
44
netbox/dcim/migrations/0210_devicerole_uniqueness.py
Normal file
@@ -0,0 +1,44 @@
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('dcim', '0209_interface_tx_power_negative'),
|
||||
('extras', '0129_fix_script_paths'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddConstraint(
|
||||
model_name='devicerole',
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=('parent', 'name'),
|
||||
name='dcim_devicerole_parent_name'
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='devicerole',
|
||||
constraint=models.UniqueConstraint(
|
||||
condition=models.Q(('parent__isnull', True)),
|
||||
fields=('name',),
|
||||
name='dcim_devicerole_name',
|
||||
violation_error_message='A top-level device role with this name already exists.'
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='devicerole',
|
||||
constraint=models.UniqueConstraint(
|
||||
fields=('parent', 'slug'),
|
||||
name='dcim_devicerole_parent_slug'
|
||||
),
|
||||
),
|
||||
migrations.AddConstraint(
|
||||
model_name='devicerole',
|
||||
constraint=models.UniqueConstraint(
|
||||
condition=models.Q(('parent__isnull', True)),
|
||||
fields=('slug',),
|
||||
name='dcim_devicerole_slug',
|
||||
violation_error_message='A top-level device role with this slug already exists.'
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "Expansion card",
|
||||
"schema": {
|
||||
"properties": {
|
||||
"connector_type": {
|
||||
"type": "string",
|
||||
"description": "Connector type e.g. PCIe x4"
|
||||
},
|
||||
"bandwidth": {
|
||||
"type": "integer",
|
||||
"description": "Total Bandwidth for this module"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -719,10 +719,13 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
|
||||
verbose_name=('channel width (MHz)'),
|
||||
help_text=_("Populated by selected channel (if set)")
|
||||
)
|
||||
tx_power = models.PositiveSmallIntegerField(
|
||||
tx_power = models.SmallIntegerField(
|
||||
blank=True,
|
||||
null=True,
|
||||
validators=(MaxValueValidator(127),),
|
||||
validators=(
|
||||
MinValueValidator(-40),
|
||||
MaxValueValidator(127),
|
||||
),
|
||||
verbose_name=_('transmit power (dBm)')
|
||||
)
|
||||
poe_mode = models.CharField(
|
||||
|
||||
@@ -398,6 +398,28 @@ class DeviceRole(NestedGroupModel):
|
||||
|
||||
class Meta:
|
||||
ordering = ('name',)
|
||||
constraints = (
|
||||
models.UniqueConstraint(
|
||||
fields=('parent', 'name'),
|
||||
name='%(app_label)s_%(class)s_parent_name'
|
||||
),
|
||||
models.UniqueConstraint(
|
||||
fields=('name',),
|
||||
name='%(app_label)s_%(class)s_name',
|
||||
condition=Q(parent__isnull=True),
|
||||
violation_error_message=_("A top-level device role with this name already exists.")
|
||||
),
|
||||
models.UniqueConstraint(
|
||||
fields=('parent', 'slug'),
|
||||
name='%(app_label)s_%(class)s_parent_slug'
|
||||
),
|
||||
models.UniqueConstraint(
|
||||
fields=('slug',),
|
||||
name='%(app_label)s_%(class)s_slug',
|
||||
condition=Q(parent__isnull=True),
|
||||
violation_error_message=_("A top-level device role with this slug already exists.")
|
||||
),
|
||||
)
|
||||
verbose_name = _('device role')
|
||||
verbose_name_plural = _('device roles')
|
||||
|
||||
@@ -415,6 +437,15 @@ class Platform(OrganizationalModel):
|
||||
null=True,
|
||||
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(
|
||||
to='extras.ConfigTemplate',
|
||||
on_delete=models.PROTECT,
|
||||
@@ -427,6 +458,28 @@ class Platform(OrganizationalModel):
|
||||
ordering = ('name',)
|
||||
verbose_name = _('platform')
|
||||
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(
|
||||
|
||||
38
netbox/dcim/object_actions.py
Normal file
38
netbox/dcim/object_actions.py
Normal 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'
|
||||
@@ -63,6 +63,10 @@ class DeviceRoleTable(NetBoxTable):
|
||||
verbose_name=_('Name'),
|
||||
linkify=True
|
||||
)
|
||||
parent = tables.Column(
|
||||
verbose_name=_('Parent'),
|
||||
linkify=True,
|
||||
)
|
||||
device_count = columns.LinkedCountColumn(
|
||||
viewname='dcim:device_list',
|
||||
url_params={'role_id': 'pk'},
|
||||
@@ -88,8 +92,8 @@ class DeviceRoleTable(NetBoxTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = models.DeviceRole
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'config_template', 'description',
|
||||
'slug', 'tags', 'actions', 'created', 'last_updated',
|
||||
'pk', 'id', 'name', 'parent', 'device_count', 'vm_count', 'color', 'vm_role', 'config_template',
|
||||
'description', 'slug', 'tags', 'actions', 'created', 'last_updated',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'device_count', 'vm_count', 'color', 'vm_role', 'description')
|
||||
|
||||
|
||||
@@ -24,6 +24,10 @@ class RegionTable(ContactsColumnMixin, NetBoxTable):
|
||||
verbose_name=_('Name'),
|
||||
linkify=True
|
||||
)
|
||||
parent = tables.Column(
|
||||
verbose_name=_('Parent'),
|
||||
linkify=True,
|
||||
)
|
||||
site_count = columns.LinkedCountColumn(
|
||||
viewname='dcim:site_list',
|
||||
url_params={'region_id': 'pk'},
|
||||
@@ -39,7 +43,7 @@ class RegionTable(ContactsColumnMixin, NetBoxTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = Region
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'slug', 'site_count', 'description', 'comments', 'contacts', 'tags',
|
||||
'pk', 'id', 'name', 'parent', 'slug', 'site_count', 'description', 'comments', 'contacts', 'tags',
|
||||
'created', 'last_updated', 'actions',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'site_count', 'description')
|
||||
@@ -54,6 +58,10 @@ class SiteGroupTable(ContactsColumnMixin, NetBoxTable):
|
||||
verbose_name=_('Name'),
|
||||
linkify=True
|
||||
)
|
||||
parent = tables.Column(
|
||||
verbose_name=_('Parent'),
|
||||
linkify=True,
|
||||
)
|
||||
site_count = columns.LinkedCountColumn(
|
||||
viewname='dcim:site_list',
|
||||
url_params={'group_id': 'pk'},
|
||||
@@ -69,7 +77,7 @@ class SiteGroupTable(ContactsColumnMixin, NetBoxTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = SiteGroup
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'slug', 'site_count', 'description', 'comments', 'contacts', 'tags',
|
||||
'pk', 'id', 'name', 'parent', 'slug', 'site_count', 'description', 'comments', 'contacts', 'tags',
|
||||
'created', 'last_updated', 'actions',
|
||||
)
|
||||
default_columns = ('pk', 'name', 'site_count', 'description')
|
||||
@@ -135,6 +143,10 @@ class LocationTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
||||
verbose_name=_('Name'),
|
||||
linkify=True
|
||||
)
|
||||
parent = tables.Column(
|
||||
verbose_name=_('Parent'),
|
||||
linkify=True,
|
||||
)
|
||||
site = tables.Column(
|
||||
verbose_name=_('Site'),
|
||||
linkify=True
|
||||
@@ -170,8 +182,8 @@ class LocationTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
||||
class Meta(NetBoxTable.Meta):
|
||||
model = Location
|
||||
fields = (
|
||||
'pk', 'id', 'name', 'site', 'status', 'facility', 'tenant', 'tenant_group', 'rack_count', 'device_count',
|
||||
'description', 'slug', 'comments', 'contacts', 'tags', 'actions', 'created', 'last_updated',
|
||||
'pk', 'id', 'name', 'parent', 'site', 'status', 'facility', 'tenant', 'tenant_group', 'rack_count',
|
||||
'device_count', 'description', 'slug', 'comments', 'contacts', 'tags', 'actions', 'created', 'last_updated',
|
||||
'vlangroup_count',
|
||||
)
|
||||
default_columns = (
|
||||
|
||||
@@ -3,7 +3,7 @@ from decimal import Decimal
|
||||
from zoneinfo import ZoneInfo
|
||||
|
||||
import yaml
|
||||
from django.test import override_settings
|
||||
from django.test import override_settings, tag
|
||||
from django.urls import reverse
|
||||
from netaddr import EUI
|
||||
|
||||
@@ -1000,18 +1000,7 @@ inventory-items:
|
||||
self.assertEqual(response.get('Content-Type'), 'text/csv; charset=utf-8')
|
||||
|
||||
|
||||
# TODO: Change base class to PrimaryObjectViewTestCase
|
||||
# Blocked by absence of bulk import view for ModuleTypes
|
||||
class ModuleTypeTestCase(
|
||||
ViewTestCases.GetObjectViewTestCase,
|
||||
ViewTestCases.GetObjectChangelogViewTestCase,
|
||||
ViewTestCases.CreateObjectViewTestCase,
|
||||
ViewTestCases.EditObjectViewTestCase,
|
||||
ViewTestCases.DeleteObjectViewTestCase,
|
||||
ViewTestCases.ListObjectsViewTestCase,
|
||||
ViewTestCases.BulkEditObjectsViewTestCase,
|
||||
ViewTestCases.BulkDeleteObjectsViewTestCase
|
||||
):
|
||||
class ModuleTypeTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
model = ModuleType
|
||||
|
||||
@classmethod
|
||||
@@ -1023,7 +1012,7 @@ class ModuleTypeTestCase(
|
||||
)
|
||||
Manufacturer.objects.bulk_create(manufacturers)
|
||||
|
||||
ModuleType.objects.bulk_create([
|
||||
module_types = ModuleType.objects.bulk_create([
|
||||
ModuleType(model='Module Type 1', manufacturer=manufacturers[0]),
|
||||
ModuleType(model='Module Type 2', manufacturer=manufacturers[0]),
|
||||
ModuleType(model='Module Type 3', manufacturer=manufacturers[0]),
|
||||
@@ -1031,6 +1020,8 @@ class ModuleTypeTestCase(
|
||||
|
||||
tags = create_tags('Alpha', 'Bravo', 'Charlie')
|
||||
|
||||
fan_module_type_profile = ModuleTypeProfile.objects.get(name='Fan')
|
||||
|
||||
cls.form_data = {
|
||||
'manufacturer': manufacturers[1].pk,
|
||||
'model': 'Device Type X',
|
||||
@@ -1044,6 +1035,70 @@ class ModuleTypeTestCase(
|
||||
'part_number': '456DEF',
|
||||
}
|
||||
|
||||
cls.csv_data = (
|
||||
"manufacturer,model,part_number,comments,profile",
|
||||
f"Manufacturer 1,fan0,generic-fan,,{fan_module_type_profile.name}"
|
||||
)
|
||||
|
||||
cls.csv_update_data = (
|
||||
"id,model",
|
||||
f"{module_types[0].id},test model",
|
||||
)
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_bulk_update_objects_with_permission(self):
|
||||
self.add_permissions(
|
||||
'dcim.add_consoleporttemplate',
|
||||
'dcim.add_consoleserverporttemplate',
|
||||
'dcim.add_powerporttemplate',
|
||||
'dcim.add_poweroutlettemplate',
|
||||
'dcim.add_interfacetemplate',
|
||||
'dcim.add_frontporttemplate',
|
||||
'dcim.add_rearporttemplate',
|
||||
'dcim.add_modulebaytemplate',
|
||||
)
|
||||
|
||||
# run base test
|
||||
super().test_bulk_update_objects_with_permission()
|
||||
|
||||
@tag('regression')
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
|
||||
def test_bulk_import_objects_with_permission(self):
|
||||
self.add_permissions(
|
||||
'dcim.add_consoleporttemplate',
|
||||
'dcim.add_consoleserverporttemplate',
|
||||
'dcim.add_powerporttemplate',
|
||||
'dcim.add_poweroutlettemplate',
|
||||
'dcim.add_interfacetemplate',
|
||||
'dcim.add_frontporttemplate',
|
||||
'dcim.add_rearporttemplate',
|
||||
'dcim.add_modulebaytemplate',
|
||||
)
|
||||
|
||||
# run base test
|
||||
super().test_bulk_import_objects_with_permission()
|
||||
|
||||
# TODO: remove extra regression asserts once parent test supports testing all import fields
|
||||
fan_module_type = ModuleType.objects.get(part_number='generic-fan')
|
||||
fan_module_type_profile = ModuleTypeProfile.objects.get(name='Fan')
|
||||
|
||||
assert fan_module_type.profile == fan_module_type_profile
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
|
||||
def test_bulk_import_objects_with_constrained_permission(self):
|
||||
self.add_permissions(
|
||||
'dcim.add_consoleporttemplate',
|
||||
'dcim.add_consoleserverporttemplate',
|
||||
'dcim.add_powerporttemplate',
|
||||
'dcim.add_poweroutlettemplate',
|
||||
'dcim.add_interfacetemplate',
|
||||
'dcim.add_frontporttemplate',
|
||||
'dcim.add_rearporttemplate',
|
||||
'dcim.add_modulebaytemplate',
|
||||
)
|
||||
|
||||
super().test_bulk_import_objects_with_constrained_permission()
|
||||
|
||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||
def test_moduletype_consoleports(self):
|
||||
moduletype = ModuleType.objects.first()
|
||||
@@ -1804,9 +1859,9 @@ class DeviceRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
|
||||
|
||||
cls.csv_data = (
|
||||
"name,slug,color",
|
||||
"Device Role 4,device-role-4,ff0000",
|
||||
"Device Role 5,device-role-5,00ff00",
|
||||
"Device Role 6,device-role-6,0000ff",
|
||||
"Device Role 6,device-role-6,ff0000",
|
||||
"Device Role 7,device-role-7,00ff00",
|
||||
"Device Role 8,device-role-8,0000ff",
|
||||
)
|
||||
|
||||
cls.csv_update_data = (
|
||||
|
||||
@@ -15,7 +15,7 @@ from circuits.models import Circuit, CircuitTermination
|
||||
from extras.views import ObjectConfigContextView, ObjectRenderConfigView
|
||||
from ipam.models import ASN, IPAddress, Prefix, VLANGroup, VLAN
|
||||
from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable
|
||||
from netbox.constants import DEFAULT_ACTION_PERMISSIONS
|
||||
from netbox.object_actions import *
|
||||
from netbox.views import generic
|
||||
from utilities.forms import ConfirmationForm
|
||||
from utilities.paginator import EnhancedPaginator, get_paginate_count
|
||||
@@ -34,6 +34,7 @@ from wireless.models import WirelessLAN
|
||||
from . import filtersets, forms, tables
|
||||
from .choices import DeviceFaceChoices, InterfaceModeChoices
|
||||
from .models import *
|
||||
from .object_actions import BulkAddComponents, BulkDisconnect
|
||||
|
||||
CABLE_TERMINATION_TYPES = {
|
||||
'dcim.consoleport': ConsolePort,
|
||||
@@ -49,11 +50,6 @@ CABLE_TERMINATION_TYPES = {
|
||||
|
||||
|
||||
class DeviceComponentsView(generic.ObjectChildrenView):
|
||||
actions = {
|
||||
**DEFAULT_ACTION_PERMISSIONS,
|
||||
'bulk_rename': {'change'},
|
||||
'bulk_disconnect': {'change'},
|
||||
}
|
||||
queryset = Device.objects.all()
|
||||
|
||||
def get_children(self, request, parent):
|
||||
@@ -61,12 +57,8 @@ class DeviceComponentsView(generic.ObjectChildrenView):
|
||||
|
||||
|
||||
class DeviceTypeComponentsView(generic.ObjectChildrenView):
|
||||
actions = {
|
||||
**DEFAULT_ACTION_PERMISSIONS,
|
||||
'bulk_rename': {'change'},
|
||||
}
|
||||
actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDelete)
|
||||
queryset = DeviceType.objects.all()
|
||||
template_name = 'dcim/devicetype/component_templates.html'
|
||||
viewname = None # Used for return_url resolution
|
||||
|
||||
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()
|
||||
template_name = 'dcim/moduletype/component_templates.html'
|
||||
actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDelete)
|
||||
viewname = None # Used for return_url resolution
|
||||
|
||||
def get_children(self, request, parent):
|
||||
@@ -300,6 +292,11 @@ class RegionBulkEditView(generic.BulkEditView):
|
||||
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)
|
||||
class RegionBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = Region.objects.add_related_count(
|
||||
@@ -426,6 +423,11 @@ class SiteGroupBulkEditView(generic.BulkEditView):
|
||||
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)
|
||||
class SiteGroupBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = SiteGroup.objects.add_related_count(
|
||||
@@ -511,6 +513,11 @@ class SiteBulkEditView(generic.BulkEditView):
|
||||
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)
|
||||
class SiteBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = Site.objects.all()
|
||||
@@ -615,6 +622,11 @@ class LocationBulkEditView(generic.BulkEditView):
|
||||
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)
|
||||
class LocationBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = Location.objects.add_related_count(
|
||||
@@ -680,6 +692,11 @@ class RackRoleBulkEditView(generic.BulkEditView):
|
||||
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)
|
||||
class RackRoleBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = RackRole.objects.annotate(
|
||||
@@ -739,6 +756,12 @@ class RackTypeBulkEditView(generic.BulkEditView):
|
||||
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)
|
||||
class RackTypeBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = RackType.objects.all()
|
||||
@@ -918,6 +941,11 @@ class RackBulkEditView(generic.BulkEditView):
|
||||
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)
|
||||
class RackBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = Rack.objects.all()
|
||||
@@ -935,6 +963,7 @@ class RackReservationListView(generic.ObjectListView):
|
||||
filterset = filtersets.RackReservationFilterSet
|
||||
filterset_form = forms.RackReservationFilterForm
|
||||
table = tables.RackReservationTable
|
||||
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
|
||||
|
||||
|
||||
@register_model_view(RackReservation)
|
||||
@@ -1051,6 +1080,11 @@ class ManufacturerBulkEditView(generic.BulkEditView):
|
||||
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)
|
||||
class ManufacturerBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = Manufacturer.objects.annotate(
|
||||
@@ -1298,6 +1332,12 @@ class DeviceTypeBulkEditView(generic.BulkEditView):
|
||||
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)
|
||||
class DeviceTypeBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = DeviceType.objects.annotate(
|
||||
@@ -1354,6 +1394,11 @@ class ModuleTypeProfileBulkEditView(generic.BulkEditView):
|
||||
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)
|
||||
class ModuleTypeProfileBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = ModuleTypeProfile.objects.annotate(
|
||||
@@ -1564,6 +1609,11 @@ class ModuleTypeBulkEditView(generic.BulkEditView):
|
||||
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)
|
||||
class ModuleTypeBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = ModuleType.objects.annotate(
|
||||
@@ -2038,6 +2088,11 @@ class DeviceRoleBulkEditView(generic.BulkEditView):
|
||||
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)
|
||||
class DeviceRoleBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = DeviceRole.objects.annotate(
|
||||
@@ -2099,6 +2154,11 @@ class PlatformBulkEditView(generic.BulkEditView):
|
||||
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)
|
||||
class PlatformBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = Platform.objects.all()
|
||||
@@ -2116,7 +2176,7 @@ class DeviceListView(generic.ObjectListView):
|
||||
filterset = filtersets.DeviceFilterSet
|
||||
filterset_form = forms.DeviceFilterForm
|
||||
table = tables.DeviceTable
|
||||
template_name = 'dcim/device_list.html'
|
||||
actions = (AddObject, BulkImport, BulkExport, BulkAddComponents, BulkEdit, BulkRename, BulkDelete)
|
||||
|
||||
|
||||
@register_model_view(Device)
|
||||
@@ -2157,7 +2217,7 @@ class DeviceConsolePortsView(DeviceComponentsView):
|
||||
table = tables.DeviceConsolePortTable
|
||||
filterset = filtersets.ConsolePortFilterSet
|
||||
filterset_form = forms.ConsolePortFilterForm
|
||||
template_name = 'dcim/device/consoleports.html',
|
||||
actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete)
|
||||
tab = ViewTab(
|
||||
label=_('Console Ports'),
|
||||
badge=lambda obj: obj.console_port_count,
|
||||
@@ -2173,7 +2233,7 @@ class DeviceConsoleServerPortsView(DeviceComponentsView):
|
||||
table = tables.DeviceConsoleServerPortTable
|
||||
filterset = filtersets.ConsoleServerPortFilterSet
|
||||
filterset_form = forms.ConsoleServerPortFilterForm
|
||||
template_name = 'dcim/device/consoleserverports.html'
|
||||
actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete)
|
||||
tab = ViewTab(
|
||||
label=_('Console Server Ports'),
|
||||
badge=lambda obj: obj.console_server_port_count,
|
||||
@@ -2189,7 +2249,7 @@ class DevicePowerPortsView(DeviceComponentsView):
|
||||
table = tables.DevicePowerPortTable
|
||||
filterset = filtersets.PowerPortFilterSet
|
||||
filterset_form = forms.PowerPortFilterForm
|
||||
template_name = 'dcim/device/powerports.html'
|
||||
actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete)
|
||||
tab = ViewTab(
|
||||
label=_('Power Ports'),
|
||||
badge=lambda obj: obj.power_port_count,
|
||||
@@ -2205,7 +2265,7 @@ class DevicePowerOutletsView(DeviceComponentsView):
|
||||
table = tables.DevicePowerOutletTable
|
||||
filterset = filtersets.PowerOutletFilterSet
|
||||
filterset_form = forms.PowerOutletFilterForm
|
||||
template_name = 'dcim/device/poweroutlets.html'
|
||||
actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete)
|
||||
tab = ViewTab(
|
||||
label=_('Power Outlets'),
|
||||
badge=lambda obj: obj.power_outlet_count,
|
||||
@@ -2221,6 +2281,7 @@ class DeviceInterfacesView(DeviceComponentsView):
|
||||
table = tables.DeviceInterfaceTable
|
||||
filterset = filtersets.InterfaceFilterSet
|
||||
filterset_form = forms.InterfaceFilterForm
|
||||
actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete)
|
||||
template_name = 'dcim/device/interfaces.html'
|
||||
tab = ViewTab(
|
||||
label=_('Interfaces'),
|
||||
@@ -2243,7 +2304,7 @@ class DeviceFrontPortsView(DeviceComponentsView):
|
||||
table = tables.DeviceFrontPortTable
|
||||
filterset = filtersets.FrontPortFilterSet
|
||||
filterset_form = forms.FrontPortFilterForm
|
||||
template_name = 'dcim/device/frontports.html'
|
||||
actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete)
|
||||
tab = ViewTab(
|
||||
label=_('Front Ports'),
|
||||
badge=lambda obj: obj.front_port_count,
|
||||
@@ -2259,7 +2320,7 @@ class DeviceRearPortsView(DeviceComponentsView):
|
||||
table = tables.DeviceRearPortTable
|
||||
filterset = filtersets.RearPortFilterSet
|
||||
filterset_form = forms.RearPortFilterForm
|
||||
template_name = 'dcim/device/rearports.html'
|
||||
actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDisconnect, BulkDelete)
|
||||
tab = ViewTab(
|
||||
label=_('Rear Ports'),
|
||||
badge=lambda obj: obj.rear_port_count,
|
||||
@@ -2275,11 +2336,7 @@ class DeviceModuleBaysView(DeviceComponentsView):
|
||||
table = tables.DeviceModuleBayTable
|
||||
filterset = filtersets.ModuleBayFilterSet
|
||||
filterset_form = forms.ModuleBayFilterForm
|
||||
template_name = 'dcim/device/modulebays.html'
|
||||
actions = {
|
||||
**DEFAULT_ACTION_PERMISSIONS,
|
||||
'bulk_rename': {'change'},
|
||||
}
|
||||
actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDelete)
|
||||
tab = ViewTab(
|
||||
label=_('Module Bays'),
|
||||
badge=lambda obj: obj.module_bay_count,
|
||||
@@ -2295,11 +2352,7 @@ class DeviceDeviceBaysView(DeviceComponentsView):
|
||||
table = tables.DeviceDeviceBayTable
|
||||
filterset = filtersets.DeviceBayFilterSet
|
||||
filterset_form = forms.DeviceBayFilterForm
|
||||
template_name = 'dcim/device/devicebays.html'
|
||||
actions = {
|
||||
**DEFAULT_ACTION_PERMISSIONS,
|
||||
'bulk_rename': {'change'},
|
||||
}
|
||||
actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDelete)
|
||||
tab = ViewTab(
|
||||
label=_('Device Bays'),
|
||||
badge=lambda obj: obj.device_bay_count,
|
||||
@@ -2315,11 +2368,7 @@ class DeviceInventoryView(DeviceComponentsView):
|
||||
table = tables.DeviceInventoryItemTable
|
||||
filterset = filtersets.InventoryItemFilterSet
|
||||
filterset_form = forms.InventoryItemFilterForm
|
||||
template_name = 'dcim/device/inventory.html'
|
||||
actions = {
|
||||
**DEFAULT_ACTION_PERMISSIONS,
|
||||
'bulk_rename': {'change'},
|
||||
}
|
||||
actions = (EditObject, DeleteObject, BulkEdit, BulkRename, BulkDelete)
|
||||
tab = ViewTab(
|
||||
label=_('Inventory Items'),
|
||||
badge=lambda obj: obj.inventory_item_count,
|
||||
@@ -2393,16 +2442,16 @@ class DeviceBulkEditView(generic.BulkEditView):
|
||||
form = forms.DeviceBulkEditForm
|
||||
|
||||
|
||||
@register_model_view(Device, 'bulk_delete', path='delete', detail=False)
|
||||
class DeviceBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = Device.objects.prefetch_related('device_type__manufacturer')
|
||||
@register_model_view(Device, 'bulk_rename', path='rename', detail=False)
|
||||
class DeviceBulkRenameView(generic.BulkRenameView):
|
||||
queryset = Device.objects.all()
|
||||
filterset = filtersets.DeviceFilterSet
|
||||
table = tables.DeviceTable
|
||||
|
||||
|
||||
@register_model_view(Device, 'bulk_rename', path='rename', detail=False)
|
||||
class DeviceBulkRenameView(generic.BulkRenameView):
|
||||
queryset = Device.objects.all()
|
||||
@register_model_view(Device, 'bulk_delete', path='delete', detail=False)
|
||||
class DeviceBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = Device.objects.prefetch_related('device_type__manufacturer')
|
||||
filterset = filtersets.DeviceFilterSet
|
||||
table = tables.DeviceTable
|
||||
|
||||
@@ -2417,6 +2466,7 @@ class ModuleListView(generic.ObjectListView):
|
||||
filterset = filtersets.ModuleFilterSet
|
||||
filterset_form = forms.ModuleFilterForm
|
||||
table = tables.ModuleTable
|
||||
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
|
||||
|
||||
|
||||
@register_model_view(Module)
|
||||
@@ -2472,11 +2522,6 @@ class ConsolePortListView(generic.ObjectListView):
|
||||
filterset = filtersets.ConsolePortFilterSet
|
||||
filterset_form = forms.ConsolePortFilterForm
|
||||
table = tables.ConsolePortTable
|
||||
template_name = 'dcim/component_list.html'
|
||||
actions = {
|
||||
**DEFAULT_ACTION_PERMISSIONS,
|
||||
'bulk_rename': {'change'},
|
||||
}
|
||||
|
||||
|
||||
@register_model_view(ConsolePort)
|
||||
@@ -2547,11 +2592,6 @@ class ConsoleServerPortListView(generic.ObjectListView):
|
||||
filterset = filtersets.ConsoleServerPortFilterSet
|
||||
filterset_form = forms.ConsoleServerPortFilterForm
|
||||
table = tables.ConsoleServerPortTable
|
||||
template_name = 'dcim/component_list.html'
|
||||
actions = {
|
||||
**DEFAULT_ACTION_PERMISSIONS,
|
||||
'bulk_rename': {'change'},
|
||||
}
|
||||
|
||||
|
||||
@register_model_view(ConsoleServerPort)
|
||||
@@ -2622,11 +2662,6 @@ class PowerPortListView(generic.ObjectListView):
|
||||
filterset = filtersets.PowerPortFilterSet
|
||||
filterset_form = forms.PowerPortFilterForm
|
||||
table = tables.PowerPortTable
|
||||
template_name = 'dcim/component_list.html'
|
||||
actions = {
|
||||
**DEFAULT_ACTION_PERMISSIONS,
|
||||
'bulk_rename': {'change'},
|
||||
}
|
||||
|
||||
|
||||
@register_model_view(PowerPort)
|
||||
@@ -2697,11 +2732,6 @@ class PowerOutletListView(generic.ObjectListView):
|
||||
filterset = filtersets.PowerOutletFilterSet
|
||||
filterset_form = forms.PowerOutletFilterForm
|
||||
table = tables.PowerOutletTable
|
||||
template_name = 'dcim/component_list.html'
|
||||
actions = {
|
||||
**DEFAULT_ACTION_PERMISSIONS,
|
||||
'bulk_rename': {'change'},
|
||||
}
|
||||
|
||||
|
||||
@register_model_view(PowerOutlet)
|
||||
@@ -2772,11 +2802,6 @@ class InterfaceListView(generic.ObjectListView):
|
||||
filterset = filtersets.InterfaceFilterSet
|
||||
filterset_form = forms.InterfaceFilterForm
|
||||
table = tables.InterfaceTable
|
||||
template_name = 'dcim/component_list.html'
|
||||
actions = {
|
||||
**DEFAULT_ACTION_PERMISSIONS,
|
||||
'bulk_rename': {'change'},
|
||||
}
|
||||
|
||||
|
||||
@register_model_view(Interface)
|
||||
@@ -2920,11 +2945,6 @@ class FrontPortListView(generic.ObjectListView):
|
||||
filterset = filtersets.FrontPortFilterSet
|
||||
filterset_form = forms.FrontPortFilterForm
|
||||
table = tables.FrontPortTable
|
||||
template_name = 'dcim/component_list.html'
|
||||
actions = {
|
||||
**DEFAULT_ACTION_PERMISSIONS,
|
||||
'bulk_rename': {'change'},
|
||||
}
|
||||
|
||||
|
||||
@register_model_view(FrontPort)
|
||||
@@ -2995,11 +3015,6 @@ class RearPortListView(generic.ObjectListView):
|
||||
filterset = filtersets.RearPortFilterSet
|
||||
filterset_form = forms.RearPortFilterForm
|
||||
table = tables.RearPortTable
|
||||
template_name = 'dcim/component_list.html'
|
||||
actions = {
|
||||
**DEFAULT_ACTION_PERMISSIONS,
|
||||
'bulk_rename': {'change'},
|
||||
}
|
||||
|
||||
|
||||
@register_model_view(RearPort)
|
||||
@@ -3070,11 +3085,6 @@ class ModuleBayListView(generic.ObjectListView):
|
||||
filterset = filtersets.ModuleBayFilterSet
|
||||
filterset_form = forms.ModuleBayFilterForm
|
||||
table = tables.ModuleBayTable
|
||||
template_name = 'dcim/component_list.html'
|
||||
actions = {
|
||||
**DEFAULT_ACTION_PERMISSIONS,
|
||||
'bulk_rename': {'change'},
|
||||
}
|
||||
|
||||
|
||||
@register_model_view(ModuleBay)
|
||||
@@ -3136,11 +3146,6 @@ class DeviceBayListView(generic.ObjectListView):
|
||||
filterset = filtersets.DeviceBayFilterSet
|
||||
filterset_form = forms.DeviceBayFilterForm
|
||||
table = tables.DeviceBayTable
|
||||
template_name = 'dcim/component_list.html'
|
||||
actions = {
|
||||
**DEFAULT_ACTION_PERMISSIONS,
|
||||
'bulk_rename': {'change'},
|
||||
}
|
||||
|
||||
|
||||
@register_model_view(DeviceBay)
|
||||
@@ -3283,11 +3288,6 @@ class InventoryItemListView(generic.ObjectListView):
|
||||
filterset = filtersets.InventoryItemFilterSet
|
||||
filterset_form = forms.InventoryItemFilterForm
|
||||
table = tables.InventoryItemTable
|
||||
template_name = 'dcim/component_list.html'
|
||||
actions = {
|
||||
**DEFAULT_ACTION_PERMISSIONS,
|
||||
'bulk_rename': {'change'},
|
||||
}
|
||||
|
||||
|
||||
@register_model_view(InventoryItem)
|
||||
@@ -3410,6 +3410,11 @@ class InventoryItemRoleBulkEditView(generic.BulkEditView):
|
||||
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)
|
||||
class InventoryItemRoleBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = InventoryItemRole.objects.annotate(
|
||||
@@ -3607,6 +3612,12 @@ class CableBulkEditView(generic.BulkEditView):
|
||||
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)
|
||||
class CableBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = Cable.objects.prefetch_related(
|
||||
@@ -3627,9 +3638,7 @@ class ConsoleConnectionsListView(generic.ObjectListView):
|
||||
filterset_form = forms.ConsoleConnectionFilterForm
|
||||
table = tables.ConsoleConnectionTable
|
||||
template_name = 'dcim/connections_list.html'
|
||||
actions = {
|
||||
'export': {'view'},
|
||||
}
|
||||
actions = (BulkExport,)
|
||||
|
||||
def get_extra_context(self, request):
|
||||
return {
|
||||
@@ -3643,9 +3652,7 @@ class PowerConnectionsListView(generic.ObjectListView):
|
||||
filterset_form = forms.PowerConnectionFilterForm
|
||||
table = tables.PowerConnectionTable
|
||||
template_name = 'dcim/connections_list.html'
|
||||
actions = {
|
||||
'export': {'view'},
|
||||
}
|
||||
actions = (BulkExport,)
|
||||
|
||||
def get_extra_context(self, request):
|
||||
return {
|
||||
@@ -3659,9 +3666,7 @@ class InterfaceConnectionsListView(generic.ObjectListView):
|
||||
filterset_form = forms.InterfaceConnectionFilterForm
|
||||
table = tables.InterfaceConnectionTable
|
||||
template_name = 'dcim/connections_list.html'
|
||||
actions = {
|
||||
'export': {'view'},
|
||||
}
|
||||
actions = (BulkExport,)
|
||||
|
||||
def get_extra_context(self, request):
|
||||
return {
|
||||
@@ -3905,6 +3910,11 @@ class VirtualChassisBulkEditView(generic.BulkEditView):
|
||||
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)
|
||||
class VirtualChassisBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = VirtualChassis.objects.all()
|
||||
@@ -3962,6 +3972,11 @@ class PowerPanelBulkEditView(generic.BulkEditView):
|
||||
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)
|
||||
class PowerPanelBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = PowerPanel.objects.annotate(
|
||||
@@ -4014,6 +4029,11 @@ class PowerFeedBulkEditView(generic.BulkEditView):
|
||||
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)
|
||||
class PowerFeedBulkDisconnectView(BulkDisconnectView):
|
||||
queryset = PowerFeed.objects.all()
|
||||
@@ -4042,6 +4062,7 @@ class VirtualDeviceContextListView(generic.ObjectListView):
|
||||
filterset = filtersets.VirtualDeviceContextFilterSet
|
||||
filterset_form = forms.VirtualDeviceContextFilterForm
|
||||
table = tables.VirtualDeviceContextTable
|
||||
actions = (AddObject, BulkImport, BulkEdit, BulkRename, BulkExport, BulkDelete)
|
||||
|
||||
|
||||
@register_model_view(VirtualDeviceContext)
|
||||
@@ -4086,6 +4107,11 @@ class VirtualDeviceContextBulkEditView(generic.BulkEditView):
|
||||
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)
|
||||
class VirtualDeviceContextBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = VirtualDeviceContext.objects.all()
|
||||
@@ -4103,6 +4129,7 @@ class MACAddressListView(generic.ObjectListView):
|
||||
filterset = filtersets.MACAddressFilterSet
|
||||
filterset_form = forms.MACAddressFilterForm
|
||||
table = tables.MACAddressTable
|
||||
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
|
||||
|
||||
|
||||
@register_model_view(MACAddress)
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
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',
|
||||
@@ -10,7 +16,32 @@ __all__ = (
|
||||
|
||||
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', 'model']
|
||||
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)
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
import functools
|
||||
import operator
|
||||
import re
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
__all__ = (
|
||||
'Condition',
|
||||
'ConditionSet',
|
||||
'InvalidCondition',
|
||||
)
|
||||
|
||||
|
||||
AND = 'and'
|
||||
OR = 'or'
|
||||
|
||||
@@ -19,6 +20,10 @@ def is_ruleset(data):
|
||||
return type(data) is dict and len(data) == 1 and list(data.keys())[0] in (AND, OR)
|
||||
|
||||
|
||||
class InvalidCondition(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class Condition:
|
||||
"""
|
||||
An individual conditional rule that evaluates a single attribute and its value.
|
||||
@@ -61,6 +66,7 @@ class Condition:
|
||||
|
||||
self.attr = attr
|
||||
self.value = value
|
||||
self.op = op
|
||||
self.eval_func = getattr(self, f'eval_{op}')
|
||||
self.negate = negate
|
||||
|
||||
@@ -70,16 +76,17 @@ class Condition:
|
||||
"""
|
||||
def _get(obj, key):
|
||||
if isinstance(obj, list):
|
||||
return [dict.get(i, key) for i in obj]
|
||||
|
||||
return dict.get(obj, key)
|
||||
return [operator.getitem(item or {}, key) for item in obj]
|
||||
return operator.getitem(obj or {}, key)
|
||||
|
||||
try:
|
||||
value = functools.reduce(_get, self.attr.split('.'), data)
|
||||
except TypeError:
|
||||
# Invalid key path
|
||||
value = None
|
||||
result = self.eval_func(value)
|
||||
except KeyError:
|
||||
raise InvalidCondition(f"Invalid key path: {self.attr}")
|
||||
try:
|
||||
result = self.eval_func(value)
|
||||
except TypeError as e:
|
||||
raise InvalidCondition(f"Invalid data type at '{self.attr}' for '{self.op}' evaluation: {e}")
|
||||
|
||||
if self.negate:
|
||||
return not result
|
||||
|
||||
@@ -192,5 +192,5 @@ def flush_events(events):
|
||||
try:
|
||||
func = import_string(name)
|
||||
func(events)
|
||||
except Exception as e:
|
||||
except ImportError as e:
|
||||
logger.error(_("Cannot import events pipeline {name} error: {error}").format(name=name, error=e))
|
||||
|
||||
@@ -90,7 +90,10 @@ class ScriptJob(JobRunner):
|
||||
request: The WSGI request associated with this execution (if any)
|
||||
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
|
||||
if request:
|
||||
@@ -100,6 +103,7 @@ class ScriptJob(JobRunner):
|
||||
|
||||
# Add the current request as a property of the script
|
||||
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
|
||||
# change logging, event rules, etc.
|
||||
|
||||
@@ -18,9 +18,22 @@ class Empty(Lookup):
|
||||
return f"CAST(LENGTH({sql}) AS BOOLEAN) IS TRUE", params
|
||||
|
||||
|
||||
class NetHost(Lookup):
|
||||
"""
|
||||
Similar to ipam.lookups.NetHost, but casts the field to INET.
|
||||
"""
|
||||
lookup_name = 'net_host'
|
||||
|
||||
def as_sql(self, qn, connection):
|
||||
lhs, lhs_params = self.process_lhs(qn, connection)
|
||||
rhs, rhs_params = self.process_rhs(qn, connection)
|
||||
params = lhs_params + rhs_params
|
||||
return 'HOST(CAST(%s AS INET)) = HOST(%s)' % (lhs, rhs), params
|
||||
|
||||
|
||||
class NetContainsOrEquals(Lookup):
|
||||
"""
|
||||
This lookup has the same functionality as the one from the ipam app except lhs is cast to inet
|
||||
Similar to ipam.lookups.NetContainsOrEquals, but casts the field to INET.
|
||||
"""
|
||||
lookup_name = 'net_contains_or_equals'
|
||||
|
||||
@@ -32,4 +45,5 @@ class NetContainsOrEquals(Lookup):
|
||||
|
||||
|
||||
CharField.register_lookup(Empty)
|
||||
CachedValueField.register_lookup(NetHost)
|
||||
CachedValueField.register_lookup(NetContainsOrEquals)
|
||||
|
||||
@@ -13,7 +13,7 @@ from rest_framework.utils.encoders import JSONEncoder
|
||||
|
||||
from core.models import ObjectType
|
||||
from extras.choices import *
|
||||
from extras.conditions import ConditionSet
|
||||
from extras.conditions import ConditionSet, InvalidCondition
|
||||
from extras.constants import *
|
||||
from extras.utils import image_upload
|
||||
from extras.models.mixins import RenderTemplateMixin
|
||||
@@ -142,7 +142,15 @@ class EventRule(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLogged
|
||||
if not self.conditions:
|
||||
return True
|
||||
|
||||
return ConditionSet(self.conditions).eval(data)
|
||||
logger = logging.getLogger('netbox.event_rules')
|
||||
|
||||
try:
|
||||
result = ConditionSet(self.conditions).eval(data)
|
||||
logger.debug(f'{self.name}: Evaluated as {result}')
|
||||
return result
|
||||
except InvalidCondition as e:
|
||||
logger.error(f"{self.name}: Evaluation failed. {e}")
|
||||
return False
|
||||
|
||||
|
||||
class Webhook(CustomFieldsMixin, ExportTemplatesMixin, TagsMixin, ChangeLoggedModel):
|
||||
|
||||
@@ -4,7 +4,7 @@ from django.test import TestCase
|
||||
from core.events import *
|
||||
from dcim.choices import SiteStatusChoices
|
||||
from dcim.models import Site
|
||||
from extras.conditions import Condition, ConditionSet
|
||||
from extras.conditions import Condition, ConditionSet, InvalidCondition
|
||||
from extras.events import serialize_for_event
|
||||
from extras.forms import EventRuleForm
|
||||
from extras.models import EventRule, Webhook
|
||||
@@ -12,16 +12,11 @@ from extras.models import EventRule, Webhook
|
||||
|
||||
class ConditionTestCase(TestCase):
|
||||
|
||||
def test_dotted_path_access(self):
|
||||
c = Condition('a.b.c', 1, 'eq')
|
||||
self.assertTrue(c.eval({'a': {'b': {'c': 1}}}))
|
||||
self.assertFalse(c.eval({'a': {'b': {'c': 2}}}))
|
||||
self.assertFalse(c.eval({'a': {'b': {'x': 1}}}))
|
||||
|
||||
def test_undefined_attr(self):
|
||||
c = Condition('x', 1, 'eq')
|
||||
self.assertFalse(c.eval({}))
|
||||
self.assertTrue(c.eval({'x': 1}))
|
||||
with self.assertRaises(InvalidCondition):
|
||||
c.eval({})
|
||||
|
||||
#
|
||||
# Validation tests
|
||||
@@ -37,10 +32,13 @@ class ConditionTestCase(TestCase):
|
||||
# dict type is unsupported
|
||||
Condition('x', 1, dict())
|
||||
|
||||
def test_invalid_op_type(self):
|
||||
def test_invalid_op_types(self):
|
||||
with self.assertRaises(ValueError):
|
||||
# 'gt' supports only numeric values
|
||||
Condition('x', 'foo', 'gt')
|
||||
with self.assertRaises(ValueError):
|
||||
# 'in' supports only iterable values
|
||||
Condition('x', 123, 'in')
|
||||
|
||||
#
|
||||
# Nested attrs tests
|
||||
@@ -50,7 +48,10 @@ class ConditionTestCase(TestCase):
|
||||
c = Condition('x.y.z', 1)
|
||||
self.assertTrue(c.eval({'x': {'y': {'z': 1}}}))
|
||||
self.assertFalse(c.eval({'x': {'y': {'z': 2}}}))
|
||||
self.assertFalse(c.eval({'a': {'b': {'c': 1}}}))
|
||||
with self.assertRaises(InvalidCondition):
|
||||
c.eval({'x': {'y': None}})
|
||||
with self.assertRaises(InvalidCondition):
|
||||
c.eval({'x': {'y': {'a': 1}}})
|
||||
|
||||
#
|
||||
# Operator tests
|
||||
@@ -74,23 +75,31 @@ class ConditionTestCase(TestCase):
|
||||
c = Condition('x', 1, 'gt')
|
||||
self.assertTrue(c.eval({'x': 2}))
|
||||
self.assertFalse(c.eval({'x': 1}))
|
||||
with self.assertRaises(InvalidCondition):
|
||||
c.eval({'x': 'foo'}) # Invalid type
|
||||
|
||||
def test_gte(self):
|
||||
c = Condition('x', 1, 'gte')
|
||||
self.assertTrue(c.eval({'x': 2}))
|
||||
self.assertTrue(c.eval({'x': 1}))
|
||||
self.assertFalse(c.eval({'x': 0}))
|
||||
with self.assertRaises(InvalidCondition):
|
||||
c.eval({'x': 'foo'}) # Invalid type
|
||||
|
||||
def test_lt(self):
|
||||
c = Condition('x', 2, 'lt')
|
||||
self.assertTrue(c.eval({'x': 1}))
|
||||
self.assertFalse(c.eval({'x': 2}))
|
||||
with self.assertRaises(InvalidCondition):
|
||||
c.eval({'x': 'foo'}) # Invalid type
|
||||
|
||||
def test_lte(self):
|
||||
c = Condition('x', 2, 'lte')
|
||||
self.assertTrue(c.eval({'x': 1}))
|
||||
self.assertTrue(c.eval({'x': 2}))
|
||||
self.assertFalse(c.eval({'x': 3}))
|
||||
with self.assertRaises(InvalidCondition):
|
||||
c.eval({'x': 'foo'}) # Invalid type
|
||||
|
||||
def test_in(self):
|
||||
c = Condition('x', [1, 2, 3], 'in')
|
||||
@@ -106,6 +115,8 @@ class ConditionTestCase(TestCase):
|
||||
c = Condition('x', 1, 'contains')
|
||||
self.assertTrue(c.eval({'x': [1, 2, 3]}))
|
||||
self.assertFalse(c.eval({'x': [2, 3, 4]}))
|
||||
with self.assertRaises(InvalidCondition):
|
||||
c.eval({'x': 123}) # Invalid type
|
||||
|
||||
def test_contains_negated(self):
|
||||
c = Condition('x', 1, 'contains', negate=True)
|
||||
|
||||
@@ -14,12 +14,13 @@ from jinja2.exceptions import TemplateError
|
||||
|
||||
from core.choices import ManagedFileRootPathChoices
|
||||
from core.models import Job
|
||||
from core.object_actions import BulkSync
|
||||
from dcim.models import Device, DeviceRole, Platform
|
||||
from extras.choices import LogLevelChoices
|
||||
from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm
|
||||
from extras.dashboard.utils import get_widget_class
|
||||
from extras.utils import SharedObjectViewMixin
|
||||
from netbox.constants import DEFAULT_ACTION_PERMISSIONS
|
||||
from netbox.object_actions import *
|
||||
from netbox.views import generic
|
||||
from netbox.views.generic.mixins import TableMixin
|
||||
from utilities.forms import ConfirmationForm, get_field_value
|
||||
@@ -96,6 +97,11 @@ class CustomFieldBulkEditView(generic.BulkEditView):
|
||||
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)
|
||||
class CustomFieldBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = CustomField.objects.select_related('choice_set')
|
||||
@@ -165,6 +171,11 @@ class CustomFieldChoiceSetBulkEditView(generic.BulkEditView):
|
||||
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)
|
||||
class CustomFieldChoiceSetBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = CustomFieldChoiceSet.objects.all()
|
||||
@@ -215,6 +226,11 @@ class CustomLinkBulkEditView(generic.BulkEditView):
|
||||
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)
|
||||
class CustomLinkBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = CustomLink.objects.all()
|
||||
@@ -232,11 +248,7 @@ class ExportTemplateListView(generic.ObjectListView):
|
||||
filterset = filtersets.ExportTemplateFilterSet
|
||||
filterset_form = forms.ExportTemplateFilterForm
|
||||
table = tables.ExportTemplateTable
|
||||
template_name = 'extras/exporttemplate_list.html'
|
||||
actions = {
|
||||
**DEFAULT_ACTION_PERMISSIONS,
|
||||
'bulk_sync': {'sync'},
|
||||
}
|
||||
actions = (AddObject, BulkImport, BulkSync, BulkExport, BulkEdit, BulkRename, BulkDelete)
|
||||
|
||||
|
||||
@register_model_view(ExportTemplate)
|
||||
@@ -270,6 +282,11 @@ class ExportTemplateBulkEditView(generic.BulkEditView):
|
||||
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)
|
||||
class ExportTemplateBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = ExportTemplate.objects.all()
|
||||
@@ -330,6 +347,11 @@ class SavedFilterBulkEditView(SharedObjectViewMixin, generic.BulkEditView):
|
||||
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)
|
||||
class SavedFilterBulkDeleteView(SharedObjectViewMixin, generic.BulkDeleteView):
|
||||
queryset = SavedFilter.objects.all()
|
||||
@@ -347,9 +369,7 @@ class TableConfigListView(SharedObjectViewMixin, generic.ObjectListView):
|
||||
filterset = filtersets.TableConfigFilterSet
|
||||
filterset_form = forms.TableConfigFilterForm
|
||||
table = tables.TableConfigTable
|
||||
actions = {
|
||||
'export': {'view'},
|
||||
}
|
||||
actions = (BulkExport, BulkEdit, BulkRename, BulkDelete)
|
||||
|
||||
|
||||
@register_model_view(TableConfig)
|
||||
@@ -389,6 +409,11 @@ class TableConfigBulkEditView(SharedObjectViewMixin, generic.BulkEditView):
|
||||
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)
|
||||
class TableConfigBulkDeleteView(SharedObjectViewMixin, generic.BulkDeleteView):
|
||||
queryset = TableConfig.objects.all()
|
||||
@@ -470,6 +495,11 @@ class NotificationGroupBulkEditView(generic.BulkEditView):
|
||||
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)
|
||||
class NotificationGroupBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = NotificationGroup.objects.all()
|
||||
@@ -616,6 +646,11 @@ class WebhookBulkEditView(generic.BulkEditView):
|
||||
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)
|
||||
class WebhookBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = Webhook.objects.all()
|
||||
@@ -666,6 +701,11 @@ class EventRuleBulkEditView(generic.BulkEditView):
|
||||
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)
|
||||
class EventRuleBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = EventRule.objects.all()
|
||||
@@ -740,6 +780,11 @@ class TagBulkEditView(generic.BulkEditView):
|
||||
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)
|
||||
class TagBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = Tag.objects.annotate(
|
||||
@@ -758,13 +803,7 @@ class ConfigContextListView(generic.ObjectListView):
|
||||
filterset = filtersets.ConfigContextFilterSet
|
||||
filterset_form = forms.ConfigContextFilterForm
|
||||
table = tables.ConfigContextTable
|
||||
template_name = 'extras/configcontext_list.html'
|
||||
actions = {
|
||||
'add': {'add'},
|
||||
'bulk_edit': {'change'},
|
||||
'bulk_delete': {'delete'},
|
||||
'bulk_sync': {'sync'},
|
||||
}
|
||||
actions = (AddObject, BulkSync, BulkEdit, BulkRename, BulkDelete)
|
||||
|
||||
|
||||
@register_model_view(ConfigContext)
|
||||
@@ -825,6 +864,11 @@ class ConfigContextBulkEditView(generic.BulkEditView):
|
||||
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)
|
||||
class ConfigContextBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = ConfigContext.objects.all()
|
||||
@@ -877,11 +921,7 @@ class ConfigTemplateListView(generic.ObjectListView):
|
||||
filterset = filtersets.ConfigTemplateFilterSet
|
||||
filterset_form = forms.ConfigTemplateFilterForm
|
||||
table = tables.ConfigTemplateTable
|
||||
template_name = 'extras/configtemplate_list.html'
|
||||
actions = {
|
||||
**DEFAULT_ACTION_PERMISSIONS,
|
||||
'bulk_sync': {'sync'},
|
||||
}
|
||||
actions = (AddObject, BulkImport, BulkExport, BulkSync, BulkEdit, BulkRename, BulkDelete)
|
||||
|
||||
|
||||
@register_model_view(ConfigTemplate)
|
||||
@@ -915,6 +955,11 @@ class ConfigTemplateBulkEditView(generic.BulkEditView):
|
||||
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)
|
||||
class ConfigTemplateBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = ConfigTemplate.objects.all()
|
||||
@@ -992,9 +1037,7 @@ class ImageAttachmentListView(generic.ObjectListView):
|
||||
filterset = filtersets.ImageAttachmentFilterSet
|
||||
filterset_form = forms.ImageAttachmentFilterForm
|
||||
table = tables.ImageAttachmentTable
|
||||
actions = {
|
||||
'export': {'view'},
|
||||
}
|
||||
actions = (BulkExport,)
|
||||
|
||||
|
||||
@register_model_view(ImageAttachment, 'add', detail=False)
|
||||
@@ -1038,12 +1081,7 @@ class JournalEntryListView(generic.ObjectListView):
|
||||
filterset = filtersets.JournalEntryFilterSet
|
||||
filterset_form = forms.JournalEntryFilterForm
|
||||
table = tables.JournalEntryTable
|
||||
actions = {
|
||||
'export': {'view'},
|
||||
'bulk_import': {'add'},
|
||||
'bulk_edit': {'change'},
|
||||
'bulk_delete': {'delete'},
|
||||
}
|
||||
actions = (BulkImport, BulkEdit, BulkDelete)
|
||||
|
||||
|
||||
@register_model_view(JournalEntry)
|
||||
|
||||
@@ -162,6 +162,11 @@ class Aggregate(ContactsMixin, GetAvailablePrefixesMixin, PrimaryModel):
|
||||
return self.prefix.version
|
||||
return None
|
||||
|
||||
@property
|
||||
def ipv6_full(self):
|
||||
if self.prefix and self.prefix.version == 6:
|
||||
return netaddr.IPAddress(self.prefix).format(netaddr.ipv6_full)
|
||||
|
||||
def get_child_prefixes(self):
|
||||
"""
|
||||
Return all Prefixes within this Aggregate
|
||||
@@ -330,6 +335,11 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
|
||||
def mask_length(self):
|
||||
return self.prefix.prefixlen if self.prefix else None
|
||||
|
||||
@property
|
||||
def ipv6_full(self):
|
||||
if self.prefix and self.prefix.version == 6:
|
||||
return netaddr.IPAddress(self.prefix).format(netaddr.ipv6_full)
|
||||
|
||||
@property
|
||||
def depth(self):
|
||||
return self._depth
|
||||
@@ -808,6 +818,11 @@ class IPAddress(ContactsMixin, PrimaryModel):
|
||||
self._original_assigned_object_id = self.__dict__.get('assigned_object_id')
|
||||
self._original_assigned_object_type_id = self.__dict__.get('assigned_object_type_id')
|
||||
|
||||
@property
|
||||
def ipv6_full(self):
|
||||
if self.address and self.address.version == 6:
|
||||
return netaddr.IPAddress(self.address).format(netaddr.ipv6_full)
|
||||
|
||||
def get_duplicates(self):
|
||||
return IPAddress.objects.filter(
|
||||
vrf=self.vrf,
|
||||
|
||||
@@ -10,6 +10,7 @@ from dcim.filtersets import InterfaceFilterSet
|
||||
from dcim.forms import InterfaceFilterForm
|
||||
from dcim.models import Device, Interface, Site
|
||||
from ipam.tables import VLANTranslationRuleTable
|
||||
from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport
|
||||
from netbox.views import generic
|
||||
from utilities.query import count_related
|
||||
from utilities.tables import get_table_ordering
|
||||
@@ -86,6 +87,11 @@ class VRFBulkEditView(generic.BulkEditView):
|
||||
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)
|
||||
class VRFBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = VRF.objects.all()
|
||||
@@ -136,6 +142,11 @@ class RouteTargetBulkEditView(generic.BulkEditView):
|
||||
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)
|
||||
class RouteTargetBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = RouteTarget.objects.all()
|
||||
@@ -195,6 +206,11 @@ class RIRBulkEditView(generic.BulkEditView):
|
||||
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)
|
||||
class RIRBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = RIR.objects.annotate(
|
||||
@@ -268,6 +284,11 @@ class ASNRangeBulkEditView(generic.BulkEditView):
|
||||
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)
|
||||
class ASNRangeBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = ASNRange.objects.annotate_asn_counts()
|
||||
@@ -335,6 +356,11 @@ class ASNBulkEditView(generic.BulkEditView):
|
||||
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)
|
||||
class ASNBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = ASN.objects.annotate(
|
||||
@@ -356,6 +382,7 @@ class AggregateListView(generic.ObjectListView):
|
||||
filterset = filtersets.AggregateFilterSet
|
||||
filterset_form = forms.AggregateFilterForm
|
||||
table = tables.AggregateTable
|
||||
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
|
||||
|
||||
|
||||
@register_model_view(Aggregate)
|
||||
@@ -488,6 +515,11 @@ class RoleBulkEditView(generic.BulkEditView):
|
||||
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)
|
||||
class RoleBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = Role.objects.all()
|
||||
@@ -506,6 +538,7 @@ class PrefixListView(generic.ObjectListView):
|
||||
filterset_form = forms.PrefixFilterForm
|
||||
table = tables.PrefixTable
|
||||
template_name = 'ipam/prefix_list.html'
|
||||
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
|
||||
|
||||
|
||||
@register_model_view(Prefix)
|
||||
@@ -766,6 +799,11 @@ class IPRangeBulkEditView(generic.BulkEditView):
|
||||
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)
|
||||
class IPRangeBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = IPRange.objects.all()
|
||||
@@ -783,6 +821,7 @@ class IPAddressListView(generic.ObjectListView):
|
||||
filterset = filtersets.IPAddressFilterSet
|
||||
filterset_form = forms.IPAddressFilterForm
|
||||
table = tables.IPAddressTable
|
||||
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
|
||||
|
||||
|
||||
@register_model_view(IPAddress)
|
||||
@@ -1006,6 +1045,11 @@ class VLANGroupBulkEditView(generic.BulkEditView):
|
||||
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)
|
||||
class VLANGroupBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = VLANGroup.objects.annotate_utilization().prefetch_related('tags')
|
||||
@@ -1095,6 +1139,11 @@ class VLANTranslationPolicyBulkEditView(generic.BulkEditView):
|
||||
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)
|
||||
class VLANTranslationPolicyBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = VLANTranslationPolicy.objects.all()
|
||||
@@ -1112,6 +1161,7 @@ class VLANTranslationRuleListView(generic.ObjectListView):
|
||||
filterset = filtersets.VLANTranslationRuleFilterSet
|
||||
filterset_form = forms.VLANTranslationRuleFilterForm
|
||||
table = tables.VLANTranslationRuleTable
|
||||
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkDelete)
|
||||
|
||||
|
||||
@register_model_view(VLANTranslationRule)
|
||||
@@ -1244,6 +1294,11 @@ class FHRPGroupBulkEditView(generic.BulkEditView):
|
||||
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)
|
||||
class FHRPGroupBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = FHRPGroup.objects.all()
|
||||
@@ -1371,6 +1426,11 @@ class VLANBulkEditView(generic.BulkEditView):
|
||||
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)
|
||||
class VLANBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = VLAN.objects.all()
|
||||
@@ -1421,6 +1481,11 @@ class ServiceTemplateBulkEditView(generic.BulkEditView):
|
||||
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)
|
||||
class ServiceTemplateBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = ServiceTemplate.objects.all()
|
||||
@@ -1488,6 +1553,11 @@ class ServiceBulkEditView(generic.BulkEditView):
|
||||
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)
|
||||
class ServiceBulkDeleteView(generic.BulkDeleteView):
|
||||
queryset = Service.objects.prefetch_related('parent')
|
||||
|
||||
@@ -28,7 +28,8 @@ ADVISORY_LOCK_KEYS = {
|
||||
'job-schedules': 110100,
|
||||
}
|
||||
|
||||
# Default view action permission mapping
|
||||
# TODO: Remove in NetBox v4.6
|
||||
# Legacy default view action permission mapping
|
||||
DEFAULT_ACTION_PERMISSIONS = {
|
||||
'add': {'add'},
|
||||
'export': {'view'},
|
||||
@@ -43,3 +44,10 @@ CENSOR_TOKEN_CHANGED = '***CHANGED***'
|
||||
|
||||
# Placeholder text for empty tables
|
||||
EMPTY_TABLE_TEXT = 'No results found'
|
||||
|
||||
# CSV delimiters
|
||||
CSV_DELIMITERS = {
|
||||
'comma': ',',
|
||||
'semicolon': ';',
|
||||
'pipe': '|',
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ from django.utils.translation import gettext_lazy as _
|
||||
from core.models import ObjectType
|
||||
from extras.choices import *
|
||||
from extras.models import CustomField, Tag
|
||||
from utilities.forms import CSVModelForm
|
||||
from utilities.forms import BulkEditForm, CSVModelForm
|
||||
from utilities.forms.fields import CSVModelMultipleChoiceField, DynamicModelMultipleChoiceField
|
||||
from utilities.forms.mixins import CheckLastUpdatedMixin
|
||||
from .mixins import CustomFieldsMixin, SavedFiltersMixin, TagsMixin
|
||||
@@ -100,7 +100,7 @@ class NetBoxModelImportForm(CSVModelForm, NetBoxModelForm):
|
||||
return customfield.to_form_field(for_csv_import=True)
|
||||
|
||||
|
||||
class NetBoxModelBulkEditForm(CustomFieldsMixin, forms.Form):
|
||||
class NetBoxModelBulkEditForm(CustomFieldsMixin, BulkEditForm):
|
||||
"""
|
||||
Base form for modifying multiple NetBox objects (of the same type) in bulk via the UI. Adds support for custom
|
||||
fields and adding/removing tags.
|
||||
@@ -108,9 +108,8 @@ class NetBoxModelBulkEditForm(CustomFieldsMixin, forms.Form):
|
||||
Attributes:
|
||||
fieldsets: An iterable of two-tuples which define a heading and field set to display per section of
|
||||
the rendered form (optional). If not defined, the all fields will be rendered as a single section.
|
||||
nullable_fields: A list of field names indicating which fields support being set to null/empty
|
||||
"""
|
||||
nullable_fields = ()
|
||||
fieldsets = None
|
||||
|
||||
pk = forms.ModelMultipleChoiceField(
|
||||
queryset=None, # Set from self.model on init
|
||||
|
||||
@@ -8,11 +8,16 @@ from django_pglocks import advisory_lock
|
||||
from rq.timeouts import JobTimeoutException
|
||||
|
||||
from core.choices import JobStatusChoices
|
||||
from core.events import JOB_COMPLETED, JOB_FAILED
|
||||
from core.exceptions import JobFailed
|
||||
from core.models import Job, ObjectType
|
||||
from extras.models import Notification
|
||||
from netbox.constants import ADVISORY_LOCK_KEYS
|
||||
from netbox.registry import registry
|
||||
from utilities.request import apply_request_processors
|
||||
|
||||
__all__ = (
|
||||
'AsyncViewJob',
|
||||
'JobRunner',
|
||||
'system_job',
|
||||
)
|
||||
@@ -34,6 +39,19 @@ def system_job(interval):
|
||||
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):
|
||||
"""
|
||||
Background Job helper class.
|
||||
@@ -52,6 +70,11 @@ class JobRunner(ABC):
|
||||
"""
|
||||
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
|
||||
def name(cls):
|
||||
return getattr(cls.Meta, 'name', cls.__name__)
|
||||
@@ -73,15 +96,21 @@ class JobRunner(ABC):
|
||||
This method is called by the Job Scheduler to handle the execution of all job commands. It will maintain the
|
||||
job's metadata and handle errors. For periodic jobs, a new job is automatically scheduled using its `interval`.
|
||||
"""
|
||||
logger = logging.getLogger('netbox.jobs')
|
||||
|
||||
try:
|
||||
job.start()
|
||||
cls(job).run(*args, **kwargs)
|
||||
job.terminate()
|
||||
|
||||
except JobFailed:
|
||||
logger.warning(f"Job {job} failed")
|
||||
job.terminate(status=JobStatusChoices.STATUS_FAILED)
|
||||
|
||||
except Exception as e:
|
||||
job.terminate(status=JobStatusChoices.STATUS_ERRORED, error=repr(e))
|
||||
if type(e) is JobTimeoutException:
|
||||
logging.error(e)
|
||||
logger.error(e)
|
||||
|
||||
# If the executed job is a periodic job, schedule its next execution at the specified interval.
|
||||
finally:
|
||||
@@ -154,3 +183,34 @@ class JobRunner(ABC):
|
||||
job.delete()
|
||||
|
||||
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()
|
||||
|
||||
if data.errors:
|
||||
raise JobFailed()
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
from contextlib import ExitStack
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
import warnings
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib import auth, messages
|
||||
@@ -13,10 +10,10 @@ from django.db.utils import InternalError
|
||||
from django.http import Http404, HttpResponseRedirect
|
||||
|
||||
from netbox.config import clear_config, get_config
|
||||
from netbox.registry import registry
|
||||
from netbox.views import handler_500
|
||||
from utilities.api import is_api_request
|
||||
from utilities.error_handlers import handle_rest_api_exception
|
||||
from utilities.request import apply_request_processors
|
||||
|
||||
__all__ = (
|
||||
'CoreMiddleware',
|
||||
@@ -36,12 +33,7 @@ class CoreMiddleware:
|
||||
request.id = uuid.uuid4()
|
||||
|
||||
# Apply all registered request processors
|
||||
with ExitStack() as stack:
|
||||
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}')
|
||||
with apply_request_processors(request):
|
||||
response = self.get_response(request)
|
||||
|
||||
# Check if language cookie should be renewed
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Sequence, Optional
|
||||
|
||||
from django.urls import reverse_lazy
|
||||
|
||||
|
||||
__all__ = (
|
||||
'get_model_item',
|
||||
@@ -22,20 +24,46 @@ class MenuItemButton:
|
||||
link: str
|
||||
title: str
|
||||
icon_class: str
|
||||
_url: Optional[str] = None
|
||||
permissions: Optional[Sequence[str]] = ()
|
||||
color: Optional[str] = None
|
||||
|
||||
def __post_init__(self):
|
||||
if self.link:
|
||||
self._url = reverse_lazy(self.link)
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
return self._url
|
||||
|
||||
@url.setter
|
||||
def url(self, value):
|
||||
self._url = value
|
||||
|
||||
|
||||
@dataclass
|
||||
class MenuItem:
|
||||
|
||||
link: str
|
||||
link_text: str
|
||||
_url: Optional[str] = None
|
||||
permissions: Optional[Sequence[str]] = ()
|
||||
auth_required: Optional[bool] = False
|
||||
staff_only: Optional[bool] = False
|
||||
buttons: Optional[Sequence[MenuItemButton]] = ()
|
||||
|
||||
def __post_init__(self):
|
||||
if self.link:
|
||||
self._url = reverse_lazy(self.link)
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
return self._url
|
||||
|
||||
@url.setter
|
||||
def url(self, value):
|
||||
self._url = value
|
||||
|
||||
|
||||
@dataclass
|
||||
class MenuGroup:
|
||||
|
||||
180
netbox/netbox/object_actions.py
Normal file
180
netbox/netbox/object_actions.py
Normal 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'
|
||||
@@ -1,3 +1,4 @@
|
||||
from django.urls import reverse_lazy
|
||||
from django.utils.text import slugify
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
@@ -32,17 +33,23 @@ class PluginMenuItem:
|
||||
This class represents a navigation menu item. This constitutes primary link and its text, but also allows for
|
||||
specifying additional link buttons that appear to the right of the item in the van menu.
|
||||
|
||||
Links are specified as Django reverse URL strings.
|
||||
Links are specified as Django reverse URL strings suitable for rendering via {% url item.link %}.
|
||||
Alternatively, a pre-generated url can be set on the object which will be rendered literally.
|
||||
Buttons are each specified as a list of PluginMenuButton instances.
|
||||
"""
|
||||
permissions = []
|
||||
buttons = []
|
||||
_url = None
|
||||
|
||||
def __init__(self, link, link_text, auth_required=False, staff_only=False, permissions=None, buttons=None):
|
||||
def __init__(
|
||||
self, link, link_text, auth_required=False, staff_only=False, permissions=None, buttons=None
|
||||
):
|
||||
self.link = link
|
||||
self.link_text = link_text
|
||||
self.auth_required = auth_required
|
||||
self.staff_only = staff_only
|
||||
if link:
|
||||
self._url = reverse_lazy(link)
|
||||
if permissions is not None:
|
||||
if type(permissions) not in (list, tuple):
|
||||
raise TypeError(_("Permissions must be passed as a tuple or list."))
|
||||
@@ -52,6 +59,14 @@ class PluginMenuItem:
|
||||
raise TypeError(_("Buttons must be passed as a tuple or list."))
|
||||
self.buttons = buttons
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
return self._url
|
||||
|
||||
@url.setter
|
||||
def url(self, value):
|
||||
self._url = value
|
||||
|
||||
|
||||
class PluginMenuButton:
|
||||
"""
|
||||
@@ -60,11 +75,14 @@ class PluginMenuButton:
|
||||
"""
|
||||
color = ButtonColorChoices.DEFAULT
|
||||
permissions = []
|
||||
_url = None
|
||||
|
||||
def __init__(self, link, title, icon_class, color=None, permissions=None):
|
||||
self.link = link
|
||||
self.title = title
|
||||
self.icon_class = icon_class
|
||||
if link:
|
||||
self._url = reverse_lazy(link)
|
||||
if permissions is not None:
|
||||
if type(permissions) not in (list, tuple):
|
||||
raise TypeError(_("Permissions must be passed as a tuple or list."))
|
||||
@@ -73,3 +91,11 @@ class PluginMenuButton:
|
||||
if color not in ButtonColorChoices.values():
|
||||
raise ValueError(_("Button color must be a choice within ButtonColorChoices."))
|
||||
self.color = color
|
||||
|
||||
@property
|
||||
def url(self):
|
||||
return self._url
|
||||
|
||||
@url.setter
|
||||
def url(self, value):
|
||||
self._url = value
|
||||
|
||||
@@ -72,6 +72,16 @@ PREFERENCES = {
|
||||
),
|
||||
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')
|
||||
),
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -115,11 +115,13 @@ class CachedValueSearchBackend(SearchBackend):
|
||||
if lookup in (LookupTypes.STARTSWITH, LookupTypes.ENDSWITH):
|
||||
# "Starts/ends with" matches are valid only on string values
|
||||
query_filter &= Q(type=FieldTypes.STRING)
|
||||
elif lookup == LookupTypes.PARTIAL:
|
||||
elif lookup in (LookupTypes.PARTIAL, LookupTypes.EXACT):
|
||||
try:
|
||||
# If the value looks like an IP address, add an extra match for CIDR values
|
||||
# If the value looks like an IP address, add extra filters for CIDR/INET values
|
||||
address = str(netaddr.IPNetwork(value.strip()).cidr)
|
||||
query_filter |= Q(type=FieldTypes.CIDR) & Q(value__net_contains_or_equals=address)
|
||||
query_filter |= Q(type=FieldTypes.INET) & Q(value__net_host=address)
|
||||
if lookup == LookupTypes.PARTIAL:
|
||||
query_filter |= Q(type=FieldTypes.CIDR) & Q(value__net_contains_or_equals=address)
|
||||
except (AddrFormatError, ValueError):
|
||||
pass
|
||||
|
||||
|
||||
@@ -7,11 +7,19 @@ from django_rq import get_queue
|
||||
from ..jobs import *
|
||||
from core.models import DataSource, Job
|
||||
from core.choices import JobStatusChoices
|
||||
from core.exceptions import JobFailed
|
||||
from utilities.testing import disable_warnings
|
||||
|
||||
|
||||
class TestJobRunner(JobRunner):
|
||||
|
||||
def run(self, *args, **kwargs):
|
||||
pass
|
||||
if kwargs.get('make_fail', False):
|
||||
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):
|
||||
@@ -47,8 +55,22 @@ class JobRunnerTest(JobRunnerTestCase):
|
||||
def test_handle(self):
|
||||
job = TestJobRunner.enqueue(immediate=True)
|
||||
|
||||
# Check job status
|
||||
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):
|
||||
with disable_warnings('netbox.jobs'):
|
||||
job = TestJobRunner.enqueue(immediate=True, make_fail=True)
|
||||
|
||||
self.assertEqual(job.status, JobStatusChoices.STATUS_FAILED)
|
||||
|
||||
def test_handle_errored(self):
|
||||
class ErroredJobRunner(TestJobRunner):
|
||||
EXP = Exception('Test error')
|
||||
|
||||
@@ -15,18 +15,21 @@ from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.urls import reverse
|
||||
from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext as _
|
||||
from django_tables2.export import TableExport
|
||||
from mptt.models import MPTTModel
|
||||
|
||||
from core.models import ObjectType
|
||||
from core.signals import clear_events
|
||||
from extras.choices import CustomFieldUIEditableChoices
|
||||
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.exceptions import AbortRequest, AbortTransaction, PermissionsViolation
|
||||
from utilities.export import TableExport
|
||||
from utilities.forms import BulkRenameForm, ConfirmationForm, restrict_form_fields
|
||||
from utilities.forms.bulk_import import BulkImportForm
|
||||
from utilities.forms.mixins import BackgroundJobMixin
|
||||
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.query import reapply_model_ordering
|
||||
from utilities.request import safe_for_redirect
|
||||
@@ -54,12 +57,12 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
|
||||
Attributes:
|
||||
filterset: A django-filter FilterSet that is applied to the queryset
|
||||
filterset_form: The form class used to render filter options
|
||||
actions: A mapping of supported actions to their required permissions. When adding custom actions, bulk
|
||||
action names must be prefixed with `bulk_`. (See ActionsMixin.)
|
||||
actions: An iterable of ObjectAction subclasses (see ActionsMixin)
|
||||
"""
|
||||
template_name = 'generic/object_list.html'
|
||||
filterset = None
|
||||
filterset_form = None
|
||||
actions = (AddObject, BulkImport, BulkExport, BulkEdit, BulkRename, BulkDelete)
|
||||
|
||||
def get_required_permission(self):
|
||||
return get_permission_for_model(self.queryset.model, 'view')
|
||||
@@ -76,7 +79,7 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
|
||||
|
||||
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.
|
||||
|
||||
@@ -85,6 +88,7 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
|
||||
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
|
||||
from the queryset model name.
|
||||
delimiter: The character used to separate columns (a comma is used by default)
|
||||
"""
|
||||
exclude_columns = {'pk', 'actions'}
|
||||
if columns:
|
||||
@@ -95,7 +99,8 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
|
||||
exporter = TableExport(
|
||||
export_format=TableExport.CSV,
|
||||
table=table,
|
||||
exclude_columns=exclude_columns
|
||||
exclude_columns=exclude_columns,
|
||||
delimiter=delimiter,
|
||||
)
|
||||
return exporter.response(
|
||||
filename=filename or f'netbox_{self.queryset.model._meta.verbose_name_plural}.csv'
|
||||
@@ -150,15 +155,16 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
|
||||
|
||||
# Determine the available actions
|
||||
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:
|
||||
|
||||
# Export the current table view
|
||||
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]
|
||||
return self.export_table(table, columns)
|
||||
delimiter = request.user.config.get('csv_delimiter')
|
||||
return self.export_table(table, columns, delimiter=delimiter)
|
||||
|
||||
# Render an ExportTemplate
|
||||
elif request.GET['export']:
|
||||
@@ -174,11 +180,12 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
|
||||
|
||||
# Fall back to default table/YAML export
|
||||
else:
|
||||
table = self.get_table(self.queryset, request, has_bulk_actions)
|
||||
return self.export_table(table)
|
||||
table = self.get_table(self.queryset, request, has_table_actions)
|
||||
delimiter = request.user.config.get('csv_delimiter')
|
||||
return self.export_table(table, delimiter=delimiter)
|
||||
|
||||
# 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 htmx_partial(request):
|
||||
@@ -498,25 +505,27 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
|
||||
if form.is_valid():
|
||||
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 process_request_as_job(self.__class__, request, name=job_name):
|
||||
return redirect(redirect_url)
|
||||
|
||||
try:
|
||||
# Iterate through data and bind each record to a new model form instance.
|
||||
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
|
||||
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
|
||||
|
||||
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):
|
||||
clear_events.send(sender=self)
|
||||
|
||||
@@ -525,6 +534,25 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
form.add_error(None, e.message)
|
||||
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:
|
||||
logger.debug("Form validation failed")
|
||||
|
||||
@@ -680,6 +708,16 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
if '_apply' in request.POST:
|
||||
if form.is_valid():
|
||||
logger.debug("Form validation was successful")
|
||||
|
||||
# If indicated, defer this request to a background job & redirect the user
|
||||
if form.cleaned_data['background_job']:
|
||||
job_name = _('Bulk edit {count} {object_type}').format(
|
||||
count=len(form.cleaned_data['pk']),
|
||||
object_type=model._meta.verbose_name_plural,
|
||||
)
|
||||
if process_request_as_job(self.__class__, request, name=job_name):
|
||||
return redirect(self.get_return_url(request))
|
||||
|
||||
try:
|
||||
with transaction.atomic(using=router.db_for_write(model)):
|
||||
updated_objects = self._update_objects(form, request)
|
||||
@@ -689,6 +727,16 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
if object_count != len(updated_objects):
|
||||
raise PermissionsViolation
|
||||
|
||||
# If this request was executed via a background job, return the raw data for logging
|
||||
if is_background_request(request):
|
||||
return AsyncJobData(
|
||||
log=[
|
||||
_('Updated {object}').format(object=str(obj))
|
||||
for obj in updated_objects
|
||||
],
|
||||
errors=form.errors
|
||||
)
|
||||
|
||||
if updated_objects:
|
||||
msg = f'Updated {len(updated_objects)} {model._meta.verbose_name_plural}'
|
||||
logger.info(msg)
|
||||
@@ -729,7 +777,11 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
"""
|
||||
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'
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -759,12 +811,12 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
replace = form.cleaned_data['replace']
|
||||
if form.cleaned_data['use_regex']:
|
||||
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
|
||||
except re.error:
|
||||
obj.new_name = obj.name
|
||||
obj.new_name = getattr(obj, self.field_name)
|
||||
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)
|
||||
|
||||
return renamed_pks
|
||||
@@ -783,7 +835,7 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
|
||||
if '_apply' in request.POST:
|
||||
for obj in selected_objects:
|
||||
obj.name = obj.new_name
|
||||
setattr(obj, self.field_name, obj.new_name)
|
||||
obj.save()
|
||||
|
||||
# Enforce constrained permissions
|
||||
@@ -813,6 +865,7 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
selected_objects = self.queryset.filter(pk__in=form.initial['pk'])
|
||||
|
||||
return render(request, self.template_name, {
|
||||
'field_name': self.field_name,
|
||||
'form': form,
|
||||
'obj_type_plural': self.queryset.model._meta.verbose_name_plural,
|
||||
'selected_objects': selected_objects,
|
||||
@@ -839,7 +892,7 @@ class BulkDeleteView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
"""
|
||||
Provide a standard bulk delete form if none has been specified for the view
|
||||
"""
|
||||
class BulkDeleteForm(ConfirmationForm):
|
||||
class BulkDeleteForm(BackgroundJobMixin, ConfirmationForm):
|
||||
pk = ModelMultipleChoiceField(queryset=self.queryset, widget=MultipleHiddenInput)
|
||||
|
||||
return BulkDeleteForm
|
||||
@@ -871,6 +924,15 @@ class BulkDeleteView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
if form.is_valid():
|
||||
logger.debug("Form validation was successful")
|
||||
|
||||
# If indicated, defer this request to a background job & redirect the user
|
||||
if form.cleaned_data['background_job']:
|
||||
job_name = _('Bulk delete {count} {object_type}').format(
|
||||
count=len(form.cleaned_data['pk']),
|
||||
object_type=model._meta.verbose_name_plural,
|
||||
)
|
||||
if process_request_as_job(self.__class__, request, name=job_name):
|
||||
return redirect(self.get_return_url(request))
|
||||
|
||||
# Delete objects
|
||||
queryset = self.queryset.filter(pk__in=pk_list)
|
||||
deleted_count = queryset.count()
|
||||
@@ -892,6 +954,16 @@ class BulkDeleteView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
messages.error(request, mark_safe(e.message))
|
||||
return redirect(self.get_return_url(request))
|
||||
|
||||
# If this request was executed via a background job, return the raw data for logging
|
||||
if is_background_request(request):
|
||||
return AsyncJobData(
|
||||
log=[
|
||||
_('Deleted {object}').format(object=str(obj))
|
||||
for obj in queryset
|
||||
],
|
||||
errors=form.errors
|
||||
)
|
||||
|
||||
msg = _("Deleted {count} {object_type}").format(
|
||||
count=deleted_count,
|
||||
object_type=model._meta.verbose_name_plural
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from django.shortcuts import get_object_or_404
|
||||
|
||||
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
|
||||
|
||||
__all__ = (
|
||||
@@ -9,6 +9,18 @@ __all__ = (
|
||||
'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:
|
||||
"""
|
||||
@@ -19,7 +31,24 @@ class ActionsMixin:
|
||||
Standard actions include: add, import, export, bulk_edit, and bulk_delete. Some views extend this default map
|
||||
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):
|
||||
"""
|
||||
@@ -27,11 +56,15 @@ class ActionsMixin:
|
||||
"""
|
||||
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
|
||||
permitted_actions = []
|
||||
for action in self.actions:
|
||||
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):
|
||||
permitted_actions.append(action)
|
||||
|
||||
@@ -14,6 +14,9 @@ from django.utils.safestring import mark_safe
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
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.exceptions import AbortRequest, PermissionsViolation
|
||||
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.
|
||||
|
||||
@@ -44,8 +47,10 @@ class ObjectView(BaseObjectView):
|
||||
|
||||
Attributes:
|
||||
tab: A ViewTab instance for the view
|
||||
actions: An iterable of ObjectAction subclasses (see ActionsMixin)
|
||||
"""
|
||||
tab = None
|
||||
actions = (CloneObject, EditObject, DeleteObject)
|
||||
|
||||
def get_required_permission(self):
|
||||
return get_permission_for_model(self.queryset.model, 'view')
|
||||
@@ -72,9 +77,11 @@ class ObjectView(BaseObjectView):
|
||||
request: The current request
|
||||
"""
|
||||
instance = self.get_object(**kwargs)
|
||||
actions = self.get_permitted_actions(request.user, model=instance)
|
||||
|
||||
return render(request, self.get_template_name(), {
|
||||
'object': instance,
|
||||
'actions': actions,
|
||||
'tab': self.tab,
|
||||
**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
|
||||
filterset: A django-filter FilterSet that is applied to the queryset
|
||||
filterset_form: The form class used to render filter options
|
||||
actions: A mapping of supported actions to their required permissions. When adding custom actions, bulk
|
||||
action names must be prefixed with `bulk_`. (See ActionsMixin.)
|
||||
actions: An iterable of ObjectAction subclasses (see ActionsMixin)
|
||||
"""
|
||||
child_model = None
|
||||
table = None
|
||||
filterset = None
|
||||
filterset_form = None
|
||||
actions = (AddObject, BulkImport, BulkEdit, BulkExport, BulkDelete)
|
||||
template_name = 'generic/object_children.html'
|
||||
|
||||
def get_children(self, request, parent):
|
||||
@@ -138,10 +145,10 @@ class ObjectChildrenView(ObjectView, ActionsMixin, TableMixin):
|
||||
|
||||
# Determine the available actions
|
||||
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 = 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 htmx_partial(request):
|
||||
|
||||
2
netbox/project-static/dist/netbox.css
vendored
2
netbox/project-static/dist/netbox.css
vendored
File diff suppressed because one or more lines are too long
10
netbox/project-static/dist/netbox.js
vendored
10
netbox/project-static/dist/netbox.js
vendored
File diff suppressed because one or more lines are too long
4
netbox/project-static/dist/netbox.js.map
vendored
4
netbox/project-static/dist/netbox.js.map
vendored
File diff suppressed because one or more lines are too long
@@ -23,13 +23,13 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@mdi/font": "7.4.47",
|
||||
"@tabler/core": "1.3.2",
|
||||
"@tabler/core": "1.4.0",
|
||||
"bootstrap": "5.3.7",
|
||||
"clipboard": "2.0.11",
|
||||
"flatpickr": "4.6.13",
|
||||
"gridstack": "12.2.1",
|
||||
"htmx.org": "2.0.5",
|
||||
"query-string": "9.2.1",
|
||||
"gridstack": "12.2.2",
|
||||
"htmx.org": "2.0.6",
|
||||
"query-string": "9.2.2",
|
||||
"sass": "1.89.2",
|
||||
"tom-select": "2.4.3",
|
||||
"typeface-inter": "3.18.1",
|
||||
@@ -39,15 +39,15 @@
|
||||
"@types/bootstrap": "5.2.10",
|
||||
"@types/cookie": "^0.6.0",
|
||||
"@types/node": "^22.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.1.0",
|
||||
"@typescript-eslint/parser": "^8.1.0",
|
||||
"esbuild": "^0.25.3",
|
||||
"@typescript-eslint/eslint-plugin": "^8.37.0",
|
||||
"@typescript-eslint/parser": "^8.37.0",
|
||||
"esbuild": "^0.25.6",
|
||||
"esbuild-sass-plugin": "^3.3.1",
|
||||
"eslint": "<9.0",
|
||||
"eslint-config-prettier": "^9.1.0",
|
||||
"eslint-import-resolver-typescript": "^3.6.3",
|
||||
"eslint-plugin-import": "^2.30.0",
|
||||
"eslint-plugin-prettier": "^5.2.1",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-prettier": "^5.5.1",
|
||||
"prettier": "^3.3.3",
|
||||
"typescript": "<5.5"
|
||||
},
|
||||
|
||||
4
netbox/project-static/styles/custom/racks.scss
Normal file
4
netbox/project-static/styles/custom/racks.scss
Normal file
@@ -0,0 +1,4 @@
|
||||
.rack-loading-container {
|
||||
min-height: 200px;
|
||||
margin-left: 30px;
|
||||
}
|
||||
@@ -27,3 +27,4 @@
|
||||
@import 'custom/markdown';
|
||||
@import 'custom/misc';
|
||||
@import 'custom/notifications';
|
||||
@import 'custom/racks';
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,3 @@
|
||||
version: "4.3.3"
|
||||
version: "4.3.4"
|
||||
edition: "Community"
|
||||
published: "2025-06-26"
|
||||
published: "2025-07-15"
|
||||
|
||||
3
netbox/templates/core/buttons/bulk_sync.html
Normal file
3
netbox/templates/core/buttons/bulk_sync.html
Normal 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>
|
||||
@@ -11,12 +11,6 @@
|
||||
<li class="breadcrumb-item"><a href="{% url 'core:datafile_list' %}?source_id={{ object.source.pk }}">{{ object.source }}</a></li>
|
||||
{% endblock %}
|
||||
|
||||
{% block control-buttons %}
|
||||
{% if request.user|can_delete:object %}
|
||||
{% delete_button object %}
|
||||
{% endif %}
|
||||
{% endblock control-buttons %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row mb-3">
|
||||
<div class="col">
|
||||
|
||||
@@ -1,33 +1,6 @@
|
||||
{% extends 'generic/object.html' %}
|
||||
{% load buttons %}
|
||||
{% load helpers %}
|
||||
{% load perms %}
|
||||
{% extends 'core/job/base.html' %}
|
||||
{% 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 %}
|
||||
<div class="row mb-3">
|
||||
<div class="col col-12 col-md-6">
|
||||
|
||||
23
netbox/templates/core/job/base.html
Normal file
23
netbox/templates/core/job/base.html
Normal 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 %}
|
||||
12
netbox/templates/core/job/log.html
Normal file
12
netbox/templates/core/job/log.html
Normal 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 %}
|
||||
71
netbox/templates/dcim/buttons/bulk_add_components.html
Normal file
71
netbox/templates/dcim/buttons/bulk_add_components.html
Normal 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>
|
||||
3
netbox/templates/dcim/buttons/bulk_disconnect.html
Normal file
3
netbox/templates/dcim/buttons/bulk_disconnect.html
Normal 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>
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -1,30 +1,5 @@
|
||||
{% extends 'dcim/device/components_base.html' %}
|
||||
{% load helpers %}
|
||||
{% load i18n %}
|
||||
{% extends 'generic/object_children.html' %}
|
||||
|
||||
{% 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 %}
|
||||
|
||||
{% 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 %}
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -1,6 +1,17 @@
|
||||
{% load i18n %}
|
||||
<div style="margin-left: -30px">
|
||||
<object data="{% url 'dcim-api:rack-elevation' pk=object.pk %}?face={{face}}&render=svg{% if extra_params %}&{{ extra_params }}{% endif %}" class="rack_elevation" aria-label="{% trans "Rack elevation" %}"></object>
|
||||
<div
|
||||
hx-get="{% url 'dcim-api:rack-elevation' pk=object.pk %}?face={{ face }}&render=svg{% if extra_params %}&{{ extra_params }}{% endif %}"
|
||||
hx-trigger="intersect"
|
||||
hx-swap="outerHTML"
|
||||
aria-label="{% trans "Rack elevation" %}"
|
||||
>
|
||||
<div class="d-flex justify-content-center align-items-center rack-loading-container">
|
||||
<div class="spinner-border" role="status">
|
||||
<span class="visually-hidden">{% trans "Loading..." %}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center mt-3">
|
||||
<a class="btn btn-outline-primary" href="{% url 'dcim-api:rack-elevation' pk=object.pk %}?face={{face}}&render=svg{% if extra_params %}&{{ extra_params }}{% endif %}" hx-boost="false">
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -1,18 +1,8 @@
|
||||
{% extends 'generic/object.html' %}
|
||||
{% load buttons %}
|
||||
{% load helpers %}
|
||||
{% load plugins %}
|
||||
{% 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 %}
|
||||
<div class="row">
|
||||
<div class="col col-12 col-md-4">
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -14,7 +14,7 @@
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Description</th>
|
||||
<td>{{ object.description|markdown|placeholder }}</td>
|
||||
<td>{{ object.description|placeholder }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row">Base Choices</th>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user