mirror of
https://github.com/netbox-community/netbox.git
synced 2026-01-08 21:02:18 -06:00
Compare commits
43 Commits
v4.3.2
...
a3ee46a32a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3ee46a32a | ||
|
|
eb86b9d6b0 | ||
|
|
37fb8ae8ae | ||
|
|
6b2643c9cd | ||
|
|
9946ff910c | ||
|
|
e95c3825de | ||
|
|
92a13a4226 | ||
|
|
ae3de95dce | ||
|
|
a1cd81ff35 | ||
|
|
ce12de8b6d | ||
|
|
601a77ac73 | ||
|
|
71e6ea5785 | ||
|
|
0a9887b42f | ||
|
|
3ecf29d797 | ||
|
|
c48e4f590e | ||
|
|
aee83a434a | ||
|
|
a17699d261 | ||
|
|
f97d07a11c | ||
|
|
1fd3d390ae | ||
|
|
7dab7d730d | ||
|
|
c660f1c019 | ||
|
|
334b45f55a | ||
|
|
e6c1cebd34 | ||
|
|
a9af541e81 | ||
|
|
f706572113 | ||
|
|
6a6286777c | ||
|
|
afeddee10d | ||
|
|
a48bee2a2e | ||
|
|
b9db6ebd63 | ||
|
|
9e0493c64c | ||
|
|
e3509c092a | ||
|
|
762cfc7d10 | ||
|
|
522f80ed9d | ||
|
|
fd6062de75 | ||
|
|
c872cce59f | ||
|
|
dc8267d890 | ||
|
|
2bfb9f4ed0 | ||
|
|
dda0a55e5e | ||
|
|
2680f855ff | ||
|
|
6ca791850a | ||
|
|
43df06f210 | ||
|
|
7e6b1bbd79 | ||
|
|
716acaa657 |
@@ -15,7 +15,7 @@ body:
|
||||
attributes:
|
||||
label: NetBox version
|
||||
description: What version of NetBox are you currently running?
|
||||
placeholder: v4.3.2
|
||||
placeholder: v4.3.3
|
||||
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.2
|
||||
placeholder: v4.3.3
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
</h3>
|
||||
<h3>
|
||||
:jigsaw: <a href="#jigsaw-creating-plugins">Create a plugin</a> ·
|
||||
:rescue_worker_helmet: <a href="#rescue_worker_helmet-become-a-maintainer">Become a maintainer</a> ·
|
||||
:briefcase: <a href="#briefcase-looking-for-a-job">Work with us!</a> ·
|
||||
:heart: <a href="#heart-other-ways-to-contribute">Other ideas</a>
|
||||
</h3>
|
||||
</div>
|
||||
@@ -109,21 +109,9 @@ Do you have an idea for something you'd like to build in NetBox, but might not b
|
||||
|
||||
Check out our [plugin development tutorial](https://github.com/netbox-community/netbox-plugin-tutorial) to get started!
|
||||
|
||||
## :rescue_worker_helmet: Become a Maintainer
|
||||
## :briefcase: Looking for a Job?
|
||||
|
||||
We're always looking for motivated individuals to join the maintainers team and help drive NetBox's long-term development. Some of our most sought-after skills include:
|
||||
|
||||
* Python development with a strong focus on the [Django](https://www.djangoproject.com/) framework
|
||||
* Expertise working with PostgreSQL databases
|
||||
* Javascript & TypeScript proficiency
|
||||
* A knack for web application design (HTML & CSS)
|
||||
* Familiarity with git and software development best practices
|
||||
* Excellent attention to detail
|
||||
* Working experience in the field of network operations & engineering
|
||||
|
||||
We generally ask that maintainers dedicate around four hours of work to the project each week on average, which includes both hands-on development and project management tasks such as issue triage. Maintainers are also encouraged (but not required) to attend our bi-weekly Zoom call to catch up on recent items.
|
||||
|
||||
Interested? You can contact our lead maintainer, Jeremy Stretch, at jeremy@netbox.dev or on the [NetDev Community Slack](https://netdev.chat/). We'd love to have you on the team!
|
||||
At [NetBox Labs](https://netboxlabs.com/), we're always looking for highly skilled and motivated people to join our team. While NetBox is a core part of our product lineup, we have an ever-expanding suite of solutions serving the network automation space. Check out our [current openings](https://netboxlabs.com/careers/) to see if you might be a fit!
|
||||
|
||||
## :heart: Other Ways to Contribute
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<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>
|
||||
<p>
|
||||
<strong><a href="https://github.com/netbox-community/netbox/">NetBox Community</a></strong> |
|
||||
<strong><a href="https://netboxlabs.com/community/">NetBox Community</a></strong> |
|
||||
<strong><a href="https://netboxlabs.com/netbox-cloud/">NetBox Cloud</a></strong> |
|
||||
<strong><a href="https://netboxlabs.com/netbox-enterprise/">NetBox Enterprise</a></strong>
|
||||
</p>
|
||||
|
||||
@@ -140,7 +140,8 @@ strawberry-graphql
|
||||
|
||||
# Strawberry GraphQL Django extension
|
||||
# https://github.com/strawberry-graphql/strawberry-django/releases
|
||||
strawberry-graphql-django
|
||||
# See #19771
|
||||
strawberry-graphql-django==0.60.0
|
||||
|
||||
# SVG image rendering (used for rack elevations)
|
||||
# https://github.com/mozman/svgwrite/blob/master/NEWS.rst
|
||||
|
||||
@@ -158,6 +158,7 @@ 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.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
|
||||
|
||||
@@ -166,7 +166,8 @@ Then, compile these portable (`.po`) files for use in the application:
|
||||
|
||||
### Update Version and Changelog
|
||||
|
||||
* Update the version number and date in `netbox/release.yaml` and `pyproject.toml`. Add or remove the designation (e.g. `beta1`) if applicable.
|
||||
* Update the version number and published date in `netbox/release.yaml`. Add or remove the designation (e.g. `beta1`) if applicable.
|
||||
* Copy the version number from `release.yaml` to `pyproject.toml` in the project root.
|
||||
* Update the example version numbers in the feature request and bug report templates under `.github/ISSUE_TEMPLATES/`.
|
||||
* Add a section for this release at the top of the changelog page for the minor version (e.g. `docs/release-notes/version-4.2.md`) listing all relevant changes made in this release.
|
||||
|
||||
@@ -192,15 +193,3 @@ Create a [new release](https://github.com/netbox-community/netbox/releases/new)
|
||||
* **Description:** Copy from the pull request body, then promote the `###` headers to `##` ones
|
||||
|
||||
Once created, the release will become available for users to install.
|
||||
|
||||
### Update the Public Documentation
|
||||
|
||||
After a release has been published, the public NetBox documentation needs to be updated. This is accomplished by running two actions on the [netboxlabs-docs](https://github.com/netboxlabs/netboxlabs-docs) repository.
|
||||
|
||||
First, run the `build-site` action, by navigating to Actions > build-site > Run workflow. This process compiles the documentation along with an overlay for integration with the documentation portal at <https://netboxlabs.com/docs>. The job should take about two minutes.
|
||||
|
||||
Once the documentation files have been compiled, they must be published by running the `deploy-kinsta` action. Select the desired deployment environment (staging or production) and specify `latest` as the deploy tag.
|
||||
|
||||
Clear the CDN cache from the [Kinsta](https://my.kinsta.com/) portal. Navigate to _Sites_ / _NetBox Labs_ / _Live_, select _Cache_ in the left-nav, click the _Clear Cache_ button, and confirm the clear operation.
|
||||
|
||||
Finally, verify that the documentation at <https://netboxlabs.com/docs/netbox/en/stable/> has been updated.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -38,6 +38,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:
|
||||
|
||||
@@ -86,3 +86,69 @@ netbox=> DELETE FROM django_migrations WHERE app='pluginname';
|
||||
|
||||
!!! warning
|
||||
Exercise extreme caution when altering Django system tables. Users are strongly encouraged to perform a backup of their database immediately before taking these actions.
|
||||
|
||||
## Clean Up Content Types and Permissions
|
||||
|
||||
After removing a plugin and its database tables, you may find that object type references (`ContentTypes`) created by the plugin still appear in the permissions management section (e.g., when editing permissions in the NetBox UI).
|
||||
This happens because the `django_content_type` table retains entries for the models that the plugin registered with Django.
|
||||
|
||||
!!! warning
|
||||
Please use caution when removing `ContentTypes`. It is strongly recommended to **back up your database** before making these changes.
|
||||
|
||||
**Identify Stale Content Types:**
|
||||
|
||||
Open the Django shell to inspect lingering `ContentType` entries related to the removed plugin.
|
||||
Typically, the Content Type's `app_label` matches the plugin’s name.
|
||||
|
||||
|
||||
```no-highlight
|
||||
$ cd /opt/netbox/
|
||||
$ source /opt/netbox/venv/bin/activate
|
||||
(venv) $ python3 netbox/manage.py nbshell
|
||||
```
|
||||
|
||||
Then, in the shell:
|
||||
|
||||
```no-highlight
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
# Replace 'pluginname' with your plugin's actual name
|
||||
stale_types = ContentType.objects.filter(app_label="pluginname")
|
||||
for ct in stale_types:
|
||||
print(ct)
|
||||
### ^^^ These will be removed, make sure its ok
|
||||
```
|
||||
|
||||
!!! warning
|
||||
Review the output carefully and confirm that each listed Content Type is related to the plugin you removed.
|
||||
|
||||
**Remove Stale Content Types and Related Permissions:**
|
||||
|
||||
Next, check for any permissions associated with these Content Types:
|
||||
|
||||
```no-highlight
|
||||
from django.contrib.auth.models import Permission
|
||||
for ct in stale_types:
|
||||
perms = Permission.objects.filter(content_type=ct)
|
||||
print(list(perms))
|
||||
```
|
||||
|
||||
If there are related Permissions, you can remove them safely:
|
||||
|
||||
```no-highlight
|
||||
for ct in stale_types:
|
||||
Permission.objects.filter(content_type=ct).delete()
|
||||
```
|
||||
|
||||
After removing any related permissions, delete the Content Type entries:
|
||||
|
||||
```no-highlight
|
||||
stale_types.delete()
|
||||
```
|
||||
|
||||
**Restart NetBox:**
|
||||
|
||||
After making these changes, restart the NetBox service to ensure all changes are reflected.
|
||||
|
||||
```no-highlight
|
||||
sudo systemctl restart netbox
|
||||
```
|
||||
|
||||
@@ -1,5 +1,33 @@
|
||||
# NetBox v4.3
|
||||
|
||||
## v4.3.3 (2025-06-26)
|
||||
|
||||
### Enhancements
|
||||
|
||||
* [#17183](https://github.com/netbox-community/netbox/issues/17183) - Enable associating tags with object types during bulk import
|
||||
* [#17719](https://github.com/netbox-community/netbox/issues/17719) - Introduce a user preference for table row striping
|
||||
* [#19492](https://github.com/netbox-community/netbox/issues/19492) - Add a UI button to download the output of an executed custom script
|
||||
* [#19499](https://github.com/netbox-community/netbox/issues/19499) - Support qualifying interfaces by parent device when bulk importing wireless links
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* [#19529](https://github.com/netbox-community/netbox/issues/19529) - Fix support for running custom scripts via the `runscript` management command
|
||||
* [#19555](https://github.com/netbox-community/netbox/issues/19555) - Fix support for `schedule_at` when invoking a custom script via the REST API
|
||||
* [#19617](https://github.com/netbox-community/netbox/issues/19617) - Ensure consistent styling of "connect" buttons in UI
|
||||
* [#19640](https://github.com/netbox-community/netbox/issues/19640) - Restore ability to filter FHRP group assignments by device/VM in GraphQL API
|
||||
* [#19644](https://github.com/netbox-community/netbox/issues/19644) - Atomic transactions should always employ database routing
|
||||
* [#19659](https://github.com/netbox-community/netbox/issues/19659) - Populate initial device/VM selection for "add a service" button
|
||||
* [#19665](https://github.com/netbox-community/netbox/issues/19665) - Correct field reference in wireless link model validation
|
||||
* [#19667](https://github.com/netbox-community/netbox/issues/19667) - Fix `TypeError` exception when creating a new module profile type with no schema
|
||||
* [#19673](https://github.com/netbox-community/netbox/issues/19673) - Ignore custom field references when compiling table prefetches
|
||||
* [#19677](https://github.com/netbox-community/netbox/issues/19677) - Fix exception when passing null value to `present_in_vrf` filter
|
||||
* [#19680](https://github.com/netbox-community/netbox/issues/19680) - Correct chronological ordering of change records resulting from device deletions
|
||||
* [#19687](https://github.com/netbox-community/netbox/issues/19687) - Cellular interface types should be considered non-connectable
|
||||
* [#19702](https://github.com/netbox-community/netbox/issues/19702) - Fix `DoesNotExist` exception when deleting a notification group with an associated event rule
|
||||
* [#19745](https://github.com/netbox-community/netbox/issues/19745) - Fix bulk import of services with IP addresses assigned to FHRP groups
|
||||
|
||||
---
|
||||
|
||||
## v4.3.2 (2025-06-05)
|
||||
|
||||
### Enhancements
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
from django.contrib import messages
|
||||
from django.db import transaction
|
||||
from django.db import router, transaction
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
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(
|
||||
@@ -384,7 +411,7 @@ class CircuitSwapTerminations(generic.ObjectEditView):
|
||||
|
||||
if termination_a and termination_z:
|
||||
# Use a placeholder to avoid an IntegrityError on the (circuit, term_side) unique constraint
|
||||
with transaction.atomic():
|
||||
with transaction.atomic(using=router.db_for_write(CircuitTermination)):
|
||||
termination_a.term_side = '_'
|
||||
termination_a.save()
|
||||
termination_z.term_side = 'A'
|
||||
@@ -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)
|
||||
@@ -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,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()
|
||||
|
||||
@@ -205,6 +219,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'
|
||||
@@ -162,6 +162,12 @@ def handle_deleted_object(sender, instance, **kwargs):
|
||||
getattr(obj, related_field_name).remove(instance)
|
||||
elif type(relation) is ManyToOneRel and relation.field.null is True:
|
||||
setattr(obj, related_field_name, None)
|
||||
# make sure the object hasn't been deleted - in case of
|
||||
# deletion chaining of related objects
|
||||
try:
|
||||
obj.refresh_from_db()
|
||||
except DoesNotExist:
|
||||
continue
|
||||
obj.save()
|
||||
|
||||
# Enqueue the object for event processing
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
|
||||
@@ -6,12 +6,13 @@ from rest_framework import status
|
||||
from core.choices import ObjectChangeActionChoices
|
||||
from core.models import ObjectChange, ObjectType
|
||||
from dcim.choices import SiteStatusChoices
|
||||
from dcim.models import Site
|
||||
from dcim.models import Site, CableTermination, Device, DeviceType, DeviceRole, Interface, Cable
|
||||
from extras.choices import *
|
||||
from extras.models import CustomField, CustomFieldChoiceSet, Tag
|
||||
from utilities.testing import APITestCase
|
||||
from utilities.testing.utils import create_tags, post_data
|
||||
from utilities.testing.views import ModelViewTestCase
|
||||
from dcim.models import Manufacturer
|
||||
|
||||
|
||||
class ChangeLogViewTest(ModelViewTestCase):
|
||||
@@ -270,6 +271,81 @@ class ChangeLogViewTest(ModelViewTestCase):
|
||||
# Check that no ObjectChange records have been created
|
||||
self.assertEqual(ObjectChange.objects.count(), 0)
|
||||
|
||||
def test_ordering_genericrelation(self):
|
||||
# Create required objects first
|
||||
manufacturer = Manufacturer.objects.create(name='Manufacturer 1')
|
||||
device_type = DeviceType.objects.create(
|
||||
manufacturer=manufacturer,
|
||||
model='Model 1',
|
||||
slug='model-1'
|
||||
)
|
||||
device_role = DeviceRole.objects.create(
|
||||
name='Role 1',
|
||||
slug='role-1'
|
||||
)
|
||||
site = Site.objects.create(
|
||||
name='Site 1',
|
||||
slug='site-1'
|
||||
)
|
||||
|
||||
# Create two devices
|
||||
device1 = Device.objects.create(
|
||||
name='Device 1',
|
||||
device_type=device_type,
|
||||
role=device_role,
|
||||
site=site
|
||||
)
|
||||
device2 = Device.objects.create(
|
||||
name='Device 2',
|
||||
device_type=device_type,
|
||||
role=device_role,
|
||||
site=site
|
||||
)
|
||||
|
||||
# Create interfaces on both devices
|
||||
interface1 = Interface.objects.create(
|
||||
device=device1,
|
||||
name='eth0',
|
||||
type='1000base-t'
|
||||
)
|
||||
interface2 = Interface.objects.create(
|
||||
device=device2,
|
||||
name='eth0',
|
||||
type='1000base-t'
|
||||
)
|
||||
|
||||
# Create a cable between the interfaces
|
||||
_ = Cable.objects.create(
|
||||
a_terminations=[interface1],
|
||||
b_terminations=[interface2],
|
||||
status='connected'
|
||||
)
|
||||
|
||||
# Delete device1
|
||||
request = {
|
||||
'path': reverse('dcim:device_delete', kwargs={'pk': device1.pk}),
|
||||
'data': post_data({'confirm': True}),
|
||||
}
|
||||
self.add_permissions(
|
||||
'dcim.delete_device',
|
||||
'dcim.delete_interface',
|
||||
'dcim.delete_cable',
|
||||
'dcim.delete_cabletermination'
|
||||
)
|
||||
response = self.client.post(**request)
|
||||
self.assertHttpStatus(response, 302)
|
||||
|
||||
# Get the ObjectChange records for delete actions ordered by time
|
||||
changes = ObjectChange.objects.filter(
|
||||
action=ObjectChangeActionChoices.ACTION_DELETE
|
||||
).order_by('time')[:3]
|
||||
|
||||
# Verify the order of deletion
|
||||
self.assertEqual(len(changes), 3)
|
||||
self.assertEqual(changes[0].changed_object_type, ContentType.objects.get_for_model(CableTermination))
|
||||
self.assertEqual(changes[1].changed_object_type, ContentType.objects.get_for_model(Interface))
|
||||
self.assertEqual(changes[2].changed_object_type, ContentType.objects.get_for_model(Device))
|
||||
|
||||
|
||||
class ChangeLogAPITest(APITestCase):
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -53,6 +53,11 @@ WIRELESS_IFACE_TYPES = [
|
||||
InterfaceTypeChoices.TYPE_802151,
|
||||
InterfaceTypeChoices.TYPE_802154,
|
||||
InterfaceTypeChoices.TYPE_OTHER_WIRELESS,
|
||||
InterfaceTypeChoices.TYPE_GSM,
|
||||
InterfaceTypeChoices.TYPE_CDMA,
|
||||
InterfaceTypeChoices.TYPE_LTE,
|
||||
InterfaceTypeChoices.TYPE_4G,
|
||||
InterfaceTypeChoices.TYPE_5G,
|
||||
]
|
||||
|
||||
NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES
|
||||
|
||||
@@ -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.'
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -415,6 +415,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 +436,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(
|
||||
|
||||
@@ -144,7 +144,7 @@ class ModuleType(ImageAttachmentsMixin, PrimaryModel, WeightMixin):
|
||||
super().clean()
|
||||
|
||||
# Validate any attributes against the assigned profile's schema
|
||||
if self.profile:
|
||||
if self.profile and self.profile.schema:
|
||||
try:
|
||||
jsonschema.validate(self.attribute_data, schema=self.profile.schema)
|
||||
except JSONValidationError as e:
|
||||
|
||||
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'
|
||||
@@ -954,6 +954,19 @@ class CableTestCase(TestCase):
|
||||
with self.assertRaises(ValidationError):
|
||||
cable.clean()
|
||||
|
||||
@tag('regression')
|
||||
def test_cable_cannot_terminate_to_a_cellular_interface(self):
|
||||
"""
|
||||
A cable cannot terminate to a cellular interface
|
||||
"""
|
||||
device1 = Device.objects.get(name='TestDevice1')
|
||||
interface2 = Interface.objects.get(device__name='TestDevice2', name='eth0')
|
||||
|
||||
cellular_interface = Interface(device=device1, name="W1", type=InterfaceTypeChoices.TYPE_LTE)
|
||||
cable = Cable(a_terminations=[interface2], b_terminations=[cellular_interface])
|
||||
with self.assertRaises(ValidationError):
|
||||
cable.clean()
|
||||
|
||||
|
||||
class VirtualDeviceContextTestCase(TestCase):
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from django.apps import apps
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.db import transaction
|
||||
from django.db import router, transaction
|
||||
|
||||
|
||||
def compile_path_node(ct_id, object_id):
|
||||
@@ -53,7 +53,7 @@ def rebuild_paths(terminations):
|
||||
for obj in terminations:
|
||||
cable_paths = CablePath.objects.filter(_nodes__contains=obj)
|
||||
|
||||
with transaction.atomic():
|
||||
with transaction.atomic(using=router.db_for_write(CablePath)):
|
||||
for cp in cable_paths:
|
||||
cp.delete()
|
||||
create_cablepath(cp.origins)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from django.contrib import messages
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.paginator import EmptyPage, PageNotAnInteger
|
||||
from django.db import transaction
|
||||
from django.db import router, transaction
|
||||
from django.db.models import Prefetch
|
||||
from django.forms import ModelMultipleChoiceField, MultipleHiddenInput, modelformset_factory
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
@@ -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):
|
||||
@@ -124,7 +116,7 @@ class BulkDisconnectView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View)
|
||||
|
||||
if form.is_valid():
|
||||
|
||||
with transaction.atomic():
|
||||
with transaction.atomic(using=router.db_for_write(Cable)):
|
||||
count = 0
|
||||
cable_ids = set()
|
||||
for obj in self.queryset.filter(pk__in=form.cleaned_data['pk']):
|
||||
@@ -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 {
|
||||
@@ -3746,7 +3751,7 @@ class VirtualChassisEditView(ObjectPermissionRequiredMixin, GetReturnURLMixin, V
|
||||
|
||||
if vc_form.is_valid() and formset.is_valid():
|
||||
|
||||
with transaction.atomic():
|
||||
with transaction.atomic(using=router.db_for_write(Device)):
|
||||
|
||||
# Save the VirtualChassis
|
||||
vc_form.save()
|
||||
@@ -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)
|
||||
|
||||
@@ -66,11 +66,11 @@ class ScriptInputSerializer(serializers.Serializer):
|
||||
interval = serializers.IntegerField(required=False, allow_null=True)
|
||||
|
||||
def validate_schedule_at(self, value):
|
||||
if value and not self.context['script'].scheduling_enabled:
|
||||
if value and not self.context['script'].python_class.scheduling_enabled:
|
||||
raise serializers.ValidationError(_("Scheduling is not enabled for this script."))
|
||||
return value
|
||||
|
||||
def validate_interval(self, value):
|
||||
if value and not self.context['script'].scheduling_enabled:
|
||||
if value and not self.context['script'].python_class.scheduling_enabled:
|
||||
raise serializers.ValidationError(_("Scheduling is not enabled for this script."))
|
||||
return value
|
||||
|
||||
@@ -270,6 +270,7 @@ class ScriptViewSet(ModelViewSet):
|
||||
module_name, script_name = pk.split('.', maxsplit=1)
|
||||
except ValueError:
|
||||
raise Http404
|
||||
|
||||
return get_object_or_404(self.queryset, module__file_path=f'{module_name}.py', name=script_name)
|
||||
|
||||
def retrieve(self, request, pk):
|
||||
|
||||
@@ -238,10 +238,18 @@ class TagImportForm(CSVModelForm):
|
||||
label=_('Weight'),
|
||||
required=False
|
||||
)
|
||||
object_types = CSVMultipleContentTypeField(
|
||||
label=_('Object types'),
|
||||
queryset=ObjectType.objects.with_feature('tags'),
|
||||
help_text=_("One or more assigned object types"),
|
||||
required=False,
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Tag
|
||||
fields = ('name', 'slug', 'color', 'weight', 'description')
|
||||
fields = (
|
||||
'name', 'slug', 'color', 'weight', 'description', 'object_types',
|
||||
)
|
||||
|
||||
|
||||
class JournalEntryImportForm(NetBoxModelImportForm):
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import os
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
from django.core.files.storage import storages
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from core.choices import JobIntervalChoices
|
||||
from core.forms import ManagedFileForm
|
||||
from extras.storage import ScriptFileSystemStorage
|
||||
from django import forms
|
||||
from django.core.files.storage import storages
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from utilities.datetime import local_now
|
||||
from utilities.forms.widgets import DateTimePicker, NumberWithOptions
|
||||
|
||||
@@ -74,12 +69,7 @@ class ScriptFileForm(ManagedFileForm):
|
||||
storage = storages.create_storage(storages.backends["scripts"])
|
||||
|
||||
filename = self.cleaned_data['upload_file'].name
|
||||
if isinstance(storage, ScriptFileSystemStorage):
|
||||
full_path = os.path.join(settings.SCRIPTS_ROOT, filename)
|
||||
else:
|
||||
full_path = filename
|
||||
|
||||
self.instance.file_path = full_path
|
||||
self.instance.file_path = filename
|
||||
data = self.cleaned_data['upload_file']
|
||||
storage.save(filename, data)
|
||||
|
||||
|
||||
@@ -39,6 +39,9 @@ class ScriptJob(JobRunner):
|
||||
|
||||
try:
|
||||
try:
|
||||
# A script can modify multiple models so need to do an atomic lock on
|
||||
# both the default database (for non ChangeLogged models) and potentially
|
||||
# any other database (for ChangeLogged models)
|
||||
with transaction.atomic():
|
||||
script.output = script.run(data, commit)
|
||||
if not commit:
|
||||
@@ -87,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:
|
||||
@@ -97,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.
|
||||
|
||||
56
netbox/extras/migrations/0129_fix_script_paths.py
Normal file
56
netbox/extras/migrations/0129_fix_script_paths.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from django.conf import settings
|
||||
from django.core.files.storage import storages
|
||||
from django.db import migrations
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from extras.storage import ScriptFileSystemStorage
|
||||
|
||||
|
||||
def normalize(url):
|
||||
parsed_url = urlparse(url)
|
||||
if not parsed_url.path.endswith('/'):
|
||||
return url + '/'
|
||||
return url
|
||||
|
||||
|
||||
def fix_script_paths(apps, schema_editor):
|
||||
"""
|
||||
Fix script paths for scripts that had incorrect path from NB 4.3.
|
||||
"""
|
||||
storage = storages.create_storage(storages.backends["scripts"])
|
||||
if not isinstance(storage, ScriptFileSystemStorage):
|
||||
return
|
||||
|
||||
ScriptModule = apps.get_model('extras', 'ScriptModule')
|
||||
script_root_path = normalize(settings.SCRIPTS_ROOT)
|
||||
for script in ScriptModule.objects.filter(file_path__startswith=script_root_path):
|
||||
script.file_path = script.file_path[len(script_root_path):]
|
||||
script.save()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('extras', '0128_tableconfig'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(code=fix_script_paths, reverse_code=migrations.RunPython.noop),
|
||||
]
|
||||
|
||||
|
||||
def oc_fix_script_paths(objectchange, reverting):
|
||||
script_root_path = normalize(settings.SCRIPTS_ROOT)
|
||||
|
||||
for data in (objectchange.prechange_data, objectchange.postchange_data):
|
||||
if data is None:
|
||||
continue
|
||||
|
||||
if file_path := data.get('file_path'):
|
||||
if file_path.startswith(script_root_path):
|
||||
data['file_path'] = file_path[len(script_root_path):]
|
||||
|
||||
|
||||
objectchange_migrators = {
|
||||
'extras.scriptmodule': oc_fix_script_paths,
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
from functools import cached_property
|
||||
|
||||
from django.conf import settings
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db import models
|
||||
from django.urls import reverse
|
||||
@@ -144,6 +144,12 @@ class NotificationGroup(ChangeLoggedModel):
|
||||
blank=True,
|
||||
related_name='notification_groups'
|
||||
)
|
||||
event_rules = GenericRelation(
|
||||
to='extras.EventRule',
|
||||
content_type_field='action_object_type',
|
||||
object_id_field='action_object_id',
|
||||
related_query_name='+'
|
||||
)
|
||||
|
||||
objects = RestrictedQuerySet.as_manager()
|
||||
|
||||
|
||||
@@ -444,6 +444,8 @@ class TagTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
|
||||
site_ct = ContentType.objects.get_for_model(Site)
|
||||
|
||||
tags = (
|
||||
Tag(name='Tag 1', slug='tag-1'),
|
||||
Tag(name='Tag 2', slug='tag-2', weight=1),
|
||||
@@ -456,14 +458,15 @@ class TagTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
|
||||
'slug': 'tag-x',
|
||||
'color': 'c0c0c0',
|
||||
'comments': 'Some comments',
|
||||
'object_types': [site_ct.pk],
|
||||
'weight': 11,
|
||||
}
|
||||
|
||||
cls.csv_data = (
|
||||
"name,slug,color,description,weight",
|
||||
"Tag 4,tag-4,ff0000,Fourth tag,0",
|
||||
"Tag 5,tag-5,00ff00,Fifth tag,1111",
|
||||
"Tag 6,tag-6,0000ff,Sixth tag,0",
|
||||
"name,slug,color,description,object_types,weight",
|
||||
"Tag 4,tag-4,ff0000,Fourth tag,dcim.interface,0",
|
||||
"Tag 5,tag-5,00ff00,Fifth tag,'dcim.device,dcim.site',1111",
|
||||
"Tag 6,tag-6,0000ff,Sixth tag,dcim.site,0",
|
||||
)
|
||||
|
||||
cls.csv_update_data = (
|
||||
|
||||
@@ -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)
|
||||
@@ -1476,7 +1514,16 @@ class ScriptResultView(TableMixin, generic.ObjectView):
|
||||
table = None
|
||||
job = get_object_or_404(Job.objects.all(), pk=kwargs.get('job_pk'))
|
||||
|
||||
if job.completed:
|
||||
# If a direct export output has been requested, return the job data content as a
|
||||
# downloadable file.
|
||||
if job.completed and request.GET.get('export') == 'output':
|
||||
content = (job.data.get("output") or "").encode()
|
||||
response = HttpResponse(content, content_type='text')
|
||||
filename = f"{job.object.name or 'script-output'}_{job.completed.strftime('%Y-%m-%d_%H%M%S')}.txt"
|
||||
response['Content-Disposition'] = f'attachment; filename="{filename}"'
|
||||
return response
|
||||
|
||||
elif job.completed:
|
||||
table = self.get_table(job, request, bulk_actions=False)
|
||||
|
||||
log_threshold = request.GET.get('log_threshold', LogLevelChoices.LOG_INFO)
|
||||
|
||||
@@ -2,7 +2,7 @@ from copy import deepcopy
|
||||
|
||||
from django.contrib.contenttypes.prefetch import GenericPrefetch
|
||||
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
|
||||
from django.db import transaction
|
||||
from django.db import router, transaction
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils.translation import gettext as _
|
||||
from django_pglocks import advisory_lock
|
||||
@@ -295,7 +295,7 @@ class AvailableObjectsView(ObjectValidationMixin, APIView):
|
||||
|
||||
# Create the new IP address(es)
|
||||
try:
|
||||
with transaction.atomic():
|
||||
with transaction.atomic(using=router.db_for_write(self.queryset.model)):
|
||||
created = serializer.save()
|
||||
self._validate_objects(created)
|
||||
except ObjectDoesNotExist:
|
||||
|
||||
@@ -449,7 +449,7 @@ class PrefixFilterSet(NetBoxModelFilterSet, ScopedFilterSet, TenancyFilterSet, C
|
||||
@extend_schema_field(OpenApiTypes.STR)
|
||||
def filter_present_in_vrf(self, queryset, name, vrf):
|
||||
if vrf is None:
|
||||
return queryset.none
|
||||
return queryset.none()
|
||||
return queryset.filter(
|
||||
Q(vrf=vrf) |
|
||||
Q(vrf__export_targets__in=vrf.import_targets.all())
|
||||
@@ -729,7 +729,7 @@ class IPAddressFilterSet(NetBoxModelFilterSet, TenancyFilterSet, ContactModelFil
|
||||
@extend_schema_field(OpenApiTypes.STR)
|
||||
def filter_present_in_vrf(self, queryset, name, vrf):
|
||||
if vrf is None:
|
||||
return queryset.none
|
||||
return queryset.none()
|
||||
return queryset.filter(
|
||||
Q(vrf=vrf) |
|
||||
Q(vrf__export_targets__in=vrf.import_targets.all())
|
||||
|
||||
@@ -633,7 +633,10 @@ class ServiceImportForm(NetBoxModelImportForm):
|
||||
# triggered
|
||||
parent = self.cleaned_data.get('parent')
|
||||
for ip_address in self.cleaned_data.get('ipaddresses', []):
|
||||
if not ip_address.assigned_object or getattr(ip_address.assigned_object, 'parent_object') != parent:
|
||||
if not (assigned := ip_address.assigned_object) or ( # no assigned object
|
||||
(isinstance(parent, FHRPGroup) and assigned != parent) # assigned to FHRPGroup
|
||||
and getattr(assigned, 'parent_object') != parent # assigned to [VM]Interface
|
||||
):
|
||||
raise forms.ValidationError(
|
||||
_("{ip} is not assigned to this parent.").format(ip=ip_address)
|
||||
)
|
||||
|
||||
@@ -826,7 +826,7 @@ class ServiceForm(NetBoxModelForm):
|
||||
except ObjectDoesNotExist:
|
||||
pass
|
||||
|
||||
if self.instance and parent_object_type_id != self.instance.parent_object_type_id:
|
||||
if self.instance and self.instance.pk and parent_object_type_id != self.instance.parent_object_type_id:
|
||||
self.initial['parent'] = None
|
||||
|
||||
def clean(self):
|
||||
|
||||
@@ -11,10 +11,12 @@ from strawberry_django import FilterLookup, DateFilterLookup
|
||||
|
||||
from core.graphql.filter_mixins import BaseObjectTypeFilterMixin, ChangeLogFilterMixin
|
||||
from dcim.graphql.filter_mixins import ScopedFilterMixin
|
||||
from dcim.models import Device
|
||||
from ipam import models
|
||||
from ipam.graphql.filter_mixins import ServiceBaseFilterMixin
|
||||
from netbox.graphql.filter_mixins import NetBoxModelFilterMixin, OrganizationalModelFilterMixin, PrimaryModelFilterMixin
|
||||
from tenancy.graphql.filter_mixins import ContactFilterMixin, TenancyFilterMixin
|
||||
from virtualization.models import VMInterface
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from netbox.graphql.filter_lookups import IntegerArrayLookup, IntegerLookup
|
||||
@@ -116,6 +118,30 @@ class FHRPGroupAssignmentFilter(BaseObjectTypeFilterMixin, ChangeLogFilterMixin)
|
||||
strawberry_django.filter_field()
|
||||
)
|
||||
|
||||
@strawberry_django.filter_field()
|
||||
def device_id(self, queryset, value: list[str], prefix) -> Q:
|
||||
return self.filter_device('id', value)
|
||||
|
||||
@strawberry_django.filter_field()
|
||||
def device(self, value: list[str], prefix) -> Q:
|
||||
return self.filter_device('name', value)
|
||||
|
||||
@strawberry_django.filter_field()
|
||||
def virtual_machine_id(self, value: list[str], prefix) -> Q:
|
||||
return Q(interface_id__in=VMInterface.objects.filter(virtual_machine_id__in=value))
|
||||
|
||||
@strawberry_django.filter_field()
|
||||
def virtual_machine(self, value: list[str], prefix) -> Q:
|
||||
return Q(interface_id__in=VMInterface.objects.filter(virtual_machine__name__in=value))
|
||||
|
||||
def filter_device(self, field, value) -> Q:
|
||||
"""Helper to standardize logic for device and device_id filters"""
|
||||
devices = Device.objects.filter(**{f'{field}__in': value})
|
||||
interface_ids = []
|
||||
for device in devices:
|
||||
interface_ids.extend(device.vc_interfaces().values_list('id', flat=True))
|
||||
return Q(interface_id__in=interface_ids)
|
||||
|
||||
|
||||
@strawberry_django.filter_type(models.IPAddress, lookups=True)
|
||||
class IPAddressFilter(ContactFilterMixin, TenancyFilterMixin, PrimaryModelFilterMixin):
|
||||
|
||||
@@ -1068,6 +1068,9 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
|
||||
device = Device.objects.create(name='Device 1', site=site, device_type=devicetype, role=role)
|
||||
interface = Interface.objects.create(device=device, name='Interface 1', type=InterfaceTypeChoices.TYPE_VIRTUAL)
|
||||
fhrp_group = FHRPGroup.objects.create(
|
||||
name='Group 1', group_id=1234, protocol=FHRPGroupProtocolChoices.PROTOCOL_CARP
|
||||
)
|
||||
|
||||
services = (
|
||||
Service(parent=device, name='Service 1', protocol=ServiceProtocolChoices.PROTOCOL_TCP, ports=[101]),
|
||||
@@ -1079,6 +1082,7 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
ip_addresses = (
|
||||
IPAddress(assigned_object=interface, address='192.0.2.1/24'),
|
||||
IPAddress(assigned_object=interface, address='192.0.2.2/24'),
|
||||
IPAddress(assigned_object=fhrp_group, address='192.0.2.3/24'),
|
||||
)
|
||||
IPAddress.objects.bulk_create(ip_addresses)
|
||||
|
||||
@@ -1100,6 +1104,7 @@ class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||
"dcim.device,Device 1,Service 1,tcp,1,192.0.2.1/24,First service",
|
||||
"dcim.device,Device 1,Service 2,tcp,2,192.0.2.2/24,Second service",
|
||||
"dcim.device,Device 1,Service 3,udp,3,,Third service",
|
||||
"ipam.fhrpgroup,Group 1,Service 4,udp,4,192.0.2.3/24,Fourth service",
|
||||
)
|
||||
|
||||
cls.csv_update_data = (
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -2,7 +2,7 @@ import logging
|
||||
from functools import cached_property
|
||||
|
||||
from django.core.exceptions import ObjectDoesNotExist, PermissionDenied
|
||||
from django.db import transaction
|
||||
from django.db import router, transaction
|
||||
from django.db.models import ProtectedError, RestrictedError
|
||||
from django_pglocks import advisory_lock
|
||||
from netbox.constants import ADVISORY_LOCK_KEYS
|
||||
@@ -170,7 +170,7 @@ class NetBoxModelViewSet(
|
||||
|
||||
# Enforce object-level permissions on save()
|
||||
try:
|
||||
with transaction.atomic():
|
||||
with transaction.atomic(using=router.db_for_write(model)):
|
||||
instance = serializer.save()
|
||||
self._validate_objects(instance)
|
||||
except ObjectDoesNotExist:
|
||||
@@ -190,7 +190,7 @@ class NetBoxModelViewSet(
|
||||
|
||||
# Enforce object-level permissions on save()
|
||||
try:
|
||||
with transaction.atomic():
|
||||
with transaction.atomic(using=router.db_for_write(model)):
|
||||
instance = serializer.save()
|
||||
self._validate_objects(instance)
|
||||
except ObjectDoesNotExist:
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
from django.db import transaction
|
||||
from django.db import router, transaction
|
||||
from django.http import Http404
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
@@ -56,22 +56,22 @@ class SequentialBulkCreatesMixin:
|
||||
which depends on the evaluation of existing objects (such as checking for free space within a rack) functions
|
||||
appropriately.
|
||||
"""
|
||||
@transaction.atomic
|
||||
def create(self, request, *args, **kwargs):
|
||||
if not isinstance(request.data, list):
|
||||
# Creating a single object
|
||||
return super().create(request, *args, **kwargs)
|
||||
with transaction.atomic(using=router.db_for_write(self.queryset.model)):
|
||||
if not isinstance(request.data, list):
|
||||
# Creating a single object
|
||||
return super().create(request, *args, **kwargs)
|
||||
|
||||
return_data = []
|
||||
for data in request.data:
|
||||
serializer = self.get_serializer(data=data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
self.perform_create(serializer)
|
||||
return_data.append(serializer.data)
|
||||
return_data = []
|
||||
for data in request.data:
|
||||
serializer = self.get_serializer(data=data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
self.perform_create(serializer)
|
||||
return_data.append(serializer.data)
|
||||
|
||||
headers = self.get_success_headers(serializer.data)
|
||||
headers = self.get_success_headers(serializer.data)
|
||||
|
||||
return Response(return_data, status=status.HTTP_201_CREATED, headers=headers)
|
||||
return Response(return_data, status=status.HTTP_201_CREATED, headers=headers)
|
||||
|
||||
|
||||
class BulkUpdateModelMixin:
|
||||
@@ -113,7 +113,7 @@ class BulkUpdateModelMixin:
|
||||
return Response(data, status=status.HTTP_200_OK)
|
||||
|
||||
def perform_bulk_update(self, objects, update_data, partial):
|
||||
with transaction.atomic():
|
||||
with transaction.atomic(using=router.db_for_write(self.queryset.model)):
|
||||
data_list = []
|
||||
for obj in objects:
|
||||
data = update_data.get(obj.id)
|
||||
@@ -157,7 +157,7 @@ class BulkDestroyModelMixin:
|
||||
return Response(status=status.HTTP_204_NO_CONTENT)
|
||||
|
||||
def perform_bulk_destroy(self, objects):
|
||||
with transaction.atomic():
|
||||
with transaction.atomic(using=router.db_for_write(self.queryset.model)):
|
||||
for obj in objects:
|
||||
if hasattr(obj, 'snapshot'):
|
||||
obj.snapshot()
|
||||
|
||||
@@ -231,14 +231,19 @@ SESSION_FILE_PATH = None
|
||||
# DISK_BASE_UNIT = 1024
|
||||
# RAM_BASE_UNIT = 1024
|
||||
|
||||
# By default, uploaded media is stored on the local filesystem. Using Django-storages is also supported. Provide the
|
||||
# class path of the storage driver in STORAGE_BACKEND and any configuration options in STORAGE_CONFIG. For example:
|
||||
# STORAGE_BACKEND = 'storages.backends.s3boto3.S3Boto3Storage'
|
||||
# STORAGE_CONFIG = {
|
||||
# 'AWS_ACCESS_KEY_ID': 'Key ID',
|
||||
# 'AWS_SECRET_ACCESS_KEY': 'Secret',
|
||||
# 'AWS_STORAGE_BUCKET_NAME': 'netbox',
|
||||
# 'AWS_S3_REGION_NAME': 'eu-west-1',
|
||||
# Within the STORAGES dictionary, "default" is used for image uploads, "staticfiles" is for static files and "scripts"
|
||||
# is used for custom scripts. See django-storages and django-storage-swift libraries for more details. By default the
|
||||
# following configuration is used:
|
||||
# STORAGES = {
|
||||
# "default": {
|
||||
# "BACKEND": "django.core.files.storage.FileSystemStorage",
|
||||
# },
|
||||
# "staticfiles": {
|
||||
# "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage",
|
||||
# },
|
||||
# "scripts": {
|
||||
# "BACKEND": "extras.storage.ScriptFileSystemStorage",
|
||||
# },
|
||||
# }
|
||||
|
||||
# Time zone (default: UTC)
|
||||
|
||||
@@ -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'},
|
||||
|
||||
@@ -34,6 +34,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 +65,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__)
|
||||
|
||||
90
netbox/netbox/models/deletion.py
Normal file
90
netbox/netbox/models/deletion.py
Normal file
@@ -0,0 +1,90 @@
|
||||
import logging
|
||||
|
||||
from django.contrib.contenttypes.fields import GenericRelation
|
||||
from django.db import router
|
||||
from django.db.models.deletion import Collector
|
||||
|
||||
logger = logging.getLogger("netbox.models.deletion")
|
||||
|
||||
|
||||
class CustomCollector(Collector):
|
||||
"""
|
||||
Custom collector that handles GenericRelations correctly.
|
||||
"""
|
||||
|
||||
def collect(
|
||||
self,
|
||||
objs,
|
||||
source=None,
|
||||
nullable=False,
|
||||
collect_related=True,
|
||||
source_attr=None,
|
||||
reverse_dependency=False,
|
||||
keep_parents=False,
|
||||
fail_on_restricted=True,
|
||||
):
|
||||
"""
|
||||
Override collect to first collect standard dependencies,
|
||||
then add GenericRelations to the dependency graph.
|
||||
"""
|
||||
# Call parent collect first to get all standard dependencies
|
||||
super().collect(
|
||||
objs,
|
||||
source=source,
|
||||
nullable=nullable,
|
||||
collect_related=collect_related,
|
||||
source_attr=source_attr,
|
||||
reverse_dependency=reverse_dependency,
|
||||
keep_parents=keep_parents,
|
||||
fail_on_restricted=fail_on_restricted,
|
||||
)
|
||||
|
||||
# Track which GenericRelations we've already processed to prevent infinite recursion
|
||||
processed_relations = set()
|
||||
|
||||
# Now add GenericRelations to the dependency graph
|
||||
for _, instances in list(self.data.items()):
|
||||
for instance in instances:
|
||||
# Get all GenericRelations for this model
|
||||
for field in instance._meta.private_fields:
|
||||
if isinstance(field, GenericRelation):
|
||||
# Create a unique key for this relation
|
||||
relation_key = f"{instance._meta.model_name}.{field.name}"
|
||||
if relation_key in processed_relations:
|
||||
continue
|
||||
processed_relations.add(relation_key)
|
||||
|
||||
# Add the model that the generic relation points to as a dependency
|
||||
self.add_dependency(field.related_model, instance, reverse_dependency=True)
|
||||
|
||||
|
||||
class DeleteMixin:
|
||||
"""
|
||||
Mixin to override the model delete function to use our custom collector.
|
||||
"""
|
||||
|
||||
def delete(self, using=None, keep_parents=False):
|
||||
"""
|
||||
Override delete to use our custom collector.
|
||||
"""
|
||||
using = using or router.db_for_write(self.__class__, instance=self)
|
||||
assert self._get_pk_val() is not None, "%s object can't be deleted because its %s attribute is set to None." % (
|
||||
self._meta.object_name,
|
||||
self._meta.pk.attname,
|
||||
)
|
||||
|
||||
collector = CustomCollector(using=using)
|
||||
collector.collect([self], keep_parents=keep_parents)
|
||||
|
||||
return collector.delete()
|
||||
|
||||
delete.alters_data = True
|
||||
|
||||
@classmethod
|
||||
def verify_mro(cls, instance):
|
||||
"""
|
||||
Verify that this mixin is first in the MRO.
|
||||
"""
|
||||
mro = instance.__class__.__mro__
|
||||
if mro.index(cls) != 0:
|
||||
raise RuntimeError(f"{cls.__name__} must be first in the MRO. Current MRO: {mro}")
|
||||
@@ -16,6 +16,7 @@ from extras.choices import *
|
||||
from extras.constants import CUSTOMFIELD_EMPTY_VALUES
|
||||
from extras.utils import is_taggable
|
||||
from netbox.config import get_config
|
||||
from netbox.models.deletion import DeleteMixin
|
||||
from netbox.registry import registry
|
||||
from netbox.signals import post_clean
|
||||
from utilities.json import CustomFieldJSONEncoder
|
||||
@@ -45,7 +46,7 @@ __all__ = (
|
||||
# Feature mixins
|
||||
#
|
||||
|
||||
class ChangeLoggingMixin(models.Model):
|
||||
class ChangeLoggingMixin(DeleteMixin, models.Model):
|
||||
"""
|
||||
Provides change logging support for a model. Adds the `created` and `last_updated` fields.
|
||||
"""
|
||||
|
||||
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'
|
||||
@@ -54,6 +54,14 @@ PREFERENCES = {
|
||||
default='bottom',
|
||||
description=_('Where the paginator controls will be displayed relative to a table')
|
||||
),
|
||||
'ui.tables.striping': UserPreference(
|
||||
label=_('Striped table rows'),
|
||||
choices=(
|
||||
('', _('Disabled')),
|
||||
('true', _('Enabled')),
|
||||
),
|
||||
description=_('Render table rows with alternating colors to increase readability'),
|
||||
),
|
||||
|
||||
# Miscellaneous
|
||||
'data_format': UserPreference(
|
||||
|
||||
@@ -66,6 +66,9 @@ class BaseTable(tables.Table):
|
||||
if column.visible:
|
||||
model = getattr(self.Meta, 'model')
|
||||
accessor = column.accessor
|
||||
if accessor.startswith('custom_field_data__'):
|
||||
# Ignore custom field references
|
||||
continue
|
||||
prefetch_path = []
|
||||
for field_name in accessor.split(accessor.SEPARATOR):
|
||||
try:
|
||||
@@ -163,6 +166,8 @@ class BaseTable(tables.Table):
|
||||
columns = userconfig.get(f"tables.{self.name}.columns")
|
||||
if ordering is None:
|
||||
ordering = userconfig.get(f"tables.{self.name}.ordering")
|
||||
if userconfig.get("ui.tables.striping"):
|
||||
self.attrs['class'] += ' table-striped'
|
||||
|
||||
# Fall back to the default columns & ordering
|
||||
if columns is None and hasattr(settings, 'DEFAULT_USER_PREFERENCES'):
|
||||
|
||||
@@ -11,7 +11,10 @@ from core.choices import JobStatusChoices
|
||||
|
||||
class TestJobRunner(JobRunner):
|
||||
def run(self, *args, **kwargs):
|
||||
pass
|
||||
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 +50,16 @@ 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_errored(self):
|
||||
class ErroredJobRunner(TestJobRunner):
|
||||
EXP = Exception('Test error')
|
||||
|
||||
@@ -6,7 +6,7 @@ from django.contrib import messages
|
||||
from django.contrib.contenttypes.fields import GenericForeignKey, GenericRel
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.core.exceptions import FieldDoesNotExist, ObjectDoesNotExist, ValidationError
|
||||
from django.db import transaction, IntegrityError
|
||||
from django.db import IntegrityError, router, transaction
|
||||
from django.db.models import ManyToManyField, ProtectedError, RestrictedError
|
||||
from django.db.models.fields.reverse_related import ManyToManyRel
|
||||
from django.forms import ModelMultipleChoiceField, MultipleHiddenInput
|
||||
@@ -22,6 +22,7 @@ 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.forms import BulkRenameForm, ConfirmationForm, restrict_form_fields
|
||||
@@ -54,12 +55,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')
|
||||
@@ -150,13 +151,13 @@ 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)
|
||||
|
||||
@@ -174,11 +175,11 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
|
||||
|
||||
# Fall back to default table/YAML export
|
||||
else:
|
||||
table = self.get_table(self.queryset, request, has_bulk_actions)
|
||||
table = self.get_table(self.queryset, request, has_table_actions)
|
||||
return self.export_table(table)
|
||||
|
||||
# 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):
|
||||
@@ -278,7 +279,7 @@ class BulkCreateView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
logger.debug("Form validation was successful")
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
with transaction.atomic(using=router.db_for_write(model)):
|
||||
new_objs = self._create_objects(form, request)
|
||||
|
||||
# Enforce object-level permissions
|
||||
@@ -501,7 +502,7 @@ class BulkImportView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
|
||||
try:
|
||||
# Iterate through data and bind each record to a new model form instance.
|
||||
with transaction.atomic():
|
||||
with transaction.atomic(using=router.db_for_write(model)):
|
||||
new_objs = self.create_and_update_objects(form, request)
|
||||
|
||||
# Enforce object-level permissions
|
||||
@@ -681,7 +682,7 @@ class BulkEditView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
if form.is_valid():
|
||||
logger.debug("Form validation was successful")
|
||||
try:
|
||||
with transaction.atomic():
|
||||
with transaction.atomic(using=router.db_for_write(model)):
|
||||
updated_objects = self._update_objects(form, request)
|
||||
|
||||
# Enforce object-level permissions
|
||||
@@ -729,7 +730,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 +764,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
|
||||
@@ -778,12 +783,12 @@ class BulkRenameView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
|
||||
if form.is_valid():
|
||||
try:
|
||||
with transaction.atomic():
|
||||
with transaction.atomic(using=router.db_for_write(self.queryset.model)):
|
||||
renamed_pks = self._rename_objects(form, selected_objects)
|
||||
|
||||
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 +818,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,
|
||||
@@ -875,7 +881,7 @@ class BulkDeleteView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
queryset = self.queryset.filter(pk__in=pk_list)
|
||||
deleted_count = queryset.count()
|
||||
try:
|
||||
with transaction.atomic():
|
||||
with transaction.atomic(using=router.db_for_write(model)):
|
||||
for obj in queryset:
|
||||
# Take a snapshot of change-logged models
|
||||
if hasattr(obj, 'snapshot'):
|
||||
@@ -980,7 +986,7 @@ class BulkComponentCreateView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
}
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
with transaction.atomic(using=router.db_for_write(self.queryset.model)):
|
||||
|
||||
for obj in data['pk']:
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from django.contrib.auth.mixins import LoginRequiredMixin
|
||||
from django.contrib.contenttypes.models import ContentType
|
||||
from django.contrib import messages
|
||||
from django.db import transaction
|
||||
from django.db import router, transaction
|
||||
from django.db.models import Q
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
@@ -240,7 +240,7 @@ class BulkSyncDataView(GetReturnURLMixin, BaseMultiObjectView):
|
||||
data_file__isnull=False
|
||||
)
|
||||
|
||||
with transaction.atomic():
|
||||
with transaction.atomic(using=router.db_for_write(self.queryset.model)):
|
||||
for obj in selected_objects:
|
||||
obj.sync(save=True)
|
||||
|
||||
|
||||
@@ -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):
|
||||
@@ -282,7 +289,7 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView):
|
||||
logger.debug("Form validation was successful")
|
||||
|
||||
try:
|
||||
with transaction.atomic():
|
||||
with transaction.atomic(using=router.db_for_write(model)):
|
||||
object_created = form.instance.pk is None
|
||||
obj = form.save()
|
||||
|
||||
@@ -570,7 +577,7 @@ class ComponentCreateView(GetReturnURLMixin, BaseObjectView):
|
||||
|
||||
if not form.errors and not component_form.errors:
|
||||
try:
|
||||
with transaction.atomic():
|
||||
with transaction.atomic(using=router.db_for_write(self.queryset.model)):
|
||||
# Create the new components
|
||||
new_objs = []
|
||||
for component_form in new_components:
|
||||
|
||||
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
6
netbox/project-static/dist/netbox.js.map
vendored
6
netbox/project-static/dist/netbox.js.map
vendored
File diff suppressed because one or more lines are too long
@@ -24,13 +24,13 @@
|
||||
"dependencies": {
|
||||
"@mdi/font": "7.4.47",
|
||||
"@tabler/core": "1.3.2",
|
||||
"bootstrap": "5.3.6",
|
||||
"bootstrap": "5.3.7",
|
||||
"clipboard": "2.0.11",
|
||||
"flatpickr": "4.6.13",
|
||||
"gridstack": "12.2.1",
|
||||
"htmx.org": "2.0.4",
|
||||
"query-string": "9.2.0",
|
||||
"sass": "1.89.1",
|
||||
"htmx.org": "2.0.5",
|
||||
"query-string": "9.2.1",
|
||||
"sass": "1.89.2",
|
||||
"tom-select": "2.4.3",
|
||||
"typeface-inter": "3.18.1",
|
||||
"typeface-roboto-mono": "1.1.13"
|
||||
|
||||
@@ -1058,6 +1058,11 @@ bootstrap@5.3.6:
|
||||
resolved "https://registry.yarnpkg.com/bootstrap/-/bootstrap-5.3.6.tgz#fbd91ebaff093f5b191a1c01a8c866d24f9fa6e1"
|
||||
integrity sha512-jX0GAcRzvdwISuvArXn3m7KZscWWFAf1MKBcnzaN02qWMb3jpMoUX4/qgeiGzqyIb4ojulRzs89UCUmGcFSzTA==
|
||||
|
||||
bootstrap@5.3.7:
|
||||
version "5.3.7"
|
||||
resolved "https://registry.yarnpkg.com/bootstrap/-/bootstrap-5.3.7.tgz#8640065036124d961d885d80b5945745e1154d90"
|
||||
integrity sha512-7KgiD8UHjfcPBHEpDNg+zGz8L3LqR3GVwqZiBRFX04a1BCArZOz1r2kjly2HQ0WokqTO0v1nF+QAt8dsW4lKlw==
|
||||
|
||||
brace-expansion@^1.1.7:
|
||||
version "1.1.11"
|
||||
resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"
|
||||
@@ -1954,10 +1959,10 @@ hey-listen@^1.0.8:
|
||||
resolved "https://registry.yarnpkg.com/hey-listen/-/hey-listen-1.0.8.tgz#8e59561ff724908de1aa924ed6ecc84a56a9aa68"
|
||||
integrity sha512-COpmrF2NOg4TBWUJ5UVyaCU2A88wEMkUPK4hNqyCkqHbxT92BbvfjoSozkAIIm6XhicGlJHhFdullInrdhwU8Q==
|
||||
|
||||
htmx.org@2.0.4:
|
||||
version "2.0.4"
|
||||
resolved "https://registry.yarnpkg.com/htmx.org/-/htmx.org-2.0.4.tgz#74fce66b177eb59c6d251ecf1052a2478743bec9"
|
||||
integrity sha512-HLxMCdfXDOJirs3vBZl/ZLoY+c7PfM4Ahr2Ad4YXh6d22T5ltbTXFFkpx9Tgb2vvmWFMbIc3LqN2ToNkZJvyYQ==
|
||||
htmx.org@2.0.5:
|
||||
version "2.0.5"
|
||||
resolved "https://registry.yarnpkg.com/htmx.org/-/htmx.org-2.0.5.tgz#88e8d89078b3059d74ac4eb653d80451c144820c"
|
||||
integrity sha512-ocgvtHCShWFW0DvSV1NbJC7Y5EzUMy2eo5zeWvGj2Ac4LOr7sv9YKg4jzCZJdXN21fXACmCViwKSy+cm6i2dWQ==
|
||||
|
||||
ignore@^5.2.0, ignore@^5.3.1:
|
||||
version "5.3.2"
|
||||
@@ -2514,10 +2519,10 @@ punycode@^2.1.0:
|
||||
resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.3.1.tgz#027422e2faec0b25e1549c3e1bd8309b9133b6e5"
|
||||
integrity sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==
|
||||
|
||||
query-string@9.2.0:
|
||||
version "9.2.0"
|
||||
resolved "https://registry.yarnpkg.com/query-string/-/query-string-9.2.0.tgz#bf9909412689117865aac4e05c10422c4839828f"
|
||||
integrity sha512-YIRhrHujoQxhexwRLxfy3VSjOXmvZRd2nyw1PwL1UUqZ/ys1dEZd1+NSgXkne2l/4X/7OXkigEAuhTX0g/ivJQ==
|
||||
query-string@9.2.1:
|
||||
version "9.2.1"
|
||||
resolved "https://registry.yarnpkg.com/query-string/-/query-string-9.2.1.tgz#67bd95f6e2cb64eafecfb0504be7cc38bcd4dd11"
|
||||
integrity sha512-3jTGGLRzlhu/1ws2zlr4Q+GVMLCQTLFOj8CMX5x44cdZG9FQE07x2mQhaNxaKVPNmIDu0mvJ/cEwtY7Pim7hqA==
|
||||
dependencies:
|
||||
decode-uri-component "^0.4.1"
|
||||
filter-obj "^5.1.0"
|
||||
@@ -2660,10 +2665,10 @@ safe-regex-test@^1.0.3:
|
||||
es-errors "^1.3.0"
|
||||
is-regex "^1.1.4"
|
||||
|
||||
sass@1.89.1:
|
||||
version "1.89.1"
|
||||
resolved "https://registry.yarnpkg.com/sass/-/sass-1.89.1.tgz#9281c52c85b4be54264d310fef63a811dfcfb9d9"
|
||||
integrity sha512-eMLLkl+qz7tx/0cJ9wI+w09GQ2zodTkcE/aVfywwdlRcI3EO19xGnbmJwg/JMIm+5MxVJ6outddLZ4Von4E++Q==
|
||||
sass@1.89.2:
|
||||
version "1.89.2"
|
||||
resolved "https://registry.yarnpkg.com/sass/-/sass-1.89.2.tgz#a771716aeae774e2b529f72c0ff2dfd46c9de10e"
|
||||
integrity sha512-xCmtksBKd/jdJ9Bt9p7nPKiuqrlBMBuuGkQlkhZjjQk3Ty48lv93k5Dq6OPkKt4XwxDJ7tvlfrTa1MPA9bf+QA==
|
||||
dependencies:
|
||||
chokidar "^4.0.0"
|
||||
immutable "^5.0.2"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
version: "4.3.2"
|
||||
version: "4.3.3"
|
||||
edition: "Community"
|
||||
published: "2025-06-05"
|
||||
published: "2025-06-26"
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
</div>
|
||||
{% elif perms.dcim.add_cable %}
|
||||
<div class="dropdown">
|
||||
<button type="button" class="btn btn-success dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
<button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
|
||||
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span> {% trans "Connect" %}
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
|
||||
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 %}
|
||||
@@ -65,7 +65,7 @@
|
||||
{% trans "Not Connected" %}
|
||||
{% if perms.dcim.add_cable %}
|
||||
<div class="dropdown float-end">
|
||||
<button type="button" class="btn btn-primary btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span> {% trans "Connect" %}
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
|
||||
@@ -65,7 +65,7 @@
|
||||
{% trans "Not Connected" %}
|
||||
{% if perms.dcim.add_cable %}
|
||||
<div class="dropdown float-end">
|
||||
<button type="button" class="btn btn-primary btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
|
||||
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span> {% trans "Connect" %}
|
||||
</button>
|
||||
<ul class="dropdown-menu dropdown-menu-end">
|
||||
|
||||
@@ -308,7 +308,7 @@
|
||||
{% trans "Services" %}
|
||||
{% if perms.ipam.add_service %}
|
||||
<div class="card-actions">
|
||||
<a href="{% url 'ipam:service_add' %}?device={{ object.pk }}" class="btn btn-ghost-primary btn-sm">
|
||||
<a href="{% url 'ipam:service_add' %}?parent_object_type={{ object|content_type_id }}&parent={{ object.pk }}" class="btn btn-ghost-primary btn-sm">
|
||||
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Add a service" %}
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@@ -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,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 %}
|
||||
|
||||
@@ -118,10 +118,6 @@
|
||||
{% else %}
|
||||
<div class="card-body text-muted">
|
||||
{% trans "Not connected" %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% if not object.mark_connected and not object.cable %}
|
||||
<div class="card-footer">
|
||||
{% if perms.dcim.add_cable %}
|
||||
<a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.powerfeed&a_terminations={{ object.pk }}&b_terminations_type=dcim.powerport&return_url={{ object.get_absolute_url }}" class="btn btn-primary float-end">
|
||||
<i class="mdi mdi-ethernet-cable" aria-hidden="true"></i> {% trans "Connect" %}
|
||||
|
||||
@@ -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 %}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user