Compare commits

...

43 Commits

Author SHA1 Message Date
Jeremy Stretch
a3ee46a32a Update documentation
Some checks failed
CI / build (20.x, 3.10) (push) Has been cancelled
CI / build (20.x, 3.11) (push) Has been cancelled
CI / build (20.x, 3.12) (push) Has been cancelled
2025-07-09 10:36:18 -04:00
Jeremy Stretch
eb86b9d6b0 Refactor HTML templates 2025-07-09 10:25:44 -04:00
Jeremy Stretch
37fb8ae8ae Test logging 2025-07-09 10:19:44 -04:00
Jeremy Stretch
6b2643c9cd Misc cleanup 2025-07-09 10:09:31 -04:00
Jeremy Stretch
9946ff910c Repurpose RQJobStatusColumn to display job entry level badges 2025-07-09 10:00:49 -04:00
Jeremy Stretch
e95c3825de Deserialize JobLogEntry timestamp 2025-07-09 09:30:38 -04:00
Jeremy Stretch
92a13a4226 Use TZ-aware timestamps 2025-07-09 08:51:03 -04:00
Jeremy Stretch
ae3de95dce Initial work on #19816 2025-07-08 16:56:52 -04:00
Jeremy Stretch
a1cd81ff35 Closes #17413: Permit identical names for platforms belonging to different manufacturers (#19814) 2025-07-07 10:38:01 -07:00
Jeremy Stretch
ce12de8b6d Closes #19231: Add bulk renaming support for all models (#19795)
* Closes #19231: Add bulk renaming support for all models

* Introduce a template filter for getattr()

* Extend BulkRenameView to support arbitrary field names

* Address bulk renaming support for remaining models

* Bulk rename URL resolution should fail silently

* Update documentation

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

* Work in progress

* Remove ClusterRemoveDevicesView (anti-pattern)

* Misc cleanup

* Fix has_bulk_actions

* Fix has_bulk_actions for ObjectChildrenView

* Restore clone button

* Misc cleanup

* Clean up custom bulk actions

* Rename individual object actions

* Collapse into a single template tag

* Fix support for legacy action dicts

* Rename bulk attr to multi

* clone_button tag should fail silently if view name is invalid

* Clean up action buttons

* Fix export button label

* Replace clone_button with an ObjectAction

* Create object actions for adding device/VM components

* Move core_sync.html to core app

* Remove extra_bulk_buttons from template doc
2025-06-30 13:03:07 -04:00
Jeremy Stretch
71e6ea5785 Release v4.3.3 2025-06-26 14:42:03 -04:00
Jason Novinger
0a9887b42f Fixes #19745: properly check IP assignment to FHRPGroup
- Expands the logic in ServiceImportForm.clean() to handle properly
  validation of FHRPGroup assignments and maintain the existing
  [VM]Interface validation checks.
- Includes an extension to ServiceTestCase.csv_data to act as a
  regression test for this behavior.
2025-06-26 12:09:14 -04:00
Tobias Genannt
3ecf29d797 Fixes #17719: User settings for table stripe (#19526)
* Fixes #17719: User setting table stripe

* Tweak user preference name

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2025-06-26 12:03:17 -04:00
Jason Novinger
c48e4f590e Fixes #19640: restores device/vm FHRPGroupAssignment graphql filters (#19712)
* Fixes #19640: restores device/vm FHRPGroupAssignment graphql filters

* Add docstring for device_filter helper function
2025-06-26 12:00:56 -04:00
github-actions
aee83a434a Update source translation strings 2025-06-26 05:02:35 +00:00
Arthur Hanson
a17699d261 19644 Make atomic use correct database instead of default (#19651)
* 19644 set atomic transactions to appropriate database

* 19644 set atomic transactions for Job Script run

* 19644 set atomic transactions to appropriate database

* 19644 set atomic transactions to appropriate database

* 19644 fix review comments

* 19644 fix review comments
2025-06-25 15:00:26 -04:00
Jeremy Stretch
f97d07a11c Update README & contributing guide (#19727) 2025-06-20 07:56:45 -07:00
github-actions
1fd3d390ae Update source translation strings 2025-06-20 05:02:37 +00:00
Omripresent
7dab7d730d Fixes: #19492: Add Save Button to Script Output Window (#19721)
* Add condition to ScriptResultView.get function to generate a download
file of job output if job is completed

* Update template script_result.html adding a download button to trigger
output download in ScriptResultView.get

* Simplify conditional logic; tweak timestamp format

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
2025-06-19 13:31:54 -04:00
Jason Novinger
c660f1c019 Fixes #19702: add NotificationGroup.event_rules GenericRelation
The collector we use to notify users about dependent object that will be
deleted does handle GFKs. However, a GenericRelation must be set up on
the other end.
2025-06-19 09:41:40 -04:00
github-actions
334b45f55a Update source translation strings 2025-06-17 05:02:05 +00:00
Martin Hauser
e6c1cebd34 Closes #19499 - Add WirelessLink Bulk Import Support by Device and Interface Names (#19679) 2025-06-16 11:19:56 -07:00
Arthur Hanson
a9af541e81 Fixes #19529: fix CLI running of scripts (#19698)
* 19529 fix custom script path

* 19529 fix custom script path

* 19529 fix custom script path

* 19529 fix custom script path

* 19680 add object_change migrator

* 19680 optimize migration

* 19680 optimize migration
2025-06-16 07:17:38 -05:00
github-actions
f706572113 Update source translation strings 2025-06-14 05:02:08 +00:00
Arthur Hanson
6a6286777c Fixes #19680 fix deletion dependency order for GenericRelations (#19681)
* #19680 fix deletion dependency order for GenericRelations

* 19680 add test

* 19680 fix Collector and test

* 19680 put on changeloggingmixin

* 19680 cleanup

* 19680 cleanup

* 19680 cleanup

* 19680 skip changelog update for deleted objects

* 19680 remove print
2025-06-13 16:08:59 -05:00
Omripresent
afeddee10d Fixes #19687: Treat cellular interface type as not connectable (#19691)
* Add cellular interface types to WIRELESS_IFACE_TYPES const
Add cable termination test for cellular interface

* Add regression tag to cellular test
2025-06-12 09:49:09 -05:00
Arthur Hanson
a48bee2a2e 19555 fix script API validation for scheduled_at (#19693)
* 19555 fix script API validation for scheduled_at

* 19555 fix script API validation for scheduled_at
2025-06-11 12:41:45 -05:00
github-actions
b9db6ebd63 Update source translation strings 2025-06-11 05:02:55 +00:00
Martin Hauser
9e0493c64c Closes #17183 - Add Object Types Field to Tag Bulk Import Form (#19639) 2025-06-10 09:13:59 -07:00
hblandford
e3509c092a Closes #19684: Update pyproject.toml version to 4.3.2 (#19688)
Co-authored-by: Hugh Blandford <hugh.blandford@gmail.com>
2025-06-10 09:56:55 -05:00
bctiemann
762cfc7d10 Merge pull request #19672 from netbox-community/19659-service-form-initial-data
Fixes #19659: Populate initial device/VM selection for "add a service" button
2025-06-10 08:49:23 -04:00
bctiemann
522f80ed9d Merge pull request #19642 from pheus/17420-add-plugins-content-type-removal-instructions
Closes #17420 - Add Instructions for Cleaning up Content Types after Uninstalling a Plugin
2025-06-10 08:39:16 -04:00
github-actions
fd6062de75 Update source translation strings 2025-06-10 05:02:15 +00:00
gizmonicus
c872cce59f Fixes: #19616: configuration_example.py has inaccurate STORAGE_BACKEND examples (#19657) 2025-06-09 11:14:52 -07:00
Jeremy Stretch
dc8267d890 Fixes #19673: Ignore custom field references when compiling table prefetches (#19674) 2025-06-09 11:12:48 -07:00
Aaron
2bfb9f4ed0 Fixes #19617: Inconsistent styling of Connect buttons (#19682) 2025-06-09 10:21:28 -04:00
Martin Hauser
dda0a55e5e fix(ipam): Correct usage of the queryset.none method (#19678)
Ensures the `queryset.none()` method is called properly with
parentheses. This fixes a potential issue where the method would not
execute as intended, improving the stability and correctness of the
filter logic.
2025-06-09 07:45:40 -05:00
Martin Hauser
2680f855ff fix(wireless): Correct validation error field reference
Fixes the reference from `interface_a` to `interface_b` in the
validation error message for WirelessLink. Ensures the correct field is
indicated during validation errors.
2025-06-06 15:27:06 -04:00
Jeremy Stretch
6ca791850a Closes #19668: Remove obsolete docs publication step from release checklist (#19675) 2025-06-06 13:26:43 -05:00
Jeremy Stretch
43df06f210 Fixes #19667: Fix TypeError exception when creating a new module profile type with no schema (#19671) 2025-06-06 13:25:19 -05:00
Jeremy Stretch
7e6b1bbd79 Fixes #19659: Populate initial device/VM selection for 'add a service' button 2025-06-06 12:26:05 -04:00
Martin Hauser
716acaa657 docs(plugins): Add guide for cleaning up Content Types
Provides instructions for removing stale Content Types and related
Permissions after uninstalling a plugin. Includes steps for identifying
and safely deleting stale entries to prevent issues in the permissions
management UI.
2025-06-04 17:58:29 +02:00
167 changed files with 3495 additions and 2278 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -8,7 +8,7 @@
</h3>
<h3>
:jigsaw: <a href="#jigsaw-creating-plugins">Create a plugin</a> &middot;
:rescue_worker_helmet: <a href="#rescue_worker_helmet-become-a-maintainer">Become a maintainer</a> &middot;
:briefcase: <a href="#briefcase-looking-for-a-job">Work with us!</a> &middot;
: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

View File

@@ -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>

View File

@@ -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

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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()`.

View File

@@ -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:

View File

@@ -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 plugins 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
```

View File

@@ -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

View File

@@ -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)

View File

@@ -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')

View File

@@ -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'),
}

View File

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

View File

@@ -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):

View File

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

View File

@@ -1,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,

View File

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

View File

@@ -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

View File

@@ -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

View File

@@ -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')

View File

@@ -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'
)

View File

@@ -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):

View File

@@ -22,6 +22,7 @@ from rq.worker_registration import clean_worker_registry
from core.utils import delete_rq_job, enqueue_rq_job, get_rq_jobs_from_status, requeue_rq_job, stop_rq_job
from 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)

View File

@@ -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

View File

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

View File

@@ -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(

View File

@@ -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:

View File

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

View File

@@ -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):

View File

@@ -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)

View File

@@ -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)

View File

@@ -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

View File

@@ -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):

View File

@@ -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):

View File

@@ -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)

View File

@@ -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.

View 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,
}

View File

@@ -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()

View File

@@ -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 = (

View File

@@ -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)

View File

@@ -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:

View File

@@ -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())

View File

@@ -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)
)

View File

@@ -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):

View File

@@ -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):

View File

@@ -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 = (

View File

@@ -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')

View File

@@ -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:

View File

@@ -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()

View File

@@ -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)

View File

@@ -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'},

View File

@@ -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__)

View 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}")

View File

@@ -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.
"""

View File

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

View File

@@ -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(

View File

@@ -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'):

View File

@@ -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')

View File

@@ -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']:

View File

@@ -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)

View File

@@ -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)

View File

@@ -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:

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -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"

View File

@@ -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"

View File

@@ -1,3 +1,3 @@
version: "4.3.2"
version: "4.3.3"
edition: "Community"
published: "2025-06-05"
published: "2025-06-26"

View File

@@ -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">

View File

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

View File

@@ -11,12 +11,6 @@
<li class="breadcrumb-item"><a href="{% url 'core:datafile_list' %}?source_id={{ object.source.pk }}">{{ object.source }}</a></li>
{% 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">

View File

@@ -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">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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">

View File

@@ -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">

View File

@@ -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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,30 +1,5 @@
{% extends 'dcim/device/components_base.html' %}
{% 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 %}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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" %}

View File

@@ -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">

View File

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

View File

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

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