mirror of
https://github.com/netbox-community/netbox.git
synced 2025-08-09 00:58:16 -06:00
Merge branch 'develop' into feature/15794-related-objects
This commit is contained in:
commit
a2aa40d8fa
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
2
.github/ISSUE_TEMPLATE/bug_report.yaml
vendored
@ -26,7 +26,7 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: NetBox Version
|
label: NetBox Version
|
||||||
description: What version of NetBox are you currently running?
|
description: What version of NetBox are you currently running?
|
||||||
placeholder: v4.0.2
|
placeholder: v4.0.5
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
|
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
2
.github/ISSUE_TEMPLATE/feature_request.yaml
vendored
@ -14,7 +14,7 @@ body:
|
|||||||
attributes:
|
attributes:
|
||||||
label: NetBox version
|
label: NetBox version
|
||||||
description: What version of NetBox are you currently running?
|
description: What version of NetBox are you currently running?
|
||||||
placeholder: v4.0.2
|
placeholder: v4.0.5
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
- type: dropdown
|
- type: dropdown
|
||||||
|
4
.github/workflows/auto-assign-issue.yml
vendored
4
.github/workflows/auto-assign-issue.yml
vendored
@ -12,10 +12,10 @@ jobs:
|
|||||||
auto-assign:
|
auto-assign:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: pozil/auto-assign-issue@v1
|
- uses: pozil/auto-assign-issue@v2
|
||||||
if: "contains(github.event.issue.labels.*.name, 'status: needs triage')"
|
if: "contains(github.event.issue.labels.*.name, 'status: needs triage')"
|
||||||
with:
|
with:
|
||||||
# Weighted assignments
|
# Weighted assignments
|
||||||
assignees: arthanson:3, jeffgdotorg:3, jeremystretch:3, abhi1693, DanSheps
|
assignees: arthanson:3, jeffgdotorg:3, jeremystretch:3, DanSheps
|
||||||
numOfAssignee: 1
|
numOfAssignee: 1
|
||||||
abortIfPreviousAssignees: true
|
abortIfPreviousAssignees: true
|
||||||
|
21
.github/workflows/ci.yml
vendored
21
.github/workflows/ci.yml
vendored
@ -1,7 +1,20 @@
|
|||||||
name: CI
|
name: CI
|
||||||
on: [push, pull_request]
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
paths-ignore:
|
||||||
|
- 'contrib/**'
|
||||||
|
- 'docs/**'
|
||||||
|
- 'netbox/translations/**'
|
||||||
|
pull_request:
|
||||||
|
paths-ignore:
|
||||||
|
- 'contrib/**'
|
||||||
|
- 'docs/**'
|
||||||
|
- 'netbox/translations/**'
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@ -34,12 +47,12 @@ jobs:
|
|||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Python ${{ matrix.python-version }}
|
- name: Set up Python ${{ matrix.python-version }}
|
||||||
uses: actions/setup-python@v4
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: ${{ matrix.python-version }}
|
python-version: ${{ matrix.python-version }}
|
||||||
|
|
||||||
- name: Use Node.js ${{ matrix.node-version }}
|
- name: Use Node.js ${{ matrix.node-version }}
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: ${{ matrix.node-version }}
|
node-version: ${{ matrix.node-version }}
|
||||||
|
|
||||||
@ -47,7 +60,7 @@ jobs:
|
|||||||
run: npm install -g yarn
|
run: npm install -g yarn
|
||||||
|
|
||||||
- name: Setup Node.js with Yarn Caching
|
- name: Setup Node.js with Yarn Caching
|
||||||
uses: actions/setup-node@v3
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: ${{ matrix.node-version }}
|
node-version: ${{ matrix.node-version }}
|
||||||
cache: yarn
|
cache: yarn
|
||||||
|
32
.github/workflows/close-incomplete-issues.yml
vendored
Normal file
32
.github/workflows/close-incomplete-issues.yml
vendored
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
# close-stale-issues (https://github.com/marketplace/actions/close-stale-issues)
|
||||||
|
name: Close incomplete issues
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '15 4 * * *'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
actions: write
|
||||||
|
issues: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
stale:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/stale@v9
|
||||||
|
with:
|
||||||
|
close-issue-message: >
|
||||||
|
This issue is being closed as no further information has been provided. If
|
||||||
|
you would like to revisit this topic, please first modify your original post
|
||||||
|
to include all the requested detail, and then ask that the issue be reopened.
|
||||||
|
days-before-stale: 7
|
||||||
|
days-before-close: 7
|
||||||
|
only-issue-labels: 'status: revisions needed'
|
||||||
|
operations-per-run: 100
|
||||||
|
remove-stale-when-updated: false
|
||||||
|
stale-issue-label: 'pending closure'
|
||||||
|
stale-issue-message: >
|
||||||
|
This is a reminder that additional information is needed in order to further
|
||||||
|
triage this issue. If the requested details are not provided, the issue will
|
||||||
|
soon be closed automatically.
|
21
.github/workflows/close-stale-issues.yml
vendored
21
.github/workflows/close-stale-issues.yml
vendored
@ -17,18 +17,19 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/stale@v9
|
- uses: actions/stale@v9
|
||||||
with:
|
with:
|
||||||
|
# General parameters
|
||||||
|
operations-per-run: 100
|
||||||
|
remove-stale-when-updated: false
|
||||||
|
|
||||||
|
# Issue parameters
|
||||||
close-issue-message: >
|
close-issue-message: >
|
||||||
This issue has been automatically closed due to lack of activity. In an
|
This issue has been automatically closed due to lack of activity. In an
|
||||||
effort to reduce noise, please do not comment any further. Note that the
|
effort to reduce noise, please do not comment any further. Note that the
|
||||||
core maintainers may elect to reopen this issue at a later date if deemed
|
core maintainers may elect to reopen this issue at a later date if deemed
|
||||||
necessary.
|
necessary.
|
||||||
close-pr-message: >
|
days-before-issue-stale: 90
|
||||||
This PR has been automatically closed due to lack of activity.
|
days-before-issue-close: 30
|
||||||
days-before-stale: 90
|
exempt-issue-labels: 'status: accepted,status: backlog,status: blocked'
|
||||||
days-before-close: 30
|
|
||||||
exempt-issue-labels: 'status: accepted,status: blocked,status: needs milestone'
|
|
||||||
operations-per-run: 100
|
|
||||||
remove-stale-when-updated: false
|
|
||||||
stale-issue-label: 'pending closure'
|
stale-issue-label: 'pending closure'
|
||||||
stale-issue-message: >
|
stale-issue-message: >
|
||||||
This issue has been automatically marked as stale because it has not had
|
This issue has been automatically marked as stale because it has not had
|
||||||
@ -38,6 +39,12 @@ jobs:
|
|||||||
process by "bumping" the issue; doing so will result in its immediate closure
|
process by "bumping" the issue; doing so will result in its immediate closure
|
||||||
and you may be barred from participating in any future discussions. Please see
|
and you may be barred from participating in any future discussions. Please see
|
||||||
our [contributing guide](https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md).
|
our [contributing guide](https://github.com/netbox-community/netbox/blob/develop/CONTRIBUTING.md).
|
||||||
|
|
||||||
|
# Pull request parameters
|
||||||
|
close-pr-message: >
|
||||||
|
This PR has been automatically closed due to lack of activity.
|
||||||
|
days-before-pr-stale: 15
|
||||||
|
days-before-pr-close: 15
|
||||||
stale-pr-label: 'pending closure'
|
stale-pr-label: 'pending closure'
|
||||||
stale-pr-message: >
|
stale-pr-message: >
|
||||||
This PR has been automatically marked as stale because it has not had
|
This PR has been automatically marked as stale because it has not had
|
||||||
|
45
.github/workflows/update-translation-strings.yml
vendored
Normal file
45
.github/workflows/update-translation-strings.yml
vendored
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
name: Update translation strings
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: '0 5 * * *'
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
env:
|
||||||
|
LOCALE: "en"
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
makemessages:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
env:
|
||||||
|
NETBOX_CONFIGURATION: netbox.configuration_testing
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check out repo
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Python
|
||||||
|
uses: actions/setup-python@v5
|
||||||
|
with:
|
||||||
|
python-version: 3.11
|
||||||
|
|
||||||
|
- name: Install system dependencies
|
||||||
|
run: sudo apt install -y gettext
|
||||||
|
|
||||||
|
- name: Install Python dependencies
|
||||||
|
run: |
|
||||||
|
python -m pip install --upgrade pip
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
|
- name: Run makemessages
|
||||||
|
run: python netbox/manage.py makemessages -l ${{ env.LOCALE }}
|
||||||
|
|
||||||
|
- name: Commit changes
|
||||||
|
uses: EndBug/add-and-commit@v9
|
||||||
|
with:
|
||||||
|
add: 'netbox/translations/'
|
||||||
|
default_author: github_actions
|
||||||
|
message: 'Update source translation strings'
|
1
.gitignore
vendored
1
.gitignore
vendored
@ -21,6 +21,7 @@ local_settings.py
|
|||||||
!upgrade.sh
|
!upgrade.sh
|
||||||
fabfile.py
|
fabfile.py
|
||||||
gunicorn.py
|
gunicorn.py
|
||||||
|
uwsgi.ini
|
||||||
netbox.log
|
netbox.log
|
||||||
netbox.pid
|
netbox.pid
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
@ -8,7 +8,9 @@ django-cors-headers
|
|||||||
|
|
||||||
# Runtime UI tool for debugging Django
|
# Runtime UI tool for debugging Django
|
||||||
# https://github.com/jazzband/django-debug-toolbar/blob/main/docs/changes.rst
|
# https://github.com/jazzband/django-debug-toolbar/blob/main/docs/changes.rst
|
||||||
django-debug-toolbar
|
# Pinned for DNS looukp bug; see https://github.com/netbox-community/netbox/issues/16454
|
||||||
|
# and https://github.com/jazzband/django-debug-toolbar/issues/1927
|
||||||
|
django-debug-toolbar==4.3.0
|
||||||
|
|
||||||
# Library for writing reusable URL query filters
|
# Library for writing reusable URL query filters
|
||||||
# https://github.com/carltongibson/django-filter/blob/main/CHANGES.rst
|
# https://github.com/carltongibson/django-filter/blob/main/CHANGES.rst
|
||||||
|
@ -179,6 +179,9 @@
|
|||||||
"usb-micro-ab",
|
"usb-micro-ab",
|
||||||
"usb-3-b",
|
"usb-3-b",
|
||||||
"usb-3-micro-b",
|
"usb-3-micro-b",
|
||||||
|
"molex-micro-fit-1x2",
|
||||||
|
"molex-micro-fit-2x2",
|
||||||
|
"molex-micro-fit-2x4",
|
||||||
"dc-terminal",
|
"dc-terminal",
|
||||||
"saf-d-grid",
|
"saf-d-grid",
|
||||||
"neutrik-powercon-20",
|
"neutrik-powercon-20",
|
||||||
@ -281,6 +284,9 @@
|
|||||||
"usb-a",
|
"usb-a",
|
||||||
"usb-micro-b",
|
"usb-micro-b",
|
||||||
"usb-c",
|
"usb-c",
|
||||||
|
"molex-micro-fit-1x2",
|
||||||
|
"molex-micro-fit-2x2",
|
||||||
|
"molex-micro-fit-2x4",
|
||||||
"dc-terminal",
|
"dc-terminal",
|
||||||
"hdot-cx",
|
"hdot-cx",
|
||||||
"saf-d-grid",
|
"saf-d-grid",
|
||||||
@ -317,6 +323,7 @@
|
|||||||
"100base-tx",
|
"100base-tx",
|
||||||
"100base-t1",
|
"100base-t1",
|
||||||
"1000base-t",
|
"1000base-t",
|
||||||
|
"1000base-tx",
|
||||||
"2.5gbase-t",
|
"2.5gbase-t",
|
||||||
"5gbase-t",
|
"5gbase-t",
|
||||||
"10gbase-t",
|
"10gbase-t",
|
||||||
@ -375,6 +382,8 @@
|
|||||||
"gsm",
|
"gsm",
|
||||||
"cdma",
|
"cdma",
|
||||||
"lte",
|
"lte",
|
||||||
|
"4g",
|
||||||
|
"5g",
|
||||||
"sonet-oc3",
|
"sonet-oc3",
|
||||||
"sonet-oc12",
|
"sonet-oc12",
|
||||||
"sonet-oc48",
|
"sonet-oc48",
|
||||||
@ -408,12 +417,15 @@
|
|||||||
"e3",
|
"e3",
|
||||||
"xdsl",
|
"xdsl",
|
||||||
"docsis",
|
"docsis",
|
||||||
|
"bpon",
|
||||||
|
"epon",
|
||||||
|
"10g-epon",
|
||||||
"gpon",
|
"gpon",
|
||||||
"xg-pon",
|
"xg-pon",
|
||||||
"xgs-pon",
|
"xgs-pon",
|
||||||
"ng-pon2",
|
"ng-pon2",
|
||||||
"epon",
|
"25g-pon",
|
||||||
"10g-epon",
|
"50g-pon",
|
||||||
"cisco-stackwise",
|
"cisco-stackwise",
|
||||||
"cisco-stackwise-plus",
|
"cisco-stackwise-plus",
|
||||||
"cisco-flexstack",
|
"cisco-flexstack",
|
||||||
|
4
docs/_theme/main.html
vendored
4
docs/_theme/main.html
vendored
@ -2,8 +2,8 @@
|
|||||||
|
|
||||||
{% block site_meta %}
|
{% block site_meta %}
|
||||||
{{ super() }}
|
{{ super() }}
|
||||||
{# Disable search indexing unless we're building for ReadTheDocs #}
|
{# Disable search indexing unless we're building for public consumption #}
|
||||||
{% if not config.extra.readthedocs %}
|
{% if not config.extra.build_public %}
|
||||||
<meta name="robots" content="noindex">
|
<meta name="robots" content="noindex">
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -94,15 +94,25 @@ REDIS = {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
!!! note
|
|
||||||
If you are upgrading from a NetBox release older than v2.7.0, please note that the Redis connection configuration
|
|
||||||
settings have changed. Manual modification to bring the `REDIS` section inline with the above specification is
|
|
||||||
necessary
|
|
||||||
|
|
||||||
!!! warning
|
!!! warning
|
||||||
It is highly recommended to keep the task and cache databases separate. Using the same database number on the
|
It is highly recommended to keep the task and cache databases separate. Using the same database number on the
|
||||||
same Redis instance for both may result in queued background tasks being lost during cache flushing events.
|
same Redis instance for both may result in queued background tasks being lost during cache flushing events.
|
||||||
|
|
||||||
|
### UNIX Socket Support
|
||||||
|
|
||||||
|
Redis may alternatively be configured by specifying a complete URL instead of individual components. This approach supports the use of a UNIX socket connection. For example:
|
||||||
|
|
||||||
|
```python
|
||||||
|
REDIS = {
|
||||||
|
'tasks': {
|
||||||
|
'URL': 'unix:///run/redis-netbox/redis.sock?db=0'
|
||||||
|
},
|
||||||
|
'caching': {
|
||||||
|
'URL': 'unix:///run/redis-netbox/redis.sock?db=1'
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
### Using Redis Sentinel
|
### Using Redis Sentinel
|
||||||
|
|
||||||
If you are using [Redis Sentinel](https://redis.io/topics/sentinel) for high-availability purposes, there is minimal
|
If you are using [Redis Sentinel](https://redis.io/topics/sentinel) for high-availability purposes, there is minimal
|
||||||
|
@ -65,12 +65,6 @@ class AnotherCustomScript(Script):
|
|||||||
script_order = (MyCustomScript, AnotherCustomScript)
|
script_order = (MyCustomScript, AnotherCustomScript)
|
||||||
```
|
```
|
||||||
|
|
||||||
## Module Attributes
|
|
||||||
|
|
||||||
### `name`
|
|
||||||
|
|
||||||
You can define `name` within a script module (the Python file which contains one or more scripts) to set the module name. If `name` is not defined, the module's file name will be used.
|
|
||||||
|
|
||||||
## Script Attributes
|
## Script Attributes
|
||||||
|
|
||||||
Script attributes are defined under a class named `Meta` within the script. These are optional, but encouraged.
|
Script attributes are defined under a class named `Meta` within the script. These are optional, but encouraged.
|
||||||
|
@ -86,15 +86,7 @@ This will automatically update the schema file at `contrib/generated_schema.json
|
|||||||
|
|
||||||
### Update & Compile Translations
|
### Update & Compile Translations
|
||||||
|
|
||||||
Log into [Transifex](https://app.transifex.com/netbox-community/netbox/dashboard/) to download the updated string maps. Download the resource (portable object, or `.po`) file for each language and save them to `netbox/translations/$lang/LC_MESSAGES/django.po`, overwriting the current files. (Be sure to click the **Download for use** link.)
|
Updated language translations should be pulled from [Transifex](https://app.transifex.com/netbox-community/netbox/dashboard/) and re-compiled for each new release. Follow the documented process for [updating translated strings](./translations.md#updating-translated-strings) to do this.
|
||||||
|
|
||||||

|
|
||||||
|
|
||||||
Once the resource files for all languages have been updated, compile the machine object (`.mo`) files using the `compilemessages` management command:
|
|
||||||
|
|
||||||
```nohighlight
|
|
||||||
./manage.py compilemessages
|
|
||||||
```
|
|
||||||
|
|
||||||
### Update Version and Changelog
|
### Update Version and Changelog
|
||||||
|
|
||||||
|
@ -6,17 +6,38 @@ All language translations in NetBox are generated from the source file found at
|
|||||||
|
|
||||||
Reviewers log into Transifex and navigate to their designated language(s) to translate strings. The initial translation for most strings will be machine-generated via the AWS Translate service. Human reviewers are responsible for reviewing these translations and making corrections where necessary.
|
Reviewers log into Transifex and navigate to their designated language(s) to translate strings. The initial translation for most strings will be machine-generated via the AWS Translate service. Human reviewers are responsible for reviewing these translations and making corrections where necessary.
|
||||||
|
|
||||||
Immediately prior to each NetBox release, the translation maps for all completed languages will be downloaded from Transifex, compiled, and checked into the NetBox code base by a maintainer.
|
|
||||||
|
|
||||||
## Updating Translation Sources
|
## Updating Translation Sources
|
||||||
|
|
||||||
To update the English `.po` file from which all translations are derived, use the `makemessages` management command:
|
To update the English `.po` file from which all translations are derived, use the `makemessages` management command (ignoring the `project-static/` directory):
|
||||||
|
|
||||||
```nohighlight
|
```nohighlight
|
||||||
./manage.py makemessages -l en
|
./manage.py makemessages -l en -i "project-static/*"
|
||||||
```
|
```
|
||||||
|
|
||||||
Then, commit the change and push to the `develop` branch on GitHub. After some time, any new strings will appear for translation on Transifex automatically.
|
Then, commit the change and push to the `develop` branch on GitHub. Any new strings will appear for translation on Transifex automatically.
|
||||||
|
|
||||||
|
## Updating Translated Strings
|
||||||
|
|
||||||
|
Typically, translated strings need to be updated only as part of the NetBox [release process](./release-checklist.md).
|
||||||
|
|
||||||
|
To update translated strings, start by initiating a sync from Transifex. From the Transifex dashboard, navigate to Settings > Integrations > GitHub > Manage, and click the **Manual Sync** button at top right.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Enter a threshold percentage of 1 (to ensure all translations are captured) and select the `develop` branch, then click **Sync**. This will initiate a pull request to GitHub to update any newly modified translation (`.po`) files.
|
||||||
|
|
||||||
|
!!! tip
|
||||||
|
The new PR should appear within a few minutes. If it does not, check that there are in fact new translations to be added.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
Once the PR has been merged, the updated strings need to be compiled into new `.mo` files so they can be used by the application. Update the `develop` branch locally to pull in the changes from the Transifex PR, then run Django's [`compilemessages`](https://docs.djangoproject.com/en/stable/ref/django-admin/#django-admin-compilemessages) management command:
|
||||||
|
|
||||||
|
```nohighlight
|
||||||
|
./manage.py compilemessages
|
||||||
|
```
|
||||||
|
|
||||||
|
Once any new `.mo` files have been generated, they need to be committed and pushed back up to GitHub. (Again, this is typically done as part of publishing a new NetBox release.)
|
||||||
|
|
||||||
## Proposing New Languages
|
## Proposing New Languages
|
||||||
|
|
||||||
|
Binary file not shown.
Before Width: | Height: | Size: 54 KiB |
BIN
docs/media/development/transifex_pull_request.png
Normal file
BIN
docs/media/development/transifex_pull_request.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 108 KiB |
BIN
docs/media/development/transifex_sync.png
Normal file
BIN
docs/media/development/transifex_sync.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 42 KiB |
@ -89,13 +89,13 @@ This form facilitates editing multiple objects in bulk. Unlike a model form, thi
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
from dcim.models import Site
|
from dcim.models import Site
|
||||||
from netbox.forms import NetBoxModelImportForm
|
from netbox.forms import NetBoxModelBulkEditForm
|
||||||
from utilities.forms import CommentField, DynamicModelChoiceField
|
from utilities.forms import CommentField, DynamicModelChoiceField
|
||||||
from utilities.forms.rendering import FieldSet
|
from utilities.forms.rendering import FieldSet
|
||||||
from .models import MyModel, MyModelStatusChoices
|
from .models import MyModel, MyModelStatusChoices
|
||||||
|
|
||||||
|
|
||||||
class MyModelEditForm(NetBoxModelImportForm):
|
class MyModelBulkEditForm(NetBoxModelBulkEditForm):
|
||||||
name = forms.CharField(
|
name = forms.CharField(
|
||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
|
@ -1,12 +1,66 @@
|
|||||||
# NetBox v4.0
|
# NetBox v4.0
|
||||||
|
|
||||||
## v4.0.3 (FUTURE)
|
## v4.0.6 (FUTURE)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v4.0.5 (2024-06-06)
|
||||||
|
|
||||||
### Enhancements
|
### Enhancements
|
||||||
|
|
||||||
|
* [#14810](https://github.com/netbox-community/netbox/issues/14810) - Enable contact assignment for services
|
||||||
|
* [#15489](https://github.com/netbox-community/netbox/issues/15489) - Add 1000Base-TX interface type
|
||||||
|
* [#15873](https://github.com/netbox-community/netbox/issues/15873) - Improve readability of allocates resource numbers for clusters
|
||||||
|
* [#16290](https://github.com/netbox-community/netbox/issues/16290) - Capture entire object in changelog data (but continue to display only non-internal attributes)
|
||||||
|
* [#16353](https://github.com/netbox-community/netbox/issues/16353) - Enable plugins to extend object change view with custom content
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* [#13422](https://github.com/netbox-community/netbox/issues/13422) - Rebuild MPTT trees for applicable models after merging staged changes
|
||||||
|
* [#14567](https://github.com/netbox-community/netbox/issues/14567) - Apply active quicksearch value when exporting "current view" from object list
|
||||||
|
* [#15194](https://github.com/netbox-community/netbox/issues/15194) - Avoid enqueuing duplicate event triggers for a modified object
|
||||||
|
* [#16039](https://github.com/netbox-community/netbox/issues/16039) - Fix row highlighting for front & rear port connections under device view
|
||||||
|
* [#16050](https://github.com/netbox-community/netbox/issues/16050) - Fix display of names & descriptions defined for custom scripts
|
||||||
|
* [#16083](https://github.com/netbox-community/netbox/issues/16083) - Disable font ligatures to avoid peculiarities in rendered text
|
||||||
|
* [#16202](https://github.com/netbox-community/netbox/issues/16202) - Fix site map button URL for certain localizations
|
||||||
|
* [#16261](https://github.com/netbox-community/netbox/issues/16261) - Fix GraphQL filtering for certain multi-value filters
|
||||||
|
* [#16286](https://github.com/netbox-community/netbox/issues/16286) - Fix global search support for provider accounts
|
||||||
|
* [#16312](https://github.com/netbox-community/netbox/issues/16312) - Fix object list navigation for dashboard widgets
|
||||||
|
* [#16315](https://github.com/netbox-community/netbox/issues/16315) - Fix filtering change log & journal entries by object type in UI
|
||||||
|
* [#16376](https://github.com/netbox-community/netbox/issues/16376) - Update change log for the terminating object (e.g. interface) when attaching a cable
|
||||||
|
* [#16400](https://github.com/netbox-community/netbox/issues/16400) - Fix AttributeError when attempting to restore a previous configuration revision after deleting the current one
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## v4.0.3 (2024-05-22)
|
||||||
|
|
||||||
|
### Enhancements
|
||||||
|
|
||||||
|
* [#12984](https://github.com/netbox-community/netbox/issues/12984) - Add Molex Micro-Fit power port & outlet types
|
||||||
|
* [#13764](https://github.com/netbox-community/netbox/issues/13764) - Enable contact assignments for aggregates, prefixes, IP ranges, and IP addresses
|
||||||
* [#14639](https://github.com/netbox-community/netbox/issues/14639) - Add Ukrainian translation support
|
* [#14639](https://github.com/netbox-community/netbox/issues/14639) - Add Ukrainian translation support
|
||||||
|
* [#14653](https://github.com/netbox-community/netbox/issues/14653) - Add an inventory items table column for all device components
|
||||||
* [#14686](https://github.com/netbox-community/netbox/issues/14686) - Add German translation support
|
* [#14686](https://github.com/netbox-community/netbox/issues/14686) - Add German translation support
|
||||||
* [#14855](https://github.com/netbox-community/netbox/issues/14855) - Add Chinese translation support
|
* [#14855](https://github.com/netbox-community/netbox/issues/14855) - Add Chinese translation support
|
||||||
|
* [#14948](https://github.com/netbox-community/netbox/issues/14948) - Introduce the `has_virtual_device_context` filter for devices
|
||||||
|
* [#15353](https://github.com/netbox-community/netbox/issues/15353) - Improve error reporting when custom scripts fail to load
|
||||||
|
* [#15496](https://github.com/netbox-community/netbox/issues/15496) - Implement dedicated views for management of circuit terminations
|
||||||
|
* [#15603](https://github.com/netbox-community/netbox/issues/15603) - Add 4G & 5G cellular interface types
|
||||||
|
* [#15962](https://github.com/netbox-community/netbox/issues/15962) - Enable UNIX socket connections for Redis
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
* [#13293](https://github.com/netbox-community/netbox/issues/13293) - Limit interface selector for IP address to current device/VM
|
||||||
|
* [#14953](https://github.com/netbox-community/netbox/issues/14953) - Ensure annotated count fields are present in REST API response data when creating new objects
|
||||||
|
* [#14982](https://github.com/netbox-community/netbox/issues/14982) - Fix OpenAPI schema definition for SerializedPKRelatedFields
|
||||||
|
* [#15082](https://github.com/netbox-community/netbox/issues/15082) - Strip whitespace from choice values & labels when creating a custom field choice set
|
||||||
|
* [#16138](https://github.com/netbox-community/netbox/issues/16138) - Fix support for referencing users & groups in object permissions
|
||||||
|
* [#16145](https://github.com/netbox-community/netbox/issues/16145) - Restore ability to reference custom scripts via module & name in REST API
|
||||||
|
* [#16164](https://github.com/netbox-community/netbox/issues/16164) - Correct display of selected values in UI when filtering object list by a null value
|
||||||
|
* [#16173](https://github.com/netbox-community/netbox/issues/16173) - Fix TypeError exception when viewing object list with no pagination preference defined
|
||||||
|
* [#16228](https://github.com/netbox-community/netbox/issues/16228) - Fix permissions enforcement for GraphQL queries of users & groups
|
||||||
|
* [#16232](https://github.com/netbox-community/netbox/issues/16232) - Preserve bulk action checkboxes on dynamic tables when using pagination
|
||||||
|
* [#16240](https://github.com/netbox-community/netbox/issues/16240) - Fixed NoReverseMatch exception when adding circuit terminations to an object counts dashboard widget
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
@ -42,7 +42,7 @@ plugins:
|
|||||||
show_root_toc_entry: false
|
show_root_toc_entry: false
|
||||||
show_source: false
|
show_source: false
|
||||||
extra:
|
extra:
|
||||||
readthedocs: !ENV READTHEDOCS
|
build_public: !ENV BUILD_PUBLIC
|
||||||
social:
|
social:
|
||||||
- icon: fontawesome/brands/github
|
- icon: fontawesome/brands/github
|
||||||
link: https://github.com/netbox-community/netbox
|
link: https://github.com/netbox-community/netbox
|
||||||
|
@ -275,6 +275,17 @@ class CircuitTerminationFilterSet(NetBoxModelFilterSet, CabledObjectFilterSet):
|
|||||||
queryset=ProviderNetwork.objects.all(),
|
queryset=ProviderNetwork.objects.all(),
|
||||||
label=_('ProviderNetwork (ID)'),
|
label=_('ProviderNetwork (ID)'),
|
||||||
)
|
)
|
||||||
|
provider_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
field_name='circuit__provider_id',
|
||||||
|
queryset=Provider.objects.all(),
|
||||||
|
label=_('Provider (ID)'),
|
||||||
|
)
|
||||||
|
provider = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
field_name='circuit__provider__slug',
|
||||||
|
queryset=Provider.objects.all(),
|
||||||
|
to_field_name='slug',
|
||||||
|
label=_('Provider (slug)'),
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = CircuitTermination
|
model = CircuitTermination
|
||||||
|
@ -3,16 +3,18 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
|
|
||||||
from circuits.choices import CircuitCommitRateChoices, CircuitStatusChoices
|
from circuits.choices import CircuitCommitRateChoices, CircuitStatusChoices
|
||||||
from circuits.models import *
|
from circuits.models import *
|
||||||
|
from dcim.models import Site
|
||||||
from ipam.models import ASN
|
from ipam.models import ASN
|
||||||
from netbox.forms import NetBoxModelBulkEditForm
|
from netbox.forms import NetBoxModelBulkEditForm
|
||||||
from tenancy.models import Tenant
|
from tenancy.models import Tenant
|
||||||
from utilities.forms import add_blank_choice
|
from utilities.forms import add_blank_choice
|
||||||
from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField
|
from utilities.forms.fields import ColorField, CommentField, DynamicModelChoiceField, DynamicModelMultipleChoiceField
|
||||||
from utilities.forms.rendering import FieldSet
|
from utilities.forms.rendering import FieldSet, TabbedGroups
|
||||||
from utilities.forms.widgets import DatePicker, NumberWithOptions
|
from utilities.forms.widgets import BulkEditNullBooleanSelect, DatePicker, NumberWithOptions
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'CircuitBulkEditForm',
|
'CircuitBulkEditForm',
|
||||||
|
'CircuitTerminationBulkEditForm',
|
||||||
'CircuitTypeBulkEditForm',
|
'CircuitTypeBulkEditForm',
|
||||||
'ProviderBulkEditForm',
|
'ProviderBulkEditForm',
|
||||||
'ProviderAccountBulkEditForm',
|
'ProviderAccountBulkEditForm',
|
||||||
@ -172,3 +174,48 @@ class CircuitBulkEditForm(NetBoxModelBulkEditForm):
|
|||||||
nullable_fields = (
|
nullable_fields = (
|
||||||
'tenant', 'commit_rate', 'description', 'comments',
|
'tenant', 'commit_rate', 'description', 'comments',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CircuitTerminationBulkEditForm(NetBoxModelBulkEditForm):
|
||||||
|
description = forms.CharField(
|
||||||
|
label=_('Description'),
|
||||||
|
max_length=200,
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
site = DynamicModelChoiceField(
|
||||||
|
label=_('Site'),
|
||||||
|
queryset=Site.objects.all(),
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
provider_network = DynamicModelChoiceField(
|
||||||
|
label=_('Provider Network'),
|
||||||
|
queryset=ProviderNetwork.objects.all(),
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
port_speed = forms.IntegerField(
|
||||||
|
required=False,
|
||||||
|
label=_('Port speed (Kbps)'),
|
||||||
|
)
|
||||||
|
upstream_speed = forms.IntegerField(
|
||||||
|
required=False,
|
||||||
|
label=_('Upstream speed (Kbps)'),
|
||||||
|
)
|
||||||
|
mark_connected = forms.NullBooleanField(
|
||||||
|
label=_('Mark connected'),
|
||||||
|
required=False,
|
||||||
|
widget=BulkEditNullBooleanSelect
|
||||||
|
)
|
||||||
|
|
||||||
|
model = CircuitTermination
|
||||||
|
fieldsets = (
|
||||||
|
FieldSet(
|
||||||
|
'description',
|
||||||
|
TabbedGroups(
|
||||||
|
FieldSet('site', name=_('Site')),
|
||||||
|
FieldSet('provider_network', name=_('Provider Network')),
|
||||||
|
),
|
||||||
|
'mark_connected', name=_('Circuit Termination')
|
||||||
|
),
|
||||||
|
FieldSet('port_speed', 'upstream_speed', name=_('Termination Details')),
|
||||||
|
)
|
||||||
|
nullable_fields = ('description')
|
||||||
|
@ -1,10 +1,10 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
|
|
||||||
from circuits.choices import CircuitStatusChoices
|
|
||||||
from circuits.models import *
|
|
||||||
from dcim.models import Site
|
|
||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
|
||||||
|
from circuits.choices import *
|
||||||
|
from circuits.models import *
|
||||||
|
from dcim.models import Site
|
||||||
from netbox.forms import NetBoxModelImportForm
|
from netbox.forms import NetBoxModelImportForm
|
||||||
from tenancy.models import Tenant
|
from tenancy.models import Tenant
|
||||||
from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, SlugField
|
from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, SlugField
|
||||||
@ -12,6 +12,7 @@ from utilities.forms.fields import CSVChoiceField, CSVModelChoiceField, SlugFiel
|
|||||||
__all__ = (
|
__all__ = (
|
||||||
'CircuitImportForm',
|
'CircuitImportForm',
|
||||||
'CircuitTerminationImportForm',
|
'CircuitTerminationImportForm',
|
||||||
|
'CircuitTerminationImportRelatedForm',
|
||||||
'CircuitTypeImportForm',
|
'CircuitTypeImportForm',
|
||||||
'ProviderImportForm',
|
'ProviderImportForm',
|
||||||
'ProviderAccountImportForm',
|
'ProviderAccountImportForm',
|
||||||
@ -111,7 +112,16 @@ class CircuitImportForm(NetBoxModelImportForm):
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class CircuitTerminationImportForm(forms.ModelForm):
|
class BaseCircuitTerminationImportForm(forms.ModelForm):
|
||||||
|
circuit = CSVModelChoiceField(
|
||||||
|
label=_('Circuit'),
|
||||||
|
queryset=Circuit.objects.all(),
|
||||||
|
to_field_name='cid',
|
||||||
|
)
|
||||||
|
term_side = CSVChoiceField(
|
||||||
|
label=_('Termination'),
|
||||||
|
choices=CircuitTerminationSideChoices,
|
||||||
|
)
|
||||||
site = CSVModelChoiceField(
|
site = CSVModelChoiceField(
|
||||||
label=_('Site'),
|
label=_('Site'),
|
||||||
queryset=Site.objects.all(),
|
queryset=Site.objects.all(),
|
||||||
@ -125,9 +135,21 @@ class CircuitTerminationImportForm(forms.ModelForm):
|
|||||||
required=False
|
required=False
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CircuitTerminationImportRelatedForm(BaseCircuitTerminationImportForm):
|
||||||
class Meta:
|
class Meta:
|
||||||
model = CircuitTermination
|
model = CircuitTermination
|
||||||
fields = [
|
fields = [
|
||||||
'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed', 'xconnect_id',
|
'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed', 'xconnect_id',
|
||||||
'pp_info', 'description',
|
'pp_info', 'description'
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class CircuitTerminationImportForm(NetBoxModelImportForm, BaseCircuitTerminationImportForm):
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = CircuitTermination
|
||||||
|
fields = [
|
||||||
|
'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed', 'xconnect_id',
|
||||||
|
'pp_info', 'description', 'tags'
|
||||||
]
|
]
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
from django import forms
|
from django import forms
|
||||||
from django.utils.translation import gettext as _
|
from django.utils.translation import gettext as _
|
||||||
|
|
||||||
from circuits.choices import CircuitCommitRateChoices, CircuitStatusChoices
|
from circuits.choices import CircuitCommitRateChoices, CircuitStatusChoices, CircuitTerminationSideChoices
|
||||||
from circuits.models import *
|
from circuits.models import *
|
||||||
from dcim.models import Region, Site, SiteGroup
|
from dcim.models import Region, Site, SiteGroup
|
||||||
from ipam.models import ASN
|
from ipam.models import ASN
|
||||||
@ -13,6 +13,7 @@ from utilities.forms.widgets import DatePicker, NumberWithOptions
|
|||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'CircuitFilterForm',
|
'CircuitFilterForm',
|
||||||
|
'CircuitTerminationFilterForm',
|
||||||
'CircuitTypeFilterForm',
|
'CircuitTypeFilterForm',
|
||||||
'ProviderFilterForm',
|
'ProviderFilterForm',
|
||||||
'ProviderAccountFilterForm',
|
'ProviderAccountFilterForm',
|
||||||
@ -186,3 +187,46 @@ class CircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFi
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
tag = TagFilterField(model)
|
tag = TagFilterField(model)
|
||||||
|
|
||||||
|
|
||||||
|
class CircuitTerminationFilterForm(NetBoxModelFilterSetForm):
|
||||||
|
model = CircuitTermination
|
||||||
|
fieldsets = (
|
||||||
|
FieldSet('q', 'filter_id', 'tag'),
|
||||||
|
FieldSet('circuit_id', 'term_side', name=_('Circuit')),
|
||||||
|
FieldSet('provider_id', 'provider_network_id', name=_('Provider')),
|
||||||
|
FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')),
|
||||||
|
)
|
||||||
|
site_id = DynamicModelMultipleChoiceField(
|
||||||
|
queryset=Site.objects.all(),
|
||||||
|
required=False,
|
||||||
|
query_params={
|
||||||
|
'region_id': '$region_id',
|
||||||
|
'site_group_id': '$site_group_id',
|
||||||
|
},
|
||||||
|
label=_('Site')
|
||||||
|
)
|
||||||
|
circuit_id = DynamicModelMultipleChoiceField(
|
||||||
|
queryset=Circuit.objects.all(),
|
||||||
|
required=False,
|
||||||
|
label=_('Circuit')
|
||||||
|
)
|
||||||
|
term_side = forms.MultipleChoiceField(
|
||||||
|
label=_('Term Side'),
|
||||||
|
choices=CircuitTerminationSideChoices,
|
||||||
|
required=False
|
||||||
|
)
|
||||||
|
provider_network_id = DynamicModelMultipleChoiceField(
|
||||||
|
queryset=ProviderNetwork.objects.all(),
|
||||||
|
required=False,
|
||||||
|
query_params={
|
||||||
|
'provider_id': '$provider_id'
|
||||||
|
},
|
||||||
|
label=_('Provider network')
|
||||||
|
)
|
||||||
|
provider_id = DynamicModelMultipleChoiceField(
|
||||||
|
queryset=Provider.objects.all(),
|
||||||
|
required=False,
|
||||||
|
label=_('Provider')
|
||||||
|
)
|
||||||
|
tag = TagFilterField(model)
|
||||||
|
@ -227,7 +227,7 @@ class CircuitTermination(
|
|||||||
return f'{self.circuit}: Termination {self.term_side}'
|
return f'{self.circuit}: Termination {self.term_side}'
|
||||||
|
|
||||||
def get_absolute_url(self):
|
def get_absolute_url(self):
|
||||||
return self.circuit.get_absolute_url()
|
return reverse('circuits:circuittermination', args=[self.pk])
|
||||||
|
|
||||||
def clean(self):
|
def clean(self):
|
||||||
super().clean()
|
super().clean()
|
||||||
|
@ -48,6 +48,7 @@ class ProviderIndex(SearchIndex):
|
|||||||
display_attrs = ('description',)
|
display_attrs = ('description',)
|
||||||
|
|
||||||
|
|
||||||
|
@register_search
|
||||||
class ProviderAccountIndex(SearchIndex):
|
class ProviderAccountIndex(SearchIndex):
|
||||||
model = models.ProviderAccount
|
model = models.ProviderAccount
|
||||||
fields = (
|
fields = (
|
||||||
|
@ -10,6 +10,7 @@ from .columns import CommitRateColumn
|
|||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'CircuitTable',
|
'CircuitTable',
|
||||||
|
'CircuitTerminationTable',
|
||||||
'CircuitTypeTable',
|
'CircuitTypeTable',
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -88,3 +89,31 @@ class CircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
|
|||||||
default_columns = (
|
default_columns = (
|
||||||
'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'description',
|
'pk', 'cid', 'provider', 'type', 'status', 'tenant', 'termination_a', 'termination_z', 'description',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class CircuitTerminationTable(NetBoxTable):
|
||||||
|
circuit = tables.Column(
|
||||||
|
verbose_name=_('Circuit'),
|
||||||
|
linkify=True
|
||||||
|
)
|
||||||
|
provider = tables.Column(
|
||||||
|
verbose_name=_('Provider'),
|
||||||
|
linkify=True,
|
||||||
|
accessor='circuit.provider'
|
||||||
|
)
|
||||||
|
site = tables.Column(
|
||||||
|
verbose_name=_('Site'),
|
||||||
|
linkify=True
|
||||||
|
)
|
||||||
|
provider_network = tables.Column(
|
||||||
|
verbose_name=_('Provider Network'),
|
||||||
|
linkify=True
|
||||||
|
)
|
||||||
|
|
||||||
|
class Meta(NetBoxTable.Meta):
|
||||||
|
model = CircuitTermination
|
||||||
|
fields = (
|
||||||
|
'pk', 'id', 'circuit', 'provider', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed',
|
||||||
|
'xconnect_id', 'pp_info', 'description', 'created', 'last_updated', 'actions',
|
||||||
|
)
|
||||||
|
default_columns = ('pk', 'id', 'circuit', 'provider', 'term_side', 'description')
|
||||||
|
@ -351,24 +351,26 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
|
|
||||||
providers = (
|
providers = (
|
||||||
Provider(name='Provider 1', slug='provider-1'),
|
Provider(name='Provider 1', slug='provider-1'),
|
||||||
|
Provider(name='Provider 2', slug='provider-2'),
|
||||||
|
Provider(name='Provider 3', slug='provider-3'),
|
||||||
)
|
)
|
||||||
Provider.objects.bulk_create(providers)
|
Provider.objects.bulk_create(providers)
|
||||||
|
|
||||||
provider_networks = (
|
provider_networks = (
|
||||||
ProviderNetwork(name='Provider Network 1', provider=providers[0]),
|
ProviderNetwork(name='Provider Network 1', provider=providers[0]),
|
||||||
ProviderNetwork(name='Provider Network 2', provider=providers[0]),
|
ProviderNetwork(name='Provider Network 2', provider=providers[1]),
|
||||||
ProviderNetwork(name='Provider Network 3', provider=providers[0]),
|
ProviderNetwork(name='Provider Network 3', provider=providers[2]),
|
||||||
)
|
)
|
||||||
ProviderNetwork.objects.bulk_create(provider_networks)
|
ProviderNetwork.objects.bulk_create(provider_networks)
|
||||||
|
|
||||||
circuits = (
|
circuits = (
|
||||||
Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 1'),
|
Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 1'),
|
||||||
Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 2'),
|
Circuit(provider=providers[1], type=circuit_types[0], cid='Circuit 2'),
|
||||||
Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 3'),
|
Circuit(provider=providers[2], type=circuit_types[0], cid='Circuit 3'),
|
||||||
Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 4'),
|
Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 4'),
|
||||||
Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 5'),
|
Circuit(provider=providers[1], type=circuit_types[0], cid='Circuit 5'),
|
||||||
Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 6'),
|
Circuit(provider=providers[2], type=circuit_types[0], cid='Circuit 6'),
|
||||||
Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 7'),
|
Circuit(provider=providers[2], type=circuit_types[0], cid='Circuit 7'),
|
||||||
)
|
)
|
||||||
Circuit.objects.bulk_create(circuits)
|
Circuit.objects.bulk_create(circuits)
|
||||||
|
|
||||||
@ -413,10 +415,17 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
def test_circuit_id(self):
|
def test_circuit_id(self):
|
||||||
circuits = Circuit.objects.all()[:2]
|
circuits = Circuit.objects.filter(cid__in=['Circuit 1', 'Circuit 2'])
|
||||||
params = {'circuit_id': [circuits[0].pk, circuits[1].pk]}
|
params = {'circuit_id': [circuits[0].pk, circuits[1].pk]}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
|
||||||
|
|
||||||
|
def test_provider(self):
|
||||||
|
providers = Provider.objects.all()[:2]
|
||||||
|
params = {'provider_id': [providers[0].pk, providers[1].pk]}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
|
||||||
|
params = {'provider': [providers[0].slug, providers[1].slug]}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
|
||||||
|
|
||||||
def test_site(self):
|
def test_site(self):
|
||||||
sites = Site.objects.all()[:2]
|
sites = Site.objects.all()[:2]
|
||||||
params = {'site_id': [sites[0].pk, sites[1].pk]}
|
params = {'site_id': [sites[0].pk, sites[1].pk]}
|
||||||
|
@ -5,8 +5,11 @@ from django.urls import reverse
|
|||||||
|
|
||||||
from circuits.choices import *
|
from circuits.choices import *
|
||||||
from circuits.models import *
|
from circuits.models import *
|
||||||
|
from core.models import ObjectType
|
||||||
from dcim.models import Cable, Interface, Site
|
from dcim.models import Cable, Interface, Site
|
||||||
from ipam.models import ASN, RIR
|
from ipam.models import ASN, RIR
|
||||||
|
from netbox.choices import ImportFormatChoices
|
||||||
|
from users.models import ObjectPermission
|
||||||
from utilities.testing import ViewTestCases, create_tags, create_test_device
|
from utilities.testing import ViewTestCases, create_tags, create_test_device
|
||||||
|
|
||||||
|
|
||||||
@ -115,6 +118,7 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
|||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def setUpTestData(cls):
|
def setUpTestData(cls):
|
||||||
|
Site.objects.create(name='Site 1', slug='site-1')
|
||||||
|
|
||||||
providers = (
|
providers = (
|
||||||
Provider(name='Provider 1', slug='provider-1'),
|
Provider(name='Provider 1', slug='provider-1'),
|
||||||
@ -184,6 +188,51 @@ class CircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
|||||||
'comments': 'New comments',
|
'comments': 'New comments',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
|
||||||
|
def test_bulk_import_objects_with_terminations(self):
|
||||||
|
json_data = """
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"cid": "Circuit 7",
|
||||||
|
"provider": "Provider 1",
|
||||||
|
"type": "Circuit Type 1",
|
||||||
|
"status": "active",
|
||||||
|
"description": "Testing Import",
|
||||||
|
"terminations": [
|
||||||
|
{
|
||||||
|
"term_side": "A",
|
||||||
|
"site": "Site 1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"term_side": "Z",
|
||||||
|
"site": "Site 1"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
"""
|
||||||
|
initial_count = self._get_queryset().count()
|
||||||
|
data = {
|
||||||
|
'data': json_data,
|
||||||
|
'format': ImportFormatChoices.JSON,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Assign model-level permission
|
||||||
|
obj_perm = ObjectPermission(
|
||||||
|
name='Test permission',
|
||||||
|
actions=['add']
|
||||||
|
)
|
||||||
|
obj_perm.save()
|
||||||
|
obj_perm.users.add(self.user)
|
||||||
|
obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
|
||||||
|
|
||||||
|
# Try GET with model-level permission
|
||||||
|
self.assertHttpStatus(self.client.get(self._get_url('import')), 200)
|
||||||
|
|
||||||
|
# Test POST with permission
|
||||||
|
self.assertHttpStatus(self.client.post(self._get_url('import'), data), 302)
|
||||||
|
self.assertEqual(self._get_queryset().count(), initial_count + 1)
|
||||||
|
|
||||||
|
|
||||||
class ProviderAccountTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
class ProviderAccountTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||||
model = ProviderAccount
|
model = ProviderAccount
|
||||||
@ -287,10 +336,7 @@ class ProviderNetworkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class CircuitTerminationTestCase(
|
class CircuitTerminationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
|
||||||
ViewTestCases.EditObjectViewTestCase,
|
|
||||||
ViewTestCases.DeleteObjectViewTestCase,
|
|
||||||
):
|
|
||||||
model = CircuitTermination
|
model = CircuitTermination
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@ -327,6 +373,24 @@ class CircuitTerminationTestCase(
|
|||||||
'description': 'New description',
|
'description': 'New description',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cls.csv_data = (
|
||||||
|
"circuit,term_side,site,description",
|
||||||
|
"Circuit 3,A,Site 1,Foo",
|
||||||
|
"Circuit 3,Z,Site 1,Bar",
|
||||||
|
)
|
||||||
|
|
||||||
|
cls.csv_update_data = (
|
||||||
|
"id,port_speed,description",
|
||||||
|
f"{circuit_terminations[0].pk},100,New description7",
|
||||||
|
f"{circuit_terminations[1].pk},200,New description8",
|
||||||
|
f"{circuit_terminations[2].pk},300,New description9",
|
||||||
|
)
|
||||||
|
|
||||||
|
cls.bulk_edit_data = {
|
||||||
|
'port_speed': 400,
|
||||||
|
'description': 'New description',
|
||||||
|
}
|
||||||
|
|
||||||
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
@override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
|
||||||
def test_trace(self):
|
def test_trace(self):
|
||||||
device = create_test_device('Device 1')
|
device = create_test_device('Device 1')
|
||||||
|
@ -48,7 +48,11 @@ urlpatterns = [
|
|||||||
path('circuits/<int:pk>/', include(get_model_urls('circuits', 'circuit'))),
|
path('circuits/<int:pk>/', include(get_model_urls('circuits', 'circuit'))),
|
||||||
|
|
||||||
# Circuit terminations
|
# Circuit terminations
|
||||||
|
path('circuit-terminations/', views.CircuitTerminationListView.as_view(), name='circuittermination_list'),
|
||||||
path('circuit-terminations/add/', views.CircuitTerminationEditView.as_view(), name='circuittermination_add'),
|
path('circuit-terminations/add/', views.CircuitTerminationEditView.as_view(), name='circuittermination_add'),
|
||||||
|
path('circuit-terminations/import/', views.CircuitTerminationBulkImportView.as_view(), name='circuittermination_import'),
|
||||||
|
path('circuit-terminations/edit/', views.CircuitTerminationBulkEditView.as_view(), name='circuittermination_bulk_edit'),
|
||||||
|
path('circuit-terminations/delete/', views.CircuitTerminationBulkDeleteView.as_view(), name='circuittermination_bulk_delete'),
|
||||||
path('circuit-terminations/<int:pk>/', include(get_model_urls('circuits', 'circuittermination'))),
|
path('circuit-terminations/<int:pk>/', include(get_model_urls('circuits', 'circuittermination'))),
|
||||||
|
|
||||||
]
|
]
|
||||||
|
@ -288,7 +288,7 @@ class CircuitBulkImportView(generic.BulkImportView):
|
|||||||
'circuits.add_circuittermination',
|
'circuits.add_circuittermination',
|
||||||
]
|
]
|
||||||
related_object_forms = {
|
related_object_forms = {
|
||||||
'terminations': forms.CircuitTerminationImportForm,
|
'terminations': forms.CircuitTerminationImportRelatedForm,
|
||||||
}
|
}
|
||||||
|
|
||||||
def prep_related_object_data(self, parent, data):
|
def prep_related_object_data(self, parent, data):
|
||||||
@ -398,6 +398,18 @@ class CircuitContactsView(ObjectContactsView):
|
|||||||
# Circuit terminations
|
# Circuit terminations
|
||||||
#
|
#
|
||||||
|
|
||||||
|
class CircuitTerminationListView(generic.ObjectListView):
|
||||||
|
queryset = CircuitTermination.objects.all()
|
||||||
|
filterset = filtersets.CircuitTerminationFilterSet
|
||||||
|
filterset_form = forms.CircuitTerminationFilterForm
|
||||||
|
table = tables.CircuitTerminationTable
|
||||||
|
|
||||||
|
|
||||||
|
@register_model_view(CircuitTermination)
|
||||||
|
class CircuitTerminationView(generic.ObjectView):
|
||||||
|
queryset = CircuitTermination.objects.all()
|
||||||
|
|
||||||
|
|
||||||
@register_model_view(CircuitTermination, 'edit')
|
@register_model_view(CircuitTermination, 'edit')
|
||||||
class CircuitTerminationEditView(generic.ObjectEditView):
|
class CircuitTerminationEditView(generic.ObjectEditView):
|
||||||
queryset = CircuitTermination.objects.all()
|
queryset = CircuitTermination.objects.all()
|
||||||
@ -409,5 +421,23 @@ class CircuitTerminationDeleteView(generic.ObjectDeleteView):
|
|||||||
queryset = CircuitTermination.objects.all()
|
queryset = CircuitTermination.objects.all()
|
||||||
|
|
||||||
|
|
||||||
|
class CircuitTerminationBulkImportView(generic.BulkImportView):
|
||||||
|
queryset = CircuitTermination.objects.all()
|
||||||
|
model_form = forms.CircuitTerminationImportForm
|
||||||
|
|
||||||
|
|
||||||
|
class CircuitTerminationBulkEditView(generic.BulkEditView):
|
||||||
|
queryset = CircuitTermination.objects.all()
|
||||||
|
filterset = filtersets.CircuitTerminationFilterSet
|
||||||
|
table = tables.CircuitTerminationTable
|
||||||
|
form = forms.CircuitTerminationBulkEditForm
|
||||||
|
|
||||||
|
|
||||||
|
class CircuitTerminationBulkDeleteView(generic.BulkDeleteView):
|
||||||
|
queryset = CircuitTermination.objects.all()
|
||||||
|
filterset = filtersets.CircuitTerminationFilterSet
|
||||||
|
table = tables.CircuitTerminationTable
|
||||||
|
|
||||||
|
|
||||||
# Trace view
|
# Trace view
|
||||||
register_model_view(CircuitTermination, 'trace', kwargs={'model': CircuitTermination})(PathTraceView)
|
register_model_view(CircuitTermination, 'trace', kwargs={'model': CircuitTermination})(PathTraceView)
|
||||||
|
@ -255,3 +255,14 @@ class NetBoxAutoSchema(AutoSchema):
|
|||||||
if '{id}' in self.path:
|
if '{id}' in self.path:
|
||||||
return f"{self.method.capitalize()} a {model_name} object."
|
return f"{self.method.capitalize()} a {model_name} object."
|
||||||
return f"{self.method.capitalize()} a list of {model_name} objects."
|
return f"{self.method.capitalize()} a list of {model_name} objects."
|
||||||
|
|
||||||
|
|
||||||
|
class FixSerializedPKRelatedField(OpenApiSerializerFieldExtension):
|
||||||
|
target_class = 'netbox.api.fields.SerializedPKRelatedField'
|
||||||
|
|
||||||
|
def map_serializer_field(self, auto_schema, direction):
|
||||||
|
if direction == "response":
|
||||||
|
component = auto_schema.resolve_serializer(self.target.serializer, direction)
|
||||||
|
return component.ref if component else None
|
||||||
|
else:
|
||||||
|
return build_basic_type(OpenApiTypes.INT)
|
||||||
|
@ -220,7 +220,7 @@ class ConfigRevisionRestoreView(ContentTypePermissionRequiredMixin, View):
|
|||||||
for param in PARAMS:
|
for param in PARAMS:
|
||||||
params.append((
|
params.append((
|
||||||
param.name,
|
param.name,
|
||||||
current_config.data.get(param.name, None),
|
current_config.data.get(param.name, None) if current_config else None,
|
||||||
candidate_config.data.get(param.name, None)
|
candidate_config.data.get(param.name, None)
|
||||||
))
|
))
|
||||||
|
|
||||||
|
@ -21,7 +21,7 @@ __all__ = (
|
|||||||
class RegionSerializer(NestedGroupModelSerializer):
|
class RegionSerializer(NestedGroupModelSerializer):
|
||||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:region-detail')
|
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:region-detail')
|
||||||
parent = NestedRegionSerializer(required=False, allow_null=True, default=None)
|
parent = NestedRegionSerializer(required=False, allow_null=True, default=None)
|
||||||
site_count = serializers.IntegerField(read_only=True)
|
site_count = serializers.IntegerField(read_only=True, default=0)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Region
|
model = Region
|
||||||
@ -35,7 +35,7 @@ class RegionSerializer(NestedGroupModelSerializer):
|
|||||||
class SiteGroupSerializer(NestedGroupModelSerializer):
|
class SiteGroupSerializer(NestedGroupModelSerializer):
|
||||||
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:sitegroup-detail')
|
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:sitegroup-detail')
|
||||||
parent = NestedSiteGroupSerializer(required=False, allow_null=True, default=None)
|
parent = NestedSiteGroupSerializer(required=False, allow_null=True, default=None)
|
||||||
site_count = serializers.IntegerField(read_only=True)
|
site_count = serializers.IntegerField(read_only=True, default=0)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = SiteGroup
|
model = SiteGroup
|
||||||
@ -86,8 +86,8 @@ class LocationSerializer(NestedGroupModelSerializer):
|
|||||||
parent = NestedLocationSerializer(required=False, allow_null=True, default=None)
|
parent = NestedLocationSerializer(required=False, allow_null=True, default=None)
|
||||||
status = ChoiceField(choices=LocationStatusChoices, required=False)
|
status = ChoiceField(choices=LocationStatusChoices, required=False)
|
||||||
tenant = TenantSerializer(nested=True, required=False, allow_null=True)
|
tenant = TenantSerializer(nested=True, required=False, allow_null=True)
|
||||||
rack_count = serializers.IntegerField(read_only=True)
|
rack_count = serializers.IntegerField(read_only=True, default=0)
|
||||||
device_count = serializers.IntegerField(read_only=True)
|
device_count = serializers.IntegerField(read_only=True, default=0)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Location
|
model = Location
|
||||||
|
@ -399,6 +399,10 @@ class PowerPortTypeChoices(ChoiceSet):
|
|||||||
TYPE_USB_MICRO_AB = 'usb-micro-ab'
|
TYPE_USB_MICRO_AB = 'usb-micro-ab'
|
||||||
TYPE_USB_3_B = 'usb-3-b'
|
TYPE_USB_3_B = 'usb-3-b'
|
||||||
TYPE_USB_3_MICROB = 'usb-3-micro-b'
|
TYPE_USB_3_MICROB = 'usb-3-micro-b'
|
||||||
|
# Molex
|
||||||
|
TYPE_MOLEX_MICRO_FIT_1X2 = 'molex-micro-fit-1x2'
|
||||||
|
TYPE_MOLEX_MICRO_FIT_2X2 = 'molex-micro-fit-2x2'
|
||||||
|
TYPE_MOLEX_MICRO_FIT_2X4 = 'molex-micro-fit-2x4'
|
||||||
# Direct current (DC)
|
# Direct current (DC)
|
||||||
TYPE_DC = 'dc-terminal'
|
TYPE_DC = 'dc-terminal'
|
||||||
# Proprietary
|
# Proprietary
|
||||||
@ -520,6 +524,11 @@ class PowerPortTypeChoices(ChoiceSet):
|
|||||||
(TYPE_USB_3_B, 'USB 3.0 Type B'),
|
(TYPE_USB_3_B, 'USB 3.0 Type B'),
|
||||||
(TYPE_USB_3_MICROB, 'USB 3.0 Micro B'),
|
(TYPE_USB_3_MICROB, 'USB 3.0 Micro B'),
|
||||||
)),
|
)),
|
||||||
|
('Molex', (
|
||||||
|
(TYPE_MOLEX_MICRO_FIT_1X2, 'Molex Micro-Fit 1x2'),
|
||||||
|
(TYPE_MOLEX_MICRO_FIT_2X2, 'Molex Micro-Fit 2x2'),
|
||||||
|
(TYPE_MOLEX_MICRO_FIT_2X4, 'Molex Micro-Fit 2x4'),
|
||||||
|
)),
|
||||||
('DC', (
|
('DC', (
|
||||||
(TYPE_DC, 'DC Terminal'),
|
(TYPE_DC, 'DC Terminal'),
|
||||||
)),
|
)),
|
||||||
@ -635,6 +644,10 @@ class PowerOutletTypeChoices(ChoiceSet):
|
|||||||
TYPE_USB_A = 'usb-a'
|
TYPE_USB_A = 'usb-a'
|
||||||
TYPE_USB_MICROB = 'usb-micro-b'
|
TYPE_USB_MICROB = 'usb-micro-b'
|
||||||
TYPE_USB_C = 'usb-c'
|
TYPE_USB_C = 'usb-c'
|
||||||
|
# Molex
|
||||||
|
TYPE_MOLEX_MICRO_FIT_1X2 = 'molex-micro-fit-1x2'
|
||||||
|
TYPE_MOLEX_MICRO_FIT_2X2 = 'molex-micro-fit-2x2'
|
||||||
|
TYPE_MOLEX_MICRO_FIT_2X4 = 'molex-micro-fit-2x4'
|
||||||
# Direct current (DC)
|
# Direct current (DC)
|
||||||
TYPE_DC = 'dc-terminal'
|
TYPE_DC = 'dc-terminal'
|
||||||
# Proprietary
|
# Proprietary
|
||||||
@ -749,6 +762,11 @@ class PowerOutletTypeChoices(ChoiceSet):
|
|||||||
(TYPE_USB_MICROB, 'USB Micro B'),
|
(TYPE_USB_MICROB, 'USB Micro B'),
|
||||||
(TYPE_USB_C, 'USB Type C'),
|
(TYPE_USB_C, 'USB Type C'),
|
||||||
)),
|
)),
|
||||||
|
('Molex', (
|
||||||
|
(TYPE_MOLEX_MICRO_FIT_1X2, 'Molex Micro-Fit 1x2'),
|
||||||
|
(TYPE_MOLEX_MICRO_FIT_2X2, 'Molex Micro-Fit 2x2'),
|
||||||
|
(TYPE_MOLEX_MICRO_FIT_2X4, 'Molex Micro-Fit 2x4'),
|
||||||
|
)),
|
||||||
('DC', (
|
('DC', (
|
||||||
(TYPE_DC, 'DC Terminal'),
|
(TYPE_DC, 'DC Terminal'),
|
||||||
)),
|
)),
|
||||||
@ -810,6 +828,7 @@ class InterfaceTypeChoices(ChoiceSet):
|
|||||||
TYPE_100ME_FIXED = '100base-tx'
|
TYPE_100ME_FIXED = '100base-tx'
|
||||||
TYPE_100ME_T1 = '100base-t1'
|
TYPE_100ME_T1 = '100base-t1'
|
||||||
TYPE_1GE_FIXED = '1000base-t'
|
TYPE_1GE_FIXED = '1000base-t'
|
||||||
|
TYPE_1GE_TX_FIXED = '1000base-tx'
|
||||||
TYPE_1GE_GBIC = '1000base-x-gbic'
|
TYPE_1GE_GBIC = '1000base-x-gbic'
|
||||||
TYPE_1GE_SFP = '1000base-x-sfp'
|
TYPE_1GE_SFP = '1000base-x-sfp'
|
||||||
TYPE_2GE_FIXED = '2.5gbase-t'
|
TYPE_2GE_FIXED = '2.5gbase-t'
|
||||||
@ -874,6 +893,8 @@ class InterfaceTypeChoices(ChoiceSet):
|
|||||||
TYPE_GSM = 'gsm'
|
TYPE_GSM = 'gsm'
|
||||||
TYPE_CDMA = 'cdma'
|
TYPE_CDMA = 'cdma'
|
||||||
TYPE_LTE = 'lte'
|
TYPE_LTE = 'lte'
|
||||||
|
TYPE_4G = '4g'
|
||||||
|
TYPE_5G = '5g'
|
||||||
|
|
||||||
# SONET
|
# SONET
|
||||||
TYPE_SONET_OC3 = 'sonet-oc3'
|
TYPE_SONET_OC3 = 'sonet-oc3'
|
||||||
@ -921,12 +942,15 @@ class InterfaceTypeChoices(ChoiceSet):
|
|||||||
TYPE_DOCSIS = 'docsis'
|
TYPE_DOCSIS = 'docsis'
|
||||||
|
|
||||||
# PON
|
# PON
|
||||||
|
TYPE_BPON = 'bpon'
|
||||||
|
TYPE_EPON = 'epon'
|
||||||
|
TYPE_10G_EPON = '10g-epon'
|
||||||
TYPE_GPON = 'gpon'
|
TYPE_GPON = 'gpon'
|
||||||
TYPE_XG_PON = 'xg-pon'
|
TYPE_XG_PON = 'xg-pon'
|
||||||
TYPE_XGS_PON = 'xgs-pon'
|
TYPE_XGS_PON = 'xgs-pon'
|
||||||
TYPE_NG_PON2 = 'ng-pon2'
|
TYPE_NG_PON2 = 'ng-pon2'
|
||||||
TYPE_EPON = 'epon'
|
TYPE_25G_PON = '25g-pon'
|
||||||
TYPE_10G_EPON = '10g-epon'
|
TYPE_50G_PON = '50g-pon'
|
||||||
|
|
||||||
# Stacking
|
# Stacking
|
||||||
TYPE_STACKWISE = 'cisco-stackwise'
|
TYPE_STACKWISE = 'cisco-stackwise'
|
||||||
@ -964,6 +988,7 @@ class InterfaceTypeChoices(ChoiceSet):
|
|||||||
(TYPE_100ME_FIXED, '100BASE-TX (10/100ME)'),
|
(TYPE_100ME_FIXED, '100BASE-TX (10/100ME)'),
|
||||||
(TYPE_100ME_T1, '100BASE-T1 (10/100ME Single Pair)'),
|
(TYPE_100ME_T1, '100BASE-T1 (10/100ME Single Pair)'),
|
||||||
(TYPE_1GE_FIXED, '1000BASE-T (1GE)'),
|
(TYPE_1GE_FIXED, '1000BASE-T (1GE)'),
|
||||||
|
(TYPE_1GE_TX_FIXED, '1000BASE-TX (1GE)'),
|
||||||
(TYPE_2GE_FIXED, '2.5GBASE-T (2.5GE)'),
|
(TYPE_2GE_FIXED, '2.5GBASE-T (2.5GE)'),
|
||||||
(TYPE_5GE_FIXED, '5GBASE-T (5GE)'),
|
(TYPE_5GE_FIXED, '5GBASE-T (5GE)'),
|
||||||
(TYPE_10GE_FIXED, '10GBASE-T (10GE)'),
|
(TYPE_10GE_FIXED, '10GBASE-T (10GE)'),
|
||||||
@ -1042,6 +1067,8 @@ class InterfaceTypeChoices(ChoiceSet):
|
|||||||
(TYPE_GSM, 'GSM'),
|
(TYPE_GSM, 'GSM'),
|
||||||
(TYPE_CDMA, 'CDMA'),
|
(TYPE_CDMA, 'CDMA'),
|
||||||
(TYPE_LTE, 'LTE'),
|
(TYPE_LTE, 'LTE'),
|
||||||
|
(TYPE_4G, '4G'),
|
||||||
|
(TYPE_5G, '5G'),
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
@ -1110,12 +1137,15 @@ class InterfaceTypeChoices(ChoiceSet):
|
|||||||
(
|
(
|
||||||
'PON',
|
'PON',
|
||||||
(
|
(
|
||||||
(TYPE_GPON, 'GPON (2.5 Gbps / 1.25 Gps)'),
|
(TYPE_BPON, 'BPON (622 Mbps / 155 Mbps)'),
|
||||||
|
(TYPE_EPON, 'EPON (1 Gbps)'),
|
||||||
|
(TYPE_10G_EPON, '10G-EPON (10 Gbps)'),
|
||||||
|
(TYPE_GPON, 'GPON (2.5 Gbps / 1.25 Gbps)'),
|
||||||
(TYPE_XG_PON, 'XG-PON (10 Gbps / 2.5 Gbps)'),
|
(TYPE_XG_PON, 'XG-PON (10 Gbps / 2.5 Gbps)'),
|
||||||
(TYPE_XGS_PON, 'XGS-PON (10 Gbps)'),
|
(TYPE_XGS_PON, 'XGS-PON (10 Gbps)'),
|
||||||
(TYPE_NG_PON2, 'NG-PON2 (TWDM-PON) (4x10 Gbps)'),
|
(TYPE_NG_PON2, 'NG-PON2 (TWDM-PON) (4x10 Gbps)'),
|
||||||
(TYPE_EPON, 'EPON (1 Gbps)'),
|
(TYPE_25G_PON, '25G-PON (25 Gbps)'),
|
||||||
(TYPE_10G_EPON, '10G-EPON (10 Gbps)'),
|
(TYPE_50G_PON, '50G-PON (50 Gbps)'),
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
|
@ -1100,6 +1100,10 @@ class DeviceFilterSet(
|
|||||||
queryset=IPAddress.objects.all(),
|
queryset=IPAddress.objects.all(),
|
||||||
label=_('OOB IP (ID)'),
|
label=_('OOB IP (ID)'),
|
||||||
)
|
)
|
||||||
|
has_virtual_device_context = django_filters.BooleanFilter(
|
||||||
|
method='_has_virtual_device_context',
|
||||||
|
label=_('Has virtual device context'),
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Device
|
model = Device
|
||||||
@ -1176,6 +1180,12 @@ class DeviceFilterSet(
|
|||||||
def _device_bays(self, queryset, name, value):
|
def _device_bays(self, queryset, name, value):
|
||||||
return queryset.exclude(devicebays__isnull=value)
|
return queryset.exclude(devicebays__isnull=value)
|
||||||
|
|
||||||
|
def _has_virtual_device_context(self, queryset, name, value):
|
||||||
|
params = Q(vdcs__isnull=False)
|
||||||
|
if value:
|
||||||
|
return queryset.filter(params).distinct()
|
||||||
|
return queryset.exclude(params)
|
||||||
|
|
||||||
|
|
||||||
class VirtualDeviceContextFilterSet(NetBoxModelFilterSet, TenancyFilterSet, PrimaryIPFilterSet):
|
class VirtualDeviceContextFilterSet(NetBoxModelFilterSet, TenancyFilterSet, PrimaryIPFilterSet):
|
||||||
device_id = django_filters.ModelMultipleChoiceFilter(
|
device_id = django_filters.ModelMultipleChoiceFilter(
|
||||||
|
@ -657,6 +657,7 @@ class DeviceFilterForm(
|
|||||||
),
|
),
|
||||||
FieldSet(
|
FieldSet(
|
||||||
'has_primary_ip', 'has_oob_ip', 'virtual_chassis_member', 'config_template_id', 'local_context_data',
|
'has_primary_ip', 'has_oob_ip', 'virtual_chassis_member', 'config_template_id', 'local_context_data',
|
||||||
|
'has_virtual_device_context',
|
||||||
name=_('Miscellaneous')
|
name=_('Miscellaneous')
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@ -813,6 +814,13 @@ class DeviceFilterForm(
|
|||||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
has_virtual_device_context = forms.NullBooleanField(
|
||||||
|
required=False,
|
||||||
|
label=_('Has virtual device contexts'),
|
||||||
|
widget=forms.Select(
|
||||||
|
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||||
|
)
|
||||||
|
)
|
||||||
tag = TagFilterField(model)
|
tag = TagFilterField(model)
|
||||||
|
|
||||||
|
|
||||||
|
@ -355,11 +355,11 @@ class CableTermination(ChangeLoggedModel):
|
|||||||
super().save(*args, **kwargs)
|
super().save(*args, **kwargs)
|
||||||
|
|
||||||
# Set the cable on the terminating object
|
# Set the cable on the terminating object
|
||||||
termination_model = self.termination._meta.model
|
termination = self.termination._meta.model.objects.get(pk=self.termination_id)
|
||||||
termination_model.objects.filter(pk=self.termination_id).update(
|
termination.snapshot()
|
||||||
cable=self.cable,
|
termination.cable = self.cable
|
||||||
cable_end=self.cable_end
|
termination.cable_end = self.cable_end
|
||||||
)
|
termination.save()
|
||||||
|
|
||||||
def delete(self, *args, **kwargs):
|
def delete(self, *args, **kwargs):
|
||||||
|
|
||||||
|
@ -43,14 +43,6 @@ MODULEBAY_STATUS = """
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def get_cabletermination_row_class(record):
|
|
||||||
if record.mark_connected:
|
|
||||||
return 'success'
|
|
||||||
elif record.cable:
|
|
||||||
return record.cable.get_status_color()
|
|
||||||
return ''
|
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Device roles
|
# Device roles
|
||||||
#
|
#
|
||||||
@ -313,6 +305,10 @@ class ModularDeviceComponentTable(DeviceComponentTable):
|
|||||||
verbose_name=_('Module'),
|
verbose_name=_('Module'),
|
||||||
linkify=True
|
linkify=True
|
||||||
)
|
)
|
||||||
|
inventory_items = columns.ManyToManyColumn(
|
||||||
|
linkify_item=True,
|
||||||
|
verbose_name=_('Inventory Items'),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class CableTerminationTable(NetBoxTable):
|
class CableTerminationTable(NetBoxTable):
|
||||||
@ -335,6 +331,14 @@ class CableTerminationTable(NetBoxTable):
|
|||||||
verbose_name=_('Mark Connected'),
|
verbose_name=_('Mark Connected'),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
row_attrs = {
|
||||||
|
'data-name': lambda record: record.name,
|
||||||
|
'data-mark-connected': lambda record: "true" if record.mark_connected else "false",
|
||||||
|
'data-cable-status': lambda record: record.cable.status if record.cable else "",
|
||||||
|
'data-type': lambda record: record.type
|
||||||
|
}
|
||||||
|
|
||||||
def value_link_peer(self, value):
|
def value_link_peer(self, value):
|
||||||
return ', '.join([
|
return ', '.join([
|
||||||
f"{termination.parent_object} > {termination}" for termination in value
|
f"{termination.parent_object} > {termination}" for termination in value
|
||||||
@ -366,7 +370,7 @@ class ConsolePortTable(ModularDeviceComponentTable, PathEndpointTable):
|
|||||||
model = models.ConsolePort
|
model = models.ConsolePort
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'speed', 'description',
|
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'speed', 'description',
|
||||||
'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created', 'last_updated',
|
'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'inventory_items', 'tags', 'created', 'last_updated',
|
||||||
)
|
)
|
||||||
default_columns = ('pk', 'name', 'device', 'label', 'type', 'speed', 'description')
|
default_columns = ('pk', 'name', 'device', 'label', 'type', 'speed', 'description')
|
||||||
|
|
||||||
@ -382,16 +386,13 @@ class DeviceConsolePortTable(ConsolePortTable):
|
|||||||
extra_buttons=CONSOLEPORT_BUTTONS
|
extra_buttons=CONSOLEPORT_BUTTONS
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta(DeviceComponentTable.Meta):
|
class Meta(CableTerminationTable.Meta, DeviceComponentTable.Meta):
|
||||||
model = models.ConsolePort
|
model = models.ConsolePort
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'speed', 'description', 'mark_connected',
|
'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'speed', 'description', 'mark_connected',
|
||||||
'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions'
|
'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions'
|
||||||
)
|
)
|
||||||
default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection')
|
default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection')
|
||||||
row_attrs = {
|
|
||||||
'class': get_cabletermination_row_class
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class ConsoleServerPortTable(ModularDeviceComponentTable, PathEndpointTable):
|
class ConsoleServerPortTable(ModularDeviceComponentTable, PathEndpointTable):
|
||||||
@ -410,7 +411,7 @@ class ConsoleServerPortTable(ModularDeviceComponentTable, PathEndpointTable):
|
|||||||
model = models.ConsoleServerPort
|
model = models.ConsoleServerPort
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'speed', 'description',
|
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'speed', 'description',
|
||||||
'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created', 'last_updated',
|
'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'inventory_items', 'tags', 'created', 'last_updated',
|
||||||
)
|
)
|
||||||
default_columns = ('pk', 'name', 'device', 'label', 'type', 'speed', 'description')
|
default_columns = ('pk', 'name', 'device', 'label', 'type', 'speed', 'description')
|
||||||
|
|
||||||
@ -427,16 +428,13 @@ class DeviceConsoleServerPortTable(ConsoleServerPortTable):
|
|||||||
extra_buttons=CONSOLESERVERPORT_BUTTONS
|
extra_buttons=CONSOLESERVERPORT_BUTTONS
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta(DeviceComponentTable.Meta):
|
class Meta(CableTerminationTable.Meta, DeviceComponentTable.Meta):
|
||||||
model = models.ConsoleServerPort
|
model = models.ConsoleServerPort
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'speed', 'description', 'mark_connected',
|
'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'speed', 'description', 'mark_connected',
|
||||||
'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions',
|
'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'actions',
|
||||||
)
|
)
|
||||||
default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection')
|
default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection')
|
||||||
row_attrs = {
|
|
||||||
'class': get_cabletermination_row_class
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class PowerPortTable(ModularDeviceComponentTable, PathEndpointTable):
|
class PowerPortTable(ModularDeviceComponentTable, PathEndpointTable):
|
||||||
@ -461,8 +459,8 @@ class PowerPortTable(ModularDeviceComponentTable, PathEndpointTable):
|
|||||||
model = models.PowerPort
|
model = models.PowerPort
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'description', 'mark_connected',
|
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'description', 'mark_connected',
|
||||||
'maximum_draw', 'allocated_draw', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created',
|
'maximum_draw', 'allocated_draw', 'cable', 'cable_color', 'link_peer', 'connection', 'inventory_items',
|
||||||
'last_updated',
|
'tags', 'created', 'last_updated',
|
||||||
)
|
)
|
||||||
default_columns = ('pk', 'name', 'device', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description')
|
default_columns = ('pk', 'name', 'device', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description')
|
||||||
|
|
||||||
@ -479,7 +477,7 @@ class DevicePowerPortTable(PowerPortTable):
|
|||||||
extra_buttons=POWERPORT_BUTTONS
|
extra_buttons=POWERPORT_BUTTONS
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta(DeviceComponentTable.Meta):
|
class Meta(CableTerminationTable.Meta, DeviceComponentTable.Meta):
|
||||||
model = models.PowerPort
|
model = models.PowerPort
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'maximum_draw', 'allocated_draw',
|
'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'maximum_draw', 'allocated_draw',
|
||||||
@ -488,9 +486,6 @@ class DevicePowerPortTable(PowerPortTable):
|
|||||||
default_columns = (
|
default_columns = (
|
||||||
'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'cable', 'connection',
|
'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'cable', 'connection',
|
||||||
)
|
)
|
||||||
row_attrs = {
|
|
||||||
'class': get_cabletermination_row_class
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class PowerOutletTable(ModularDeviceComponentTable, PathEndpointTable):
|
class PowerOutletTable(ModularDeviceComponentTable, PathEndpointTable):
|
||||||
@ -513,8 +508,8 @@ class PowerOutletTable(ModularDeviceComponentTable, PathEndpointTable):
|
|||||||
model = models.PowerOutlet
|
model = models.PowerOutlet
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'description', 'power_port',
|
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'description', 'power_port',
|
||||||
'feed_leg', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'tags', 'created',
|
'feed_leg', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'inventory_items',
|
||||||
'last_updated',
|
'tags', 'created', 'last_updated',
|
||||||
)
|
)
|
||||||
default_columns = ('pk', 'name', 'device', 'label', 'type', 'power_port', 'feed_leg', 'description')
|
default_columns = ('pk', 'name', 'device', 'label', 'type', 'power_port', 'feed_leg', 'description')
|
||||||
|
|
||||||
@ -530,7 +525,7 @@ class DevicePowerOutletTable(PowerOutletTable):
|
|||||||
extra_buttons=POWEROUTLET_BUTTONS
|
extra_buttons=POWEROUTLET_BUTTONS
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta(DeviceComponentTable.Meta):
|
class Meta(CableTerminationTable.Meta, DeviceComponentTable.Meta):
|
||||||
model = models.PowerOutlet
|
model = models.PowerOutlet
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'power_port', 'feed_leg', 'description',
|
'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'power_port', 'feed_leg', 'description',
|
||||||
@ -539,9 +534,6 @@ class DevicePowerOutletTable(PowerOutletTable):
|
|||||||
default_columns = (
|
default_columns = (
|
||||||
'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'cable', 'connection',
|
'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'cable', 'connection',
|
||||||
)
|
)
|
||||||
row_attrs = {
|
|
||||||
'class': get_cabletermination_row_class
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class BaseInterfaceTable(NetBoxTable):
|
class BaseInterfaceTable(NetBoxTable):
|
||||||
@ -618,10 +610,6 @@ class InterfaceTable(ModularDeviceComponentTable, BaseInterfaceTable, PathEndpoi
|
|||||||
verbose_name=_('VRF'),
|
verbose_name=_('VRF'),
|
||||||
linkify=True
|
linkify=True
|
||||||
)
|
)
|
||||||
inventory_items = columns.ManyToManyColumn(
|
|
||||||
linkify_item=True,
|
|
||||||
verbose_name=_('Inventory Items'),
|
|
||||||
)
|
|
||||||
tags = columns.TagColumn(
|
tags = columns.TagColumn(
|
||||||
url_name='dcim:interface_list'
|
url_name='dcim:interface_list'
|
||||||
)
|
)
|
||||||
@ -713,8 +701,8 @@ class FrontPortTable(ModularDeviceComponentTable, CableTerminationTable):
|
|||||||
model = models.FrontPort
|
model = models.FrontPort
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'color', 'rear_port',
|
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'color', 'rear_port',
|
||||||
'rear_port_position', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags',
|
'rear_port_position', 'description', 'mark_connected', 'cable', 'cable_color', 'link_peer',
|
||||||
'created', 'last_updated',
|
'inventory_items', 'tags', 'created', 'last_updated',
|
||||||
)
|
)
|
||||||
default_columns = (
|
default_columns = (
|
||||||
'pk', 'name', 'device', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description',
|
'pk', 'name', 'device', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description',
|
||||||
@ -733,7 +721,7 @@ class DeviceFrontPortTable(FrontPortTable):
|
|||||||
extra_buttons=FRONTPORT_BUTTONS
|
extra_buttons=FRONTPORT_BUTTONS
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta(DeviceComponentTable.Meta):
|
class Meta(CableTerminationTable.Meta, DeviceComponentTable.Meta):
|
||||||
model = models.FrontPort
|
model = models.FrontPort
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'rear_port', 'rear_port_position',
|
'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'rear_port', 'rear_port_position',
|
||||||
@ -742,9 +730,6 @@ class DeviceFrontPortTable(FrontPortTable):
|
|||||||
default_columns = (
|
default_columns = (
|
||||||
'pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'link_peer',
|
'pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'link_peer',
|
||||||
)
|
)
|
||||||
row_attrs = {
|
|
||||||
'class': get_cabletermination_row_class
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class RearPortTable(ModularDeviceComponentTable, CableTerminationTable):
|
class RearPortTable(ModularDeviceComponentTable, CableTerminationTable):
|
||||||
@ -766,7 +751,7 @@ class RearPortTable(ModularDeviceComponentTable, CableTerminationTable):
|
|||||||
model = models.RearPort
|
model = models.RearPort
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'color', 'positions', 'description',
|
'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'type', 'color', 'positions', 'description',
|
||||||
'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags', 'created', 'last_updated',
|
'mark_connected', 'cable', 'cable_color', 'link_peer', 'inventory_items', 'tags', 'created', 'last_updated',
|
||||||
)
|
)
|
||||||
default_columns = ('pk', 'name', 'device', 'label', 'type', 'color', 'description')
|
default_columns = ('pk', 'name', 'device', 'label', 'type', 'color', 'description')
|
||||||
|
|
||||||
@ -783,7 +768,7 @@ class DeviceRearPortTable(RearPortTable):
|
|||||||
extra_buttons=REARPORT_BUTTONS
|
extra_buttons=REARPORT_BUTTONS
|
||||||
)
|
)
|
||||||
|
|
||||||
class Meta(DeviceComponentTable.Meta):
|
class Meta(CableTerminationTable.Meta, DeviceComponentTable.Meta):
|
||||||
model = models.RearPort
|
model = models.RearPort
|
||||||
fields = (
|
fields = (
|
||||||
'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'positions', 'description', 'mark_connected',
|
'pk', 'id', 'name', 'module_bay', 'module', 'label', 'type', 'positions', 'description', 'mark_connected',
|
||||||
@ -792,9 +777,6 @@ class DeviceRearPortTable(RearPortTable):
|
|||||||
default_columns = (
|
default_columns = (
|
||||||
'pk', 'name', 'label', 'type', 'positions', 'description', 'cable', 'link_peer',
|
'pk', 'name', 'label', 'type', 'positions', 'description', 'cable', 'link_peer',
|
||||||
)
|
)
|
||||||
row_attrs = {
|
|
||||||
'class': get_cabletermination_row_class
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
class DeviceBayTable(DeviceComponentTable):
|
class DeviceBayTable(DeviceComponentTable):
|
||||||
|
@ -2103,6 +2103,9 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
Device.objects.filter(pk=devices[0].pk).update(virtual_chassis=virtual_chassis, vc_position=1, vc_priority=1)
|
Device.objects.filter(pk=devices[0].pk).update(virtual_chassis=virtual_chassis, vc_position=1, vc_priority=1)
|
||||||
Device.objects.filter(pk=devices[1].pk).update(virtual_chassis=virtual_chassis, vc_position=2, vc_priority=2)
|
Device.objects.filter(pk=devices[1].pk).update(virtual_chassis=virtual_chassis, vc_position=2, vc_priority=2)
|
||||||
|
|
||||||
|
# VirtualDeviceContext assignment for filtering
|
||||||
|
VirtualDeviceContext.objects.create(device=devices[0], name="VDC 1", identifier=1, status='active')
|
||||||
|
|
||||||
def test_q(self):
|
def test_q(self):
|
||||||
params = {'q': 'foobar1'}
|
params = {'q': 'foobar1'}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||||
@ -2336,6 +2339,12 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
|
|||||||
params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
|
params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
|
||||||
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
|
def test_has_virtual_device_context(self):
|
||||||
|
params = {'has_virtual_device_context': 'true'}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
|
||||||
|
params = {'has_virtual_device_context': 'false'}
|
||||||
|
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
|
|
||||||
class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests):
|
class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests):
|
||||||
queryset = Module.objects.all()
|
queryset = Module.objects.all()
|
||||||
|
@ -30,6 +30,16 @@ class ObjectChangeSerializer(BaseModelSerializer):
|
|||||||
changed_object = serializers.SerializerMethodField(
|
changed_object = serializers.SerializerMethodField(
|
||||||
read_only=True
|
read_only=True
|
||||||
)
|
)
|
||||||
|
prechange_data = serializers.JSONField(
|
||||||
|
source='prechange_data_clean',
|
||||||
|
read_only=True,
|
||||||
|
allow_null=True
|
||||||
|
)
|
||||||
|
postchange_data = serializers.JSONField(
|
||||||
|
source='postchange_data_clean',
|
||||||
|
read_only=True,
|
||||||
|
allow_null=True
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ObjectChange
|
model = ObjectChange
|
||||||
|
@ -43,7 +43,7 @@ class JournalEntrySerializer(NetBoxModelSerializer):
|
|||||||
def validate(self, data):
|
def validate(self, data):
|
||||||
|
|
||||||
# Validate that the parent object exists
|
# Validate that the parent object exists
|
||||||
if 'assigned_object_type' in data and 'assigned_object_id' in data:
|
if not self.nested and 'assigned_object_type' in data and 'assigned_object_id' in data:
|
||||||
try:
|
try:
|
||||||
data['assigned_object_type'].get_object_for_this_type(id=data['assigned_object_id'])
|
data['assigned_object_type'].get_object_for_this_type(id=data['assigned_object_id'])
|
||||||
except ObjectDoesNotExist:
|
except ObjectDoesNotExist:
|
||||||
@ -51,10 +51,7 @@ class JournalEntrySerializer(NetBoxModelSerializer):
|
|||||||
f"Invalid assigned_object: {data['assigned_object_type']} ID {data['assigned_object_id']}"
|
f"Invalid assigned_object: {data['assigned_object_type']} ID {data['assigned_object_id']}"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Enforce model validation
|
return super().validate(data)
|
||||||
super().validate(data)
|
|
||||||
|
|
||||||
return data
|
|
||||||
|
|
||||||
@extend_schema_field(serializers.JSONField(allow_null=True))
|
@extend_schema_field(serializers.JSONField(allow_null=True))
|
||||||
def get_assigned_object(self, instance):
|
def get_assigned_object(self, instance):
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
from django.http import Http404
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from django_rq.queues import get_connection
|
from django_rq.queues import get_connection
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
@ -215,21 +216,32 @@ class ScriptViewSet(ModelViewSet):
|
|||||||
_ignore_model_permissions = True
|
_ignore_model_permissions = True
|
||||||
lookup_value_regex = '[^/]+' # Allow dots
|
lookup_value_regex = '[^/]+' # Allow dots
|
||||||
|
|
||||||
|
def _get_script(self, pk):
|
||||||
|
# If pk is numeric, retrieve script by ID
|
||||||
|
if pk.isnumeric():
|
||||||
|
return get_object_or_404(self.queryset, pk=pk)
|
||||||
|
|
||||||
|
# Default to retrieval by module & name
|
||||||
|
try:
|
||||||
|
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):
|
def retrieve(self, request, pk):
|
||||||
script = get_object_or_404(self.queryset, pk=pk)
|
script = self._get_script(pk)
|
||||||
serializer = serializers.ScriptDetailSerializer(script, context={'request': request})
|
serializer = serializers.ScriptDetailSerializer(script, context={'request': request})
|
||||||
|
|
||||||
return Response(serializer.data)
|
return Response(serializer.data)
|
||||||
|
|
||||||
def post(self, request, pk):
|
def post(self, request, pk):
|
||||||
"""
|
"""
|
||||||
Run a Script identified by the id and return the pending Job as the result
|
Run a Script identified by its numeric PK or module & name and return the pending Job as the result
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if not request.user.has_perm('extras.run_script'):
|
if not request.user.has_perm('extras.run_script'):
|
||||||
raise PermissionDenied("This user does not have permission to run scripts.")
|
raise PermissionDenied("This user does not have permission to run scripts.")
|
||||||
|
|
||||||
script = get_object_or_404(self.queryset, pk=pk)
|
script = self._get_script(pk)
|
||||||
input_serializer = serializers.ScriptInputSerializer(
|
input_serializer = serializers.ScriptInputSerializer(
|
||||||
data=request.data,
|
data=request.data,
|
||||||
context={'script': script}
|
context={'script': script}
|
||||||
|
@ -135,23 +135,23 @@ class ConditionSet:
|
|||||||
def __init__(self, ruleset):
|
def __init__(self, ruleset):
|
||||||
if type(ruleset) is not dict:
|
if type(ruleset) is not dict:
|
||||||
raise ValueError(_("Ruleset must be a dictionary, not {ruleset}.").format(ruleset=type(ruleset)))
|
raise ValueError(_("Ruleset must be a dictionary, not {ruleset}.").format(ruleset=type(ruleset)))
|
||||||
if len(ruleset) != 1:
|
|
||||||
raise ValueError(_("Ruleset must have exactly one logical operator (found {ruleset})").format(
|
|
||||||
ruleset=len(ruleset)))
|
|
||||||
|
|
||||||
# Determine the logic type
|
if len(ruleset) == 1:
|
||||||
logic = list(ruleset.keys())[0]
|
self.logic = (list(ruleset.keys())[0]).lower()
|
||||||
if type(logic) is not str or logic.lower() not in (AND, OR):
|
if self.logic not in (AND, OR):
|
||||||
raise ValueError(_("Invalid logic type: {logic} (must be '{op_and}' or '{op_or}')").format(
|
raise ValueError(_("Invalid logic type: must be 'AND' or 'OR'. Please check documentation."))
|
||||||
logic=logic, op_and=AND, op_or=OR
|
|
||||||
))
|
|
||||||
self.logic = logic.lower()
|
|
||||||
|
|
||||||
# Compile the set of Conditions
|
# Compile the set of Conditions
|
||||||
self.conditions = [
|
self.conditions = [
|
||||||
ConditionSet(rule) if is_ruleset(rule) else Condition(**rule)
|
ConditionSet(rule) if is_ruleset(rule) else Condition(**rule)
|
||||||
for rule in ruleset[self.logic]
|
for rule in ruleset[self.logic]
|
||||||
]
|
]
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
self.logic = None
|
||||||
|
self.conditions = [Condition(**ruleset)]
|
||||||
|
except TypeError:
|
||||||
|
raise ValueError(_("Incorrect key(s) informed. Please check documentation."))
|
||||||
|
|
||||||
def eval(self, data):
|
def eval(self, data):
|
||||||
"""
|
"""
|
||||||
|
@ -13,13 +13,14 @@ def event_tracking(request):
|
|||||||
:param request: WSGIRequest object with a unique `id` set
|
:param request: WSGIRequest object with a unique `id` set
|
||||||
"""
|
"""
|
||||||
current_request.set(request)
|
current_request.set(request)
|
||||||
events_queue.set([])
|
events_queue.set({})
|
||||||
|
|
||||||
yield
|
yield
|
||||||
|
|
||||||
# Flush queued webhooks to RQ
|
# Flush queued webhooks to RQ
|
||||||
flush_events(events_queue.get())
|
if events := list(events_queue.get().values()):
|
||||||
|
flush_events(events)
|
||||||
|
|
||||||
# Clear context vars
|
# Clear context vars
|
||||||
current_request.set(None)
|
current_request.set(None)
|
||||||
events_queue.set([])
|
events_queue.set({})
|
||||||
|
@ -265,6 +265,7 @@ class ObjectListWidget(DashboardWidget):
|
|||||||
parameters = self.config.get('url_params') or {}
|
parameters = self.config.get('url_params') or {}
|
||||||
if page_size := self.config.get('page_size'):
|
if page_size := self.config.get('page_size'):
|
||||||
parameters['per_page'] = page_size
|
parameters['per_page'] = page_size
|
||||||
|
parameters['embedded'] = True
|
||||||
|
|
||||||
if parameters:
|
if parameters:
|
||||||
try:
|
try:
|
||||||
|
@ -58,15 +58,21 @@ def enqueue_object(queue, instance, user, request_id, action):
|
|||||||
if model_name not in registry['model_features']['event_rules'].get(app_label, []):
|
if model_name not in registry['model_features']['event_rules'].get(app_label, []):
|
||||||
return
|
return
|
||||||
|
|
||||||
queue.append({
|
assert instance.pk is not None
|
||||||
'content_type': ContentType.objects.get_for_model(instance),
|
key = f'{app_label}.{model_name}:{instance.pk}'
|
||||||
'object_id': instance.pk,
|
if key in queue:
|
||||||
'event': action,
|
queue[key]['data'] = serialize_for_event(instance)
|
||||||
'data': serialize_for_event(instance),
|
queue[key]['snapshots']['postchange'] = get_snapshots(instance, action)['postchange']
|
||||||
'snapshots': get_snapshots(instance, action),
|
else:
|
||||||
'username': user.username,
|
queue[key] = {
|
||||||
'request_id': request_id
|
'content_type': ContentType.objects.get_for_model(instance),
|
||||||
})
|
'object_id': instance.pk,
|
||||||
|
'event': action,
|
||||||
|
'data': serialize_for_event(instance),
|
||||||
|
'snapshots': get_snapshots(instance, action),
|
||||||
|
'username': user.username,
|
||||||
|
'request_id': request_id
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def process_event_rules(event_rules, model_name, event, data, username=None, snapshots=None, request_id=None):
|
def process_event_rules(event_rules, model_name, event, data, username=None, snapshots=None, request_id=None):
|
||||||
@ -163,14 +169,14 @@ def process_event_queue(events):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def flush_events(queue):
|
def flush_events(events):
|
||||||
"""
|
"""
|
||||||
Flush a list of object representation to RQ for webhook processing.
|
Flush a list of object representations to RQ for event processing.
|
||||||
"""
|
"""
|
||||||
if queue:
|
if events:
|
||||||
for name in settings.EVENTS_PIPELINE:
|
for name in settings.EVENTS_PIPELINE:
|
||||||
try:
|
try:
|
||||||
func = import_string(name)
|
func = import_string(name)
|
||||||
func(queue)
|
func(events)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(_("Cannot import events pipeline {name} error: {error}").format(name=name, error=e))
|
logger.error(_("Cannot import events pipeline {name} error: {error}").format(name=name, error=e))
|
||||||
|
@ -464,13 +464,10 @@ class JournalEntryFilterForm(NetBoxModelFilterSetForm):
|
|||||||
required=False,
|
required=False,
|
||||||
label=_('User')
|
label=_('User')
|
||||||
)
|
)
|
||||||
assigned_object_type_id = DynamicModelMultipleChoiceField(
|
assigned_object_type_id = ContentTypeMultipleChoiceField(
|
||||||
queryset=ObjectType.objects.all(),
|
queryset=ObjectType.objects.with_feature('journaling'),
|
||||||
required=False,
|
required=False,
|
||||||
label=_('Object Type'),
|
label=_('Object Type'),
|
||||||
widget=APISelectMultiple(
|
|
||||||
api_url='/api/extras/content-types/',
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
kind = forms.ChoiceField(
|
kind = forms.ChoiceField(
|
||||||
label=_('Kind'),
|
label=_('Kind'),
|
||||||
@ -507,11 +504,8 @@ class ObjectChangeFilterForm(SavedFiltersMixin, FilterForm):
|
|||||||
required=False,
|
required=False,
|
||||||
label=_('User')
|
label=_('User')
|
||||||
)
|
)
|
||||||
changed_object_type_id = DynamicModelMultipleChoiceField(
|
changed_object_type_id = ContentTypeMultipleChoiceField(
|
||||||
queryset=ObjectType.objects.all(),
|
queryset=ObjectType.objects.with_feature('change_logging'),
|
||||||
required=False,
|
required=False,
|
||||||
label=_('Object Type'),
|
label=_('Object Type'),
|
||||||
widget=APISelectMultiple(
|
|
||||||
api_url='/api/extras/content-types/',
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
@ -122,7 +122,7 @@ class CustomFieldChoiceSetForm(forms.ModelForm):
|
|||||||
label = label.replace('\\:', ':')
|
label = label.replace('\\:', ':')
|
||||||
except ValueError:
|
except ValueError:
|
||||||
value, label = line, line
|
value, label = line, line
|
||||||
data.append((value, label))
|
data.append((value.strip(), label.strip()))
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
@ -1,12 +1,17 @@
|
|||||||
|
from functools import cached_property
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from mptt.models import MPTTModel
|
||||||
|
|
||||||
from core.models import ObjectType
|
from core.models import ObjectType
|
||||||
from extras.choices import *
|
from extras.choices import *
|
||||||
|
from netbox.models.features import ChangeLoggingMixin
|
||||||
|
from utilities.data import shallow_compare_dict
|
||||||
from ..querysets import ObjectChangeQuerySet
|
from ..querysets import ObjectChangeQuerySet
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
@ -136,6 +141,71 @@ class ObjectChange(models.Model):
|
|||||||
def get_action_color(self):
|
def get_action_color(self):
|
||||||
return ObjectChangeActionChoices.colors.get(self.action)
|
return ObjectChangeActionChoices.colors.get(self.action)
|
||||||
|
|
||||||
@property
|
@cached_property
|
||||||
def has_changes(self):
|
def has_changes(self):
|
||||||
return self.prechange_data != self.postchange_data
|
return self.prechange_data != self.postchange_data
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def diff_exclude_fields(self):
|
||||||
|
"""
|
||||||
|
Return a set of attributes which should be ignored when calculating a diff
|
||||||
|
between the pre- and post-change data. (For instance, it would not make
|
||||||
|
sense to compare the "last updated" times as these are expected to differ.)
|
||||||
|
"""
|
||||||
|
model = self.changed_object_type.model_class()
|
||||||
|
attrs = set()
|
||||||
|
|
||||||
|
# Exclude auto-populated change tracking fields
|
||||||
|
if issubclass(model, ChangeLoggingMixin):
|
||||||
|
attrs.update({'created', 'last_updated'})
|
||||||
|
|
||||||
|
# Exclude MPTT-internal fields
|
||||||
|
if issubclass(model, MPTTModel):
|
||||||
|
attrs.update({'level', 'lft', 'rght', 'tree_id'})
|
||||||
|
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
def get_clean_data(self, prefix):
|
||||||
|
"""
|
||||||
|
Return only the pre-/post-change attributes which are relevant for calculating a diff.
|
||||||
|
"""
|
||||||
|
ret = {}
|
||||||
|
change_data = getattr(self, f'{prefix}_data') or {}
|
||||||
|
for k, v in change_data.items():
|
||||||
|
if k not in self.diff_exclude_fields and not k.startswith('_'):
|
||||||
|
ret[k] = v
|
||||||
|
return ret
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def prechange_data_clean(self):
|
||||||
|
return self.get_clean_data('prechange')
|
||||||
|
|
||||||
|
@cached_property
|
||||||
|
def postchange_data_clean(self):
|
||||||
|
return self.get_clean_data('postchange')
|
||||||
|
|
||||||
|
def diff(self):
|
||||||
|
"""
|
||||||
|
Return a dictionary of pre- and post-change values for attributes which have changed.
|
||||||
|
"""
|
||||||
|
prechange_data = self.prechange_data_clean
|
||||||
|
postchange_data = self.postchange_data_clean
|
||||||
|
|
||||||
|
# Determine which attributes have changed
|
||||||
|
if self.action == ObjectChangeActionChoices.ACTION_CREATE:
|
||||||
|
changed_attrs = sorted(postchange_data.keys())
|
||||||
|
elif self.action == ObjectChangeActionChoices.ACTION_DELETE:
|
||||||
|
changed_attrs = sorted(prechange_data.keys())
|
||||||
|
else:
|
||||||
|
# TODO: Support deep (recursive) comparison
|
||||||
|
changed_data = shallow_compare_dict(prechange_data, postchange_data)
|
||||||
|
changed_attrs = sorted(changed_data.keys())
|
||||||
|
|
||||||
|
return {
|
||||||
|
'pre': {
|
||||||
|
k: prechange_data.get(k) for k in changed_attrs
|
||||||
|
},
|
||||||
|
'post': {
|
||||||
|
k: postchange_data.get(k) for k in changed_attrs
|
||||||
|
},
|
||||||
|
}
|
||||||
|
@ -96,6 +96,7 @@ class ScriptModule(PythonModuleMixin, JobsMixin, ManagedFile):
|
|||||||
Proxy model for script module files.
|
Proxy model for script module files.
|
||||||
"""
|
"""
|
||||||
objects = ScriptModuleManager()
|
objects = ScriptModuleManager()
|
||||||
|
error = None
|
||||||
|
|
||||||
event_rules = GenericRelation(
|
event_rules = GenericRelation(
|
||||||
to='extras.EventRule',
|
to='extras.EventRule',
|
||||||
@ -126,6 +127,7 @@ class ScriptModule(PythonModuleMixin, JobsMixin, ManagedFile):
|
|||||||
try:
|
try:
|
||||||
module = self.get_module()
|
module = self.get_module()
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
self.error = e
|
||||||
logger.debug(f"Failed to load script: {self.python_name} error: {e}")
|
logger.debug(f"Failed to load script: {self.python_name} error: {e}")
|
||||||
module = None
|
module = None
|
||||||
|
|
||||||
|
@ -4,6 +4,7 @@ from django.contrib.auth import get_user_model
|
|||||||
from django.contrib.contenttypes.fields import GenericForeignKey
|
from django.contrib.contenttypes.fields import GenericForeignKey
|
||||||
from django.db import models, transaction
|
from django.db import models, transaction
|
||||||
from django.utils.translation import gettext_lazy as _
|
from django.utils.translation import gettext_lazy as _
|
||||||
|
from mptt.models import MPTTModel
|
||||||
|
|
||||||
from extras.choices import ChangeActionChoices
|
from extras.choices import ChangeActionChoices
|
||||||
from netbox.models import ChangeLoggedModel
|
from netbox.models import ChangeLoggedModel
|
||||||
@ -124,6 +125,11 @@ class StagedChange(CustomValidationMixin, EventRulesMixin, models.Model):
|
|||||||
instance = self.model.objects.get(pk=self.object_id)
|
instance = self.model.objects.get(pk=self.object_id)
|
||||||
logger.info(f'Deleting {self.model._meta.verbose_name} {instance}')
|
logger.info(f'Deleting {self.model._meta.verbose_name} {instance}')
|
||||||
instance.delete()
|
instance.delete()
|
||||||
|
|
||||||
|
# Rebuild the MPTT tree where applicable
|
||||||
|
if issubclass(self.model, MPTTModel):
|
||||||
|
self.model.objects.rebuild()
|
||||||
|
|
||||||
apply.alters_data = True
|
apply.alters_data = True
|
||||||
|
|
||||||
def get_action_color(self):
|
def get_action_color(self):
|
||||||
|
@ -55,18 +55,6 @@ def run_validators(instance, validators):
|
|||||||
clear_events = Signal()
|
clear_events = Signal()
|
||||||
|
|
||||||
|
|
||||||
def is_same_object(instance, webhook_data, request_id):
|
|
||||||
"""
|
|
||||||
Compare the given instance to the most recent queued webhook object, returning True
|
|
||||||
if they match. This check is used to avoid creating duplicate webhook entries.
|
|
||||||
"""
|
|
||||||
return (
|
|
||||||
ContentType.objects.get_for_model(instance) == webhook_data['content_type'] and
|
|
||||||
instance.pk == webhook_data['object_id'] and
|
|
||||||
request_id == webhook_data['request_id']
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@receiver((post_save, m2m_changed))
|
@receiver((post_save, m2m_changed))
|
||||||
def handle_changed_object(sender, instance, **kwargs):
|
def handle_changed_object(sender, instance, **kwargs):
|
||||||
"""
|
"""
|
||||||
@ -112,14 +100,13 @@ def handle_changed_object(sender, instance, **kwargs):
|
|||||||
objectchange.request_id = request.id
|
objectchange.request_id = request.id
|
||||||
objectchange.save()
|
objectchange.save()
|
||||||
|
|
||||||
# If this is an M2M change, update the previously queued webhook (from post_save)
|
# Ensure that we're working with fresh M2M assignments
|
||||||
|
if m2m_changed:
|
||||||
|
instance.refresh_from_db()
|
||||||
|
|
||||||
|
# Enqueue the object for event processing
|
||||||
queue = events_queue.get()
|
queue = events_queue.get()
|
||||||
if m2m_changed and queue and is_same_object(instance, queue[-1], request.id):
|
enqueue_object(queue, instance, request.user, request.id, action)
|
||||||
instance.refresh_from_db() # Ensure that we're working with fresh M2M assignments
|
|
||||||
queue[-1]['data'] = serialize_for_event(instance)
|
|
||||||
queue[-1]['snapshots']['postchange'] = get_snapshots(instance, action)['postchange']
|
|
||||||
else:
|
|
||||||
enqueue_object(queue, instance, request.user, request.id, action)
|
|
||||||
events_queue.set(queue)
|
events_queue.set(queue)
|
||||||
|
|
||||||
# Increment metric counters
|
# Increment metric counters
|
||||||
@ -179,7 +166,7 @@ def handle_deleted_object(sender, instance, **kwargs):
|
|||||||
obj.snapshot() # Ensure the change record includes the "before" state
|
obj.snapshot() # Ensure the change record includes the "before" state
|
||||||
getattr(obj, related_field_name).remove(instance)
|
getattr(obj, related_field_name).remove(instance)
|
||||||
|
|
||||||
# Enqueue webhooks
|
# Enqueue the object for event processing
|
||||||
queue = events_queue.get()
|
queue = events_queue.get()
|
||||||
enqueue_object(queue, instance, request.user, request.id, ObjectChangeActionChoices.ACTION_DELETE)
|
enqueue_object(queue, instance, request.user, request.id, ObjectChangeActionChoices.ACTION_DELETE)
|
||||||
events_queue.set(queue)
|
events_queue.set(queue)
|
||||||
@ -195,7 +182,7 @@ def clear_events_queue(sender, **kwargs):
|
|||||||
"""
|
"""
|
||||||
logger = logging.getLogger('events')
|
logger = logging.getLogger('events')
|
||||||
logger.info(f"Clearing {len(events_queue.get())} queued events ({sender})")
|
logger.info(f"Clearing {len(events_queue.get())} queued events ({sender})")
|
||||||
events_queue.set([])
|
events_queue.set({})
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
|
@ -75,6 +75,10 @@ class ChangeLogViewTest(ModelViewTestCase):
|
|||||||
self.assertEqual(oc.postchange_data['custom_fields']['cf2'], form_data['cf_cf2'])
|
self.assertEqual(oc.postchange_data['custom_fields']['cf2'], form_data['cf_cf2'])
|
||||||
self.assertEqual(oc.postchange_data['tags'], ['Tag 1', 'Tag 2'])
|
self.assertEqual(oc.postchange_data['tags'], ['Tag 1', 'Tag 2'])
|
||||||
|
|
||||||
|
# Check that private attributes were included in raw data but not display data
|
||||||
|
self.assertIn('_name', oc.postchange_data)
|
||||||
|
self.assertNotIn('_name', oc.postchange_data_clean)
|
||||||
|
|
||||||
def test_update_object(self):
|
def test_update_object(self):
|
||||||
site = Site(name='Site 1', slug='site-1')
|
site = Site(name='Site 1', slug='site-1')
|
||||||
site.save()
|
site.save()
|
||||||
@ -112,6 +116,12 @@ class ChangeLogViewTest(ModelViewTestCase):
|
|||||||
self.assertEqual(oc.postchange_data['custom_fields']['cf2'], form_data['cf_cf2'])
|
self.assertEqual(oc.postchange_data['custom_fields']['cf2'], form_data['cf_cf2'])
|
||||||
self.assertEqual(oc.postchange_data['tags'], ['Tag 3'])
|
self.assertEqual(oc.postchange_data['tags'], ['Tag 3'])
|
||||||
|
|
||||||
|
# Check that private attributes were included in raw data but not display data
|
||||||
|
self.assertIn('_name', oc.prechange_data)
|
||||||
|
self.assertNotIn('_name', oc.prechange_data_clean)
|
||||||
|
self.assertIn('_name', oc.postchange_data)
|
||||||
|
self.assertNotIn('_name', oc.postchange_data_clean)
|
||||||
|
|
||||||
def test_delete_object(self):
|
def test_delete_object(self):
|
||||||
site = Site(
|
site = Site(
|
||||||
name='Site 1',
|
name='Site 1',
|
||||||
@ -142,6 +152,10 @@ class ChangeLogViewTest(ModelViewTestCase):
|
|||||||
self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2'])
|
self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2'])
|
||||||
self.assertEqual(oc.postchange_data, None)
|
self.assertEqual(oc.postchange_data, None)
|
||||||
|
|
||||||
|
# Check that private attributes were included in raw data but not display data
|
||||||
|
self.assertIn('_name', oc.prechange_data)
|
||||||
|
self.assertNotIn('_name', oc.prechange_data_clean)
|
||||||
|
|
||||||
def test_bulk_update_objects(self):
|
def test_bulk_update_objects(self):
|
||||||
sites = (
|
sites = (
|
||||||
Site(name='Site 1', slug='site-1', status=SiteStatusChoices.STATUS_ACTIVE),
|
Site(name='Site 1', slug='site-1', status=SiteStatusChoices.STATUS_ACTIVE),
|
||||||
@ -338,6 +352,10 @@ class ChangeLogAPITest(APITestCase):
|
|||||||
self.assertEqual(oc.postchange_data['custom_fields'], data['custom_fields'])
|
self.assertEqual(oc.postchange_data['custom_fields'], data['custom_fields'])
|
||||||
self.assertEqual(oc.postchange_data['tags'], ['Tag 1', 'Tag 2'])
|
self.assertEqual(oc.postchange_data['tags'], ['Tag 1', 'Tag 2'])
|
||||||
|
|
||||||
|
# Check that private attributes were included in raw data but not display data
|
||||||
|
self.assertIn('_name', oc.postchange_data)
|
||||||
|
self.assertNotIn('_name', oc.postchange_data_clean)
|
||||||
|
|
||||||
def test_update_object(self):
|
def test_update_object(self):
|
||||||
site = Site(name='Site 1', slug='site-1')
|
site = Site(name='Site 1', slug='site-1')
|
||||||
site.save()
|
site.save()
|
||||||
@ -370,6 +388,12 @@ class ChangeLogAPITest(APITestCase):
|
|||||||
self.assertEqual(oc.postchange_data['custom_fields'], data['custom_fields'])
|
self.assertEqual(oc.postchange_data['custom_fields'], data['custom_fields'])
|
||||||
self.assertEqual(oc.postchange_data['tags'], ['Tag 3'])
|
self.assertEqual(oc.postchange_data['tags'], ['Tag 3'])
|
||||||
|
|
||||||
|
# Check that private attributes were included in raw data but not display data
|
||||||
|
self.assertIn('_name', oc.prechange_data)
|
||||||
|
self.assertNotIn('_name', oc.prechange_data_clean)
|
||||||
|
self.assertIn('_name', oc.postchange_data)
|
||||||
|
self.assertNotIn('_name', oc.postchange_data_clean)
|
||||||
|
|
||||||
def test_delete_object(self):
|
def test_delete_object(self):
|
||||||
site = Site(
|
site = Site(
|
||||||
name='Site 1',
|
name='Site 1',
|
||||||
@ -398,6 +422,10 @@ class ChangeLogAPITest(APITestCase):
|
|||||||
self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2'])
|
self.assertEqual(oc.prechange_data['tags'], ['Tag 1', 'Tag 2'])
|
||||||
self.assertEqual(oc.postchange_data, None)
|
self.assertEqual(oc.postchange_data, None)
|
||||||
|
|
||||||
|
# Check that private attributes were included in raw data but not display data
|
||||||
|
self.assertIn('_name', oc.prechange_data)
|
||||||
|
self.assertNotIn('_name', oc.prechange_data_clean)
|
||||||
|
|
||||||
def test_bulk_create_objects(self):
|
def test_bulk_create_objects(self):
|
||||||
data = (
|
data = (
|
||||||
{
|
{
|
||||||
|
@ -1,6 +1,12 @@
|
|||||||
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
|
||||||
|
from dcim.choices import SiteStatusChoices
|
||||||
|
from dcim.models import Site
|
||||||
from extras.conditions import Condition, ConditionSet
|
from extras.conditions import Condition, ConditionSet
|
||||||
|
from extras.events import serialize_for_event
|
||||||
|
from extras.forms import EventRuleForm
|
||||||
|
from extras.models import EventRule, Webhook
|
||||||
|
|
||||||
|
|
||||||
class ConditionTestCase(TestCase):
|
class ConditionTestCase(TestCase):
|
||||||
@ -217,3 +223,93 @@ class ConditionSetTest(TestCase):
|
|||||||
self.assertTrue(cs.eval({'a': 1, 'b': 2, 'c': 9}))
|
self.assertTrue(cs.eval({'a': 1, 'b': 2, 'c': 9}))
|
||||||
self.assertFalse(cs.eval({'a': 9, 'b': 2, 'c': 9}))
|
self.assertFalse(cs.eval({'a': 9, 'b': 2, 'c': 9}))
|
||||||
self.assertFalse(cs.eval({'a': 9, 'b': 9, 'c': 3}))
|
self.assertFalse(cs.eval({'a': 9, 'b': 9, 'c': 3}))
|
||||||
|
|
||||||
|
def test_event_rule_conditions_without_logic_operator(self):
|
||||||
|
"""
|
||||||
|
Test evaluation of EventRule conditions without logic operator.
|
||||||
|
"""
|
||||||
|
event_rule = EventRule(
|
||||||
|
name='Event Rule 1',
|
||||||
|
type_create=True,
|
||||||
|
type_update=True,
|
||||||
|
conditions={
|
||||||
|
'attr': 'status.value',
|
||||||
|
'value': 'active',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a Site to evaluate - Status = active
|
||||||
|
site = Site.objects.create(name='Site 1', slug='site-1', status=SiteStatusChoices.STATUS_ACTIVE)
|
||||||
|
data = serialize_for_event(site)
|
||||||
|
|
||||||
|
# Evaluate the conditions (status='active')
|
||||||
|
self.assertTrue(event_rule.eval_conditions(data))
|
||||||
|
|
||||||
|
def test_event_rule_conditions_with_logical_operation(self):
|
||||||
|
"""
|
||||||
|
Test evaluation of EventRule conditions without logic operator, but with logical operation (in).
|
||||||
|
"""
|
||||||
|
event_rule = EventRule(
|
||||||
|
name='Event Rule 1',
|
||||||
|
type_create=True,
|
||||||
|
type_update=True,
|
||||||
|
conditions={
|
||||||
|
"attr": "status.value",
|
||||||
|
"value": ["planned", "staging"],
|
||||||
|
"op": "in",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a Site to evaluate - Status = active
|
||||||
|
site = Site.objects.create(name='Site 1', slug='site-1', status=SiteStatusChoices.STATUS_ACTIVE)
|
||||||
|
data = serialize_for_event(site)
|
||||||
|
|
||||||
|
# Evaluate the conditions (status in ['planned, 'staging'])
|
||||||
|
self.assertFalse(event_rule.eval_conditions(data))
|
||||||
|
|
||||||
|
def test_event_rule_conditions_with_logical_operation_and_negate(self):
|
||||||
|
"""
|
||||||
|
Test evaluation of EventRule with logical operation (in) and negate.
|
||||||
|
"""
|
||||||
|
event_rule = EventRule(
|
||||||
|
name='Event Rule 1',
|
||||||
|
type_create=True,
|
||||||
|
type_update=True,
|
||||||
|
conditions={
|
||||||
|
"attr": "status.value",
|
||||||
|
"value": ["planned", "staging"],
|
||||||
|
"op": "in",
|
||||||
|
"negate": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create a Site to evaluate - Status = active
|
||||||
|
site = Site.objects.create(name='Site 1', slug='site-1', status=SiteStatusChoices.STATUS_ACTIVE)
|
||||||
|
data = serialize_for_event(site)
|
||||||
|
|
||||||
|
# Evaluate the conditions (status NOT in ['planned, 'staging'])
|
||||||
|
self.assertTrue(event_rule.eval_conditions(data))
|
||||||
|
|
||||||
|
def test_event_rule_conditions_with_incorrect_key_must_return_false(self):
|
||||||
|
"""
|
||||||
|
Test Event Rule with incorrect condition (key "foo" is wrong). Must return false.
|
||||||
|
"""
|
||||||
|
|
||||||
|
ct = ContentType.objects.get(app_label='extras', model='webhook')
|
||||||
|
site_ct = ContentType.objects.get_for_model(Site)
|
||||||
|
webhook = Webhook.objects.create(name='Webhook 100', payload_url='http://example.com/?1', http_method='POST')
|
||||||
|
form = EventRuleForm({
|
||||||
|
"name": "Event Rule 1",
|
||||||
|
"type_create": True,
|
||||||
|
"type_update": True,
|
||||||
|
"action_object_type": ct.pk,
|
||||||
|
"action_type": "webhook",
|
||||||
|
"action_choice": webhook.pk,
|
||||||
|
"content_types": [site_ct.pk],
|
||||||
|
"conditions": {
|
||||||
|
"foo": "status.value",
|
||||||
|
"value": "active"
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
self.assertFalse(form.is_valid())
|
||||||
|
@ -4,6 +4,7 @@ from unittest.mock import patch
|
|||||||
|
|
||||||
import django_rq
|
import django_rq
|
||||||
from django.http import HttpResponse
|
from django.http import HttpResponse
|
||||||
|
from django.test import RequestFactory
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
from requests import Session
|
from requests import Session
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
@ -12,6 +13,7 @@ from core.models import ObjectType
|
|||||||
from dcim.choices import SiteStatusChoices
|
from dcim.choices import SiteStatusChoices
|
||||||
from dcim.models import Site
|
from dcim.models import Site
|
||||||
from extras.choices import EventRuleActionChoices, ObjectChangeActionChoices
|
from extras.choices import EventRuleActionChoices, ObjectChangeActionChoices
|
||||||
|
from extras.context_managers import event_tracking
|
||||||
from extras.events import enqueue_object, flush_events, serialize_for_event
|
from extras.events import enqueue_object, flush_events, serialize_for_event
|
||||||
from extras.models import EventRule, Tag, Webhook
|
from extras.models import EventRule, Tag, Webhook
|
||||||
from extras.webhooks import generate_signature, send_webhook
|
from extras.webhooks import generate_signature, send_webhook
|
||||||
@ -360,7 +362,7 @@ class EventRuleTest(APITestCase):
|
|||||||
return HttpResponse()
|
return HttpResponse()
|
||||||
|
|
||||||
# Enqueue a webhook for processing
|
# Enqueue a webhook for processing
|
||||||
webhooks_queue = []
|
webhooks_queue = {}
|
||||||
site = Site.objects.create(name='Site 1', slug='site-1')
|
site = Site.objects.create(name='Site 1', slug='site-1')
|
||||||
enqueue_object(
|
enqueue_object(
|
||||||
webhooks_queue,
|
webhooks_queue,
|
||||||
@ -369,7 +371,7 @@ class EventRuleTest(APITestCase):
|
|||||||
request_id=request_id,
|
request_id=request_id,
|
||||||
action=ObjectChangeActionChoices.ACTION_CREATE
|
action=ObjectChangeActionChoices.ACTION_CREATE
|
||||||
)
|
)
|
||||||
flush_events(webhooks_queue)
|
flush_events(list(webhooks_queue.values()))
|
||||||
|
|
||||||
# Retrieve the job from queue
|
# Retrieve the job from queue
|
||||||
job = self.queue.jobs[0]
|
job = self.queue.jobs[0]
|
||||||
@ -377,3 +379,24 @@ class EventRuleTest(APITestCase):
|
|||||||
# Patch the Session object with our dummy_send() method, then process the webhook for sending
|
# Patch the Session object with our dummy_send() method, then process the webhook for sending
|
||||||
with patch.object(Session, 'send', dummy_send) as mock_send:
|
with patch.object(Session, 'send', dummy_send) as mock_send:
|
||||||
send_webhook(**job.kwargs)
|
send_webhook(**job.kwargs)
|
||||||
|
|
||||||
|
def test_duplicate_triggers(self):
|
||||||
|
"""
|
||||||
|
Test for erroneous duplicate event triggers resulting from saving an object multiple times
|
||||||
|
within the span of a single request.
|
||||||
|
"""
|
||||||
|
url = reverse('dcim:site_add')
|
||||||
|
request = RequestFactory().get(url)
|
||||||
|
request.id = uuid.uuid4()
|
||||||
|
request.user = self.user
|
||||||
|
|
||||||
|
self.assertEqual(self.queue.count, 0, msg="Unexpected jobs found in queue")
|
||||||
|
|
||||||
|
with event_tracking(request):
|
||||||
|
site = Site(name='Site 1', slug='site-1')
|
||||||
|
site.save()
|
||||||
|
|
||||||
|
# Save the site a second time
|
||||||
|
site.save()
|
||||||
|
|
||||||
|
self.assertEqual(self.queue.count, 1, msg="Duplicate jobs found in queue")
|
||||||
|
@ -723,15 +723,15 @@ class ObjectChangeView(generic.ObjectView):
|
|||||||
|
|
||||||
if not instance.prechange_data and instance.action in ['update', 'delete'] and prev_change:
|
if not instance.prechange_data and instance.action in ['update', 'delete'] and prev_change:
|
||||||
non_atomic_change = True
|
non_atomic_change = True
|
||||||
prechange_data = prev_change.postchange_data
|
prechange_data = prev_change.postchange_data_clean
|
||||||
else:
|
else:
|
||||||
non_atomic_change = False
|
non_atomic_change = False
|
||||||
prechange_data = instance.prechange_data
|
prechange_data = instance.prechange_data_clean
|
||||||
|
|
||||||
if prechange_data and instance.postchange_data:
|
if prechange_data and instance.postchange_data:
|
||||||
diff_added = shallow_compare_dict(
|
diff_added = shallow_compare_dict(
|
||||||
prechange_data or dict(),
|
prechange_data or dict(),
|
||||||
instance.postchange_data or dict(),
|
instance.postchange_data_clean or dict(),
|
||||||
exclude=['last_updated'],
|
exclude=['last_updated'],
|
||||||
)
|
)
|
||||||
diff_removed = {
|
diff_removed = {
|
||||||
@ -1052,12 +1052,27 @@ class ScriptListView(ContentTypePermissionRequiredMixin, View):
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
class ScriptView(generic.ObjectView):
|
class BaseScriptView(generic.ObjectView):
|
||||||
queryset = Script.objects.all()
|
queryset = Script.objects.all()
|
||||||
|
|
||||||
|
def _get_script_class(self, script):
|
||||||
|
"""
|
||||||
|
Return an instance of the Script's Python class
|
||||||
|
"""
|
||||||
|
if script_class := script.python_class:
|
||||||
|
return script_class()
|
||||||
|
|
||||||
|
|
||||||
|
class ScriptView(BaseScriptView):
|
||||||
|
|
||||||
def get(self, request, **kwargs):
|
def get(self, request, **kwargs):
|
||||||
script = self.get_object(**kwargs)
|
script = self.get_object(**kwargs)
|
||||||
script_class = script.python_class()
|
script_class = self._get_script_class(script)
|
||||||
|
if not script_class:
|
||||||
|
return render(request, 'extras/script.html', {
|
||||||
|
'script': script,
|
||||||
|
})
|
||||||
|
|
||||||
form = script_class.as_form(initial=normalize_querydict(request.GET))
|
form = script_class.as_form(initial=normalize_querydict(request.GET))
|
||||||
|
|
||||||
return render(request, 'extras/script.html', {
|
return render(request, 'extras/script.html', {
|
||||||
@ -1069,11 +1084,16 @@ class ScriptView(generic.ObjectView):
|
|||||||
|
|
||||||
def post(self, request, **kwargs):
|
def post(self, request, **kwargs):
|
||||||
script = self.get_object(**kwargs)
|
script = self.get_object(**kwargs)
|
||||||
script_class = script.python_class()
|
|
||||||
|
|
||||||
if not request.user.has_perm('extras.run_script', obj=script):
|
if not request.user.has_perm('extras.run_script', obj=script):
|
||||||
return HttpResponseForbidden()
|
return HttpResponseForbidden()
|
||||||
|
|
||||||
|
script_class = self._get_script_class(script)
|
||||||
|
if not script_class:
|
||||||
|
return render(request, 'extras/script.html', {
|
||||||
|
'script': script,
|
||||||
|
})
|
||||||
|
|
||||||
form = script_class.as_form(request.POST, request.FILES)
|
form = script_class.as_form(request.POST, request.FILES)
|
||||||
|
|
||||||
# Allow execution only if RQ worker process is running
|
# Allow execution only if RQ worker process is running
|
||||||
@ -1103,21 +1123,22 @@ class ScriptView(generic.ObjectView):
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
class ScriptSourceView(generic.ObjectView):
|
class ScriptSourceView(BaseScriptView):
|
||||||
queryset = Script.objects.all()
|
queryset = Script.objects.all()
|
||||||
|
|
||||||
def get(self, request, **kwargs):
|
def get(self, request, **kwargs):
|
||||||
script = self.get_object(**kwargs)
|
script = self.get_object(**kwargs)
|
||||||
|
script_class = self._get_script_class(script)
|
||||||
|
|
||||||
return render(request, 'extras/script/source.html', {
|
return render(request, 'extras/script/source.html', {
|
||||||
'script': script,
|
'script': script,
|
||||||
'script_class': script.python_class(),
|
'script_class': script_class,
|
||||||
'job_count': script.jobs.count(),
|
'job_count': script.jobs.count(),
|
||||||
'tab': 'source',
|
'tab': 'source',
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
class ScriptJobsView(generic.ObjectView):
|
class ScriptJobsView(BaseScriptView):
|
||||||
queryset = Script.objects.all()
|
queryset = Script.objects.all()
|
||||||
|
|
||||||
def get(self, request, **kwargs):
|
def get(self, request, **kwargs):
|
||||||
|
@ -168,6 +168,7 @@ class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
|||||||
'within_include', 'family', 'status', 'role_id', 'mask_length', 'is_pool', 'mark_utilized',
|
'within_include', 'family', 'status', 'role_id', 'mask_length', 'is_pool', 'mark_utilized',
|
||||||
name=_('Addressing')
|
name=_('Addressing')
|
||||||
),
|
),
|
||||||
|
FieldSet('vlan_id', name=_('VLAN Assignment')),
|
||||||
FieldSet('vrf_id', 'present_in_vrf_id', name=_('VRF')),
|
FieldSet('vrf_id', 'present_in_vrf_id', name=_('VRF')),
|
||||||
FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')),
|
FieldSet('region_id', 'site_group_id', 'site_id', name=_('Location')),
|
||||||
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
|
||||||
@ -249,6 +250,12 @@ class PrefixFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):
|
|||||||
choices=BOOLEAN_WITH_BLANK_CHOICES
|
choices=BOOLEAN_WITH_BLANK_CHOICES
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
vlan_id = DynamicModelMultipleChoiceField(
|
||||||
|
queryset=VLAN.objects.all(),
|
||||||
|
required=False,
|
||||||
|
label=_('VLAN'),
|
||||||
|
)
|
||||||
|
|
||||||
tag = TagFilterField(model)
|
tag = TagFilterField(model)
|
||||||
|
|
||||||
|
|
||||||
|
@ -18,6 +18,7 @@ from ipam.querysets import PrefixQuerySet
|
|||||||
from ipam.validators import DNSValidator
|
from ipam.validators import DNSValidator
|
||||||
from netbox.config import get_config
|
from netbox.config import get_config
|
||||||
from netbox.models import OrganizationalModel, PrimaryModel
|
from netbox.models import OrganizationalModel, PrimaryModel
|
||||||
|
from netbox.models.features import ContactsMixin
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'Aggregate',
|
'Aggregate',
|
||||||
@ -74,7 +75,7 @@ class RIR(OrganizationalModel):
|
|||||||
return reverse('ipam:rir', args=[self.pk])
|
return reverse('ipam:rir', args=[self.pk])
|
||||||
|
|
||||||
|
|
||||||
class Aggregate(GetAvailablePrefixesMixin, PrimaryModel):
|
class Aggregate(ContactsMixin, GetAvailablePrefixesMixin, PrimaryModel):
|
||||||
"""
|
"""
|
||||||
An aggregate exists at the root level of the IP address space hierarchy in NetBox. Aggregates are used to organize
|
An aggregate exists at the root level of the IP address space hierarchy in NetBox. Aggregates are used to organize
|
||||||
the hierarchy and track the overall utilization of available address space. Each Aggregate is assigned to a RIR.
|
the hierarchy and track the overall utilization of available address space. Each Aggregate is assigned to a RIR.
|
||||||
@ -206,7 +207,7 @@ class Role(OrganizationalModel):
|
|||||||
return reverse('ipam:role', args=[self.pk])
|
return reverse('ipam:role', args=[self.pk])
|
||||||
|
|
||||||
|
|
||||||
class Prefix(GetAvailablePrefixesMixin, PrimaryModel):
|
class Prefix(ContactsMixin, GetAvailablePrefixesMixin, PrimaryModel):
|
||||||
"""
|
"""
|
||||||
A Prefix represents an IPv4 or IPv6 network, including mask length. Prefixes can optionally be assigned to Sites and
|
A Prefix represents an IPv4 or IPv6 network, including mask length. Prefixes can optionally be assigned to Sites and
|
||||||
VRFs. A Prefix must be assigned a status and may optionally be assigned a used-define Role. A Prefix can also be
|
VRFs. A Prefix must be assigned a status and may optionally be assigned a used-define Role. A Prefix can also be
|
||||||
@ -486,7 +487,7 @@ class Prefix(GetAvailablePrefixesMixin, PrimaryModel):
|
|||||||
return min(utilization, 100)
|
return min(utilization, 100)
|
||||||
|
|
||||||
|
|
||||||
class IPRange(PrimaryModel):
|
class IPRange(ContactsMixin, PrimaryModel):
|
||||||
"""
|
"""
|
||||||
A range of IP addresses, defined by start and end addresses.
|
A range of IP addresses, defined by start and end addresses.
|
||||||
"""
|
"""
|
||||||
@ -695,7 +696,7 @@ class IPRange(PrimaryModel):
|
|||||||
return min(float(child_count) / self.size * 100, 100)
|
return min(float(child_count) / self.size * 100, 100)
|
||||||
|
|
||||||
|
|
||||||
class IPAddress(PrimaryModel):
|
class IPAddress(ContactsMixin, PrimaryModel):
|
||||||
"""
|
"""
|
||||||
An IPAddress represents an individual IPv4 or IPv6 address and its mask. The mask length should match what is
|
An IPAddress represents an individual IPv4 or IPv6 address and its mask. The mask length should match what is
|
||||||
configured in the real world. (Typically, only loopback interfaces are configured with /32 or /128 masks.) Like
|
configured in the real world. (Typically, only loopback interfaces are configured with /32 or /128 masks.) Like
|
||||||
|
@ -8,6 +8,7 @@ from django.utils.translation import gettext_lazy as _
|
|||||||
from ipam.choices import *
|
from ipam.choices import *
|
||||||
from ipam.constants import *
|
from ipam.constants import *
|
||||||
from netbox.models import PrimaryModel
|
from netbox.models import PrimaryModel
|
||||||
|
from netbox.models.features import ContactsMixin
|
||||||
from utilities.data import array_to_string
|
from utilities.data import array_to_string
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
@ -62,7 +63,7 @@ class ServiceTemplate(ServiceBase, PrimaryModel):
|
|||||||
return reverse('ipam:servicetemplate', args=[self.pk])
|
return reverse('ipam:servicetemplate', args=[self.pk])
|
||||||
|
|
||||||
|
|
||||||
class Service(ServiceBase, PrimaryModel):
|
class Service(ContactsMixin, ServiceBase, PrimaryModel):
|
||||||
"""
|
"""
|
||||||
A Service represents a layer-four service (e.g. HTTP or SSH) running on a Device or VirtualMachine. A Service may
|
A Service represents a layer-four service (e.g. HTTP or SSH) running on a Device or VirtualMachine. A Service may
|
||||||
optionally be tied to one or more specific IPAddresses belonging to its parent.
|
optionally be tied to one or more specific IPAddresses belonging to its parent.
|
||||||
|
@ -649,7 +649,7 @@ class IPAddressTest(APIViewTestCases.APIViewTestCase):
|
|||||||
'description': 'New description',
|
'description': 'New description',
|
||||||
}
|
}
|
||||||
graphql_filter = {
|
graphql_filter = {
|
||||||
'address': '192.168.0.1/24',
|
'address': {'lookup': 'i_exact', 'value': '192.168.0.1/24'},
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
|
@ -9,6 +9,7 @@ from circuits.models import Provider
|
|||||||
from dcim.filtersets import InterfaceFilterSet
|
from dcim.filtersets import InterfaceFilterSet
|
||||||
from dcim.models import Interface, Site
|
from dcim.models import Interface, Site
|
||||||
from netbox.views import generic
|
from netbox.views import generic
|
||||||
|
from tenancy.views import ObjectContactsView
|
||||||
from utilities.query import count_related
|
from utilities.query import count_related
|
||||||
from utilities.tables import get_table_ordering
|
from utilities.tables import get_table_ordering
|
||||||
from utilities.views import GetRelatedModelsMixin, ViewTab, register_model_view
|
from utilities.views import GetRelatedModelsMixin, ViewTab, register_model_view
|
||||||
@ -391,6 +392,11 @@ class AggregateBulkDeleteView(generic.BulkDeleteView):
|
|||||||
table = tables.AggregateTable
|
table = tables.AggregateTable
|
||||||
|
|
||||||
|
|
||||||
|
@register_model_view(Aggregate, 'contacts')
|
||||||
|
class AggregateContactsView(ObjectContactsView):
|
||||||
|
queryset = Aggregate.objects.all()
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# Prefix/VLAN roles
|
# Prefix/VLAN roles
|
||||||
#
|
#
|
||||||
@ -623,6 +629,11 @@ class PrefixBulkDeleteView(generic.BulkDeleteView):
|
|||||||
table = tables.PrefixTable
|
table = tables.PrefixTable
|
||||||
|
|
||||||
|
|
||||||
|
@register_model_view(Prefix, 'contacts')
|
||||||
|
class PrefixContactsView(ObjectContactsView):
|
||||||
|
queryset = Prefix.objects.all()
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# IP Ranges
|
# IP Ranges
|
||||||
#
|
#
|
||||||
@ -706,6 +717,11 @@ class IPRangeBulkDeleteView(generic.BulkDeleteView):
|
|||||||
table = tables.IPRangeTable
|
table = tables.IPRangeTable
|
||||||
|
|
||||||
|
|
||||||
|
@register_model_view(IPRange, 'contacts')
|
||||||
|
class IPRangeContactsView(ObjectContactsView):
|
||||||
|
queryset = IPRange.objects.all()
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# IP addresses
|
# IP addresses
|
||||||
#
|
#
|
||||||
@ -873,6 +889,11 @@ class IPAddressRelatedIPsView(generic.ObjectChildrenView):
|
|||||||
return parent.get_related_ips().restrict(request.user, 'view')
|
return parent.get_related_ips().restrict(request.user, 'view')
|
||||||
|
|
||||||
|
|
||||||
|
@register_model_view(IPAddress, 'contacts')
|
||||||
|
class IPAddressContactsView(ObjectContactsView):
|
||||||
|
queryset = IPAddress.objects.all()
|
||||||
|
|
||||||
|
|
||||||
#
|
#
|
||||||
# VLAN groups
|
# VLAN groups
|
||||||
#
|
#
|
||||||
@ -1235,3 +1256,8 @@ class ServiceBulkDeleteView(generic.BulkDeleteView):
|
|||||||
queryset = Service.objects.prefetch_related('device', 'virtual_machine')
|
queryset = Service.objects.prefetch_related('device', 'virtual_machine')
|
||||||
filterset = filtersets.ServiceFilterSet
|
filterset = filtersets.ServiceFilterSet
|
||||||
table = tables.ServiceTable
|
table = tables.ServiceTable
|
||||||
|
|
||||||
|
|
||||||
|
@register_model_view(Service, 'contacts')
|
||||||
|
class ServiceContactsView(ObjectContactsView):
|
||||||
|
queryset = Service.objects.all()
|
||||||
|
@ -7,4 +7,4 @@ __all__ = (
|
|||||||
|
|
||||||
|
|
||||||
current_request = ContextVar('current_request', default=None)
|
current_request = ContextVar('current_request', default=None)
|
||||||
events_queue = ContextVar('events_queue', default=[])
|
events_queue = ContextVar('events_queue', default=dict())
|
||||||
|
@ -23,8 +23,9 @@ def map_strawberry_type(field):
|
|||||||
elif isinstance(field, MultiValueArrayFilter):
|
elif isinstance(field, MultiValueArrayFilter):
|
||||||
pass
|
pass
|
||||||
elif isinstance(field, MultiValueCharFilter):
|
elif isinstance(field, MultiValueCharFilter):
|
||||||
should_create_function = True
|
# Note: Need to use the legacy FilterLookup from filters, not from
|
||||||
attr_type = List[str] | None
|
# strawberry_django.FilterLookup as we currently have USE_DEPRECATED_FILTERS
|
||||||
|
attr_type = strawberry_django.filters.FilterLookup[str] | None
|
||||||
elif isinstance(field, MultiValueDateFilter):
|
elif isinstance(field, MultiValueDateFilter):
|
||||||
attr_type = auto
|
attr_type = auto
|
||||||
elif isinstance(field, MultiValueDateTimeFilter):
|
elif isinstance(field, MultiValueDateTimeFilter):
|
||||||
|
@ -258,6 +258,7 @@ CIRCUITS_MENU = Menu(
|
|||||||
items=(
|
items=(
|
||||||
get_model_item('circuits', 'circuit', _('Circuits')),
|
get_model_item('circuits', 'circuit', _('Circuits')),
|
||||||
get_model_item('circuits', 'circuittype', _('Circuit Types')),
|
get_model_item('circuits', 'circuittype', _('Circuit Types')),
|
||||||
|
get_model_item('circuits', 'circuittermination', _('Circuit Terminations')),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
MenuGroup(
|
MenuGroup(
|
||||||
@ -372,19 +373,19 @@ ADMIN_MENU = Menu(
|
|||||||
link=f'users:user_list',
|
link=f'users:user_list',
|
||||||
link_text=_('Users'),
|
link_text=_('Users'),
|
||||||
auth_required=True,
|
auth_required=True,
|
||||||
permissions=[f'auth.view_user'],
|
permissions=[f'users.view_user'],
|
||||||
buttons=(
|
buttons=(
|
||||||
MenuItemButton(
|
MenuItemButton(
|
||||||
link=f'users:user_add',
|
link=f'users:user_add',
|
||||||
title='Add',
|
title='Add',
|
||||||
icon_class='mdi mdi-plus-thick',
|
icon_class='mdi mdi-plus-thick',
|
||||||
permissions=[f'auth.add_user']
|
permissions=[f'users.add_user']
|
||||||
),
|
),
|
||||||
MenuItemButton(
|
MenuItemButton(
|
||||||
link=f'users:user_import',
|
link=f'users:user_import',
|
||||||
title='Import',
|
title='Import',
|
||||||
icon_class='mdi mdi-upload',
|
icon_class='mdi mdi-upload',
|
||||||
permissions=[f'auth.add_user']
|
permissions=[f'users.add_user']
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
@ -392,19 +393,19 @@ ADMIN_MENU = Menu(
|
|||||||
link=f'users:group_list',
|
link=f'users:group_list',
|
||||||
link_text=_('Groups'),
|
link_text=_('Groups'),
|
||||||
auth_required=True,
|
auth_required=True,
|
||||||
permissions=[f'auth.view_group'],
|
permissions=[f'users.view_group'],
|
||||||
buttons=(
|
buttons=(
|
||||||
MenuItemButton(
|
MenuItemButton(
|
||||||
link=f'users:group_add',
|
link=f'users:group_add',
|
||||||
title='Add',
|
title='Add',
|
||||||
icon_class='mdi mdi-plus-thick',
|
icon_class='mdi mdi-plus-thick',
|
||||||
permissions=[f'auth.add_group']
|
permissions=[f'users.add_group']
|
||||||
),
|
),
|
||||||
MenuItemButton(
|
MenuItemButton(
|
||||||
link=f'users:group_import',
|
link=f'users:group_import',
|
||||||
title='Import',
|
title='Import',
|
||||||
icon_class='mdi mdi-upload',
|
icon_class='mdi mdi-upload',
|
||||||
permissions=[f'auth.add_group']
|
permissions=[f'users.add_group']
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
|
@ -25,7 +25,7 @@ from utilities.string import trailing_slash
|
|||||||
# Environment setup
|
# Environment setup
|
||||||
#
|
#
|
||||||
|
|
||||||
VERSION = '4.0.3-dev'
|
VERSION = '4.0.6-dev'
|
||||||
HOSTNAME = platform.node()
|
HOSTNAME = platform.node()
|
||||||
# Set the base directory two levels up
|
# Set the base directory two levels up
|
||||||
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
@ -242,6 +242,7 @@ if 'tasks' not in REDIS:
|
|||||||
TASKS_REDIS = REDIS['tasks']
|
TASKS_REDIS = REDIS['tasks']
|
||||||
TASKS_REDIS_HOST = TASKS_REDIS.get('HOST', 'localhost')
|
TASKS_REDIS_HOST = TASKS_REDIS.get('HOST', 'localhost')
|
||||||
TASKS_REDIS_PORT = TASKS_REDIS.get('PORT', 6379)
|
TASKS_REDIS_PORT = TASKS_REDIS.get('PORT', 6379)
|
||||||
|
TASKS_REDIS_URL = TASKS_REDIS.get('URL')
|
||||||
TASKS_REDIS_SENTINELS = TASKS_REDIS.get('SENTINELS', [])
|
TASKS_REDIS_SENTINELS = TASKS_REDIS.get('SENTINELS', [])
|
||||||
TASKS_REDIS_USING_SENTINEL = all([
|
TASKS_REDIS_USING_SENTINEL = all([
|
||||||
isinstance(TASKS_REDIS_SENTINELS, (list, tuple)),
|
isinstance(TASKS_REDIS_SENTINELS, (list, tuple)),
|
||||||
@ -270,7 +271,7 @@ CACHING_REDIS_SENTINEL_SERVICE = REDIS['caching'].get('SENTINEL_SERVICE', 'defau
|
|||||||
CACHING_REDIS_PROTO = 'rediss' if REDIS['caching'].get('SSL', False) else 'redis'
|
CACHING_REDIS_PROTO = 'rediss' if REDIS['caching'].get('SSL', False) else 'redis'
|
||||||
CACHING_REDIS_SKIP_TLS_VERIFY = REDIS['caching'].get('INSECURE_SKIP_TLS_VERIFY', False)
|
CACHING_REDIS_SKIP_TLS_VERIFY = REDIS['caching'].get('INSECURE_SKIP_TLS_VERIFY', False)
|
||||||
CACHING_REDIS_CA_CERT_PATH = REDIS['caching'].get('CA_CERT_PATH', False)
|
CACHING_REDIS_CA_CERT_PATH = REDIS['caching'].get('CA_CERT_PATH', False)
|
||||||
CACHING_REDIS_URL = f'{CACHING_REDIS_PROTO}://{CACHING_REDIS_USERNAME_HOST}:{CACHING_REDIS_PORT}/{CACHING_REDIS_DATABASE}'
|
CACHING_REDIS_URL = REDIS['caching'].get('URL', f'{CACHING_REDIS_PROTO}://{CACHING_REDIS_USERNAME_HOST}:{CACHING_REDIS_PORT}/{CACHING_REDIS_DATABASE}')
|
||||||
|
|
||||||
# Configure Django's default cache to use Redis
|
# Configure Django's default cache to use Redis
|
||||||
CACHES = {
|
CACHES = {
|
||||||
@ -367,6 +368,8 @@ INSTALLED_APPS = [
|
|||||||
'drf_spectacular',
|
'drf_spectacular',
|
||||||
'drf_spectacular_sidecar',
|
'drf_spectacular_sidecar',
|
||||||
]
|
]
|
||||||
|
if not DEBUG:
|
||||||
|
INSTALLED_APPS.remove('debug_toolbar')
|
||||||
if not DJANGO_ADMIN_ENABLED:
|
if not DJANGO_ADMIN_ENABLED:
|
||||||
INSTALLED_APPS.remove('django.contrib.admin')
|
INSTALLED_APPS.remove('django.contrib.admin')
|
||||||
|
|
||||||
@ -678,6 +681,12 @@ if TASKS_REDIS_USING_SENTINEL:
|
|||||||
'socket_connect_timeout': TASKS_REDIS_SENTINEL_TIMEOUT
|
'socket_connect_timeout': TASKS_REDIS_SENTINEL_TIMEOUT
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
elif TASKS_REDIS_URL:
|
||||||
|
RQ_PARAMS = {
|
||||||
|
'URL': TASKS_REDIS_URL,
|
||||||
|
'SSL': TASKS_REDIS_SSL,
|
||||||
|
'SSL_CERT_REQS': None if TASKS_REDIS_SKIP_TLS_VERIFY else 'required',
|
||||||
|
}
|
||||||
else:
|
else:
|
||||||
RQ_PARAMS = {
|
RQ_PARAMS = {
|
||||||
'HOST': TASKS_REDIS_HOST,
|
'HOST': TASKS_REDIS_HOST,
|
||||||
|
@ -1,4 +1,5 @@
|
|||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
|
from functools import cached_property
|
||||||
|
|
||||||
import django_tables2 as tables
|
import django_tables2 as tables
|
||||||
from django.contrib.auth.models import AnonymousUser
|
from django.contrib.auth.models import AnonymousUser
|
||||||
@ -189,6 +190,7 @@ class NetBoxTable(BaseTable):
|
|||||||
actions = columns.ActionsColumn()
|
actions = columns.ActionsColumn()
|
||||||
|
|
||||||
exempt_columns = ('pk', 'actions')
|
exempt_columns = ('pk', 'actions')
|
||||||
|
embedded = False
|
||||||
|
|
||||||
class Meta(BaseTable.Meta):
|
class Meta(BaseTable.Meta):
|
||||||
pass
|
pass
|
||||||
@ -218,12 +220,12 @@ class NetBoxTable(BaseTable):
|
|||||||
|
|
||||||
super().__init__(*args, extra_columns=extra_columns, **kwargs)
|
super().__init__(*args, extra_columns=extra_columns, **kwargs)
|
||||||
|
|
||||||
@property
|
@cached_property
|
||||||
def htmx_url(self):
|
def htmx_url(self):
|
||||||
"""
|
"""
|
||||||
Return the base HTML request URL for embedded tables.
|
Return the base HTML request URL for embedded tables.
|
||||||
"""
|
"""
|
||||||
if getattr(self, 'embedded', False):
|
if self.embedded:
|
||||||
viewname = get_viewname(self._meta.model, action='list')
|
viewname = get_viewname(self._meta.model, action='list')
|
||||||
try:
|
try:
|
||||||
return reverse(viewname)
|
return reverse(viewname)
|
||||||
|
@ -163,7 +163,7 @@ class ObjectListView(BaseMultiObjectView, ActionsMixin, TableMixin):
|
|||||||
|
|
||||||
# If this is an HTMX request, return only the rendered table HTML
|
# If this is an HTMX request, return only the rendered table HTML
|
||||||
if htmx_partial(request):
|
if htmx_partial(request):
|
||||||
if not request.htmx.target:
|
if request.GET.get('embedded', False):
|
||||||
table.embedded = True
|
table.embedded = True
|
||||||
# Hide selection checkboxes
|
# Hide selection checkboxes
|
||||||
if 'pk' in table.base_columns:
|
if 'pk' in table.base_columns:
|
||||||
|
BIN
netbox/project-static/dist/netbox.css
vendored
BIN
netbox/project-static/dist/netbox.css
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox.js
vendored
BIN
netbox/project-static/dist/netbox.js
vendored
Binary file not shown.
BIN
netbox/project-static/dist/netbox.js.map
vendored
BIN
netbox/project-static/dist/netbox.js.map
vendored
Binary file not shown.
@ -27,10 +27,10 @@
|
|||||||
"bootstrap": "5.3.3",
|
"bootstrap": "5.3.3",
|
||||||
"clipboard": "2.0.11",
|
"clipboard": "2.0.11",
|
||||||
"flatpickr": "4.6.13",
|
"flatpickr": "4.6.13",
|
||||||
"gridstack": "10.1.2",
|
"gridstack": "10.2.0",
|
||||||
"htmx.org": "1.9.12",
|
"htmx.org": "1.9.12",
|
||||||
"query-string": "9.0.0",
|
"query-string": "9.0.0",
|
||||||
"sass": "1.77.1",
|
"sass": "1.77.4",
|
||||||
"tom-select": "2.3.1",
|
"tom-select": "2.3.1",
|
||||||
"typeface-inter": "3.18.1",
|
"typeface-inter": "3.18.1",
|
||||||
"typeface-roboto-mono": "1.1.13"
|
"typeface-roboto-mono": "1.1.13"
|
||||||
|
@ -7,38 +7,74 @@ import { isTruthy } from './util';
|
|||||||
*/
|
*/
|
||||||
function quickSearchEventHandler(event: Event): void {
|
function quickSearchEventHandler(event: Event): void {
|
||||||
const quicksearch = event.currentTarget as HTMLInputElement;
|
const quicksearch = event.currentTarget as HTMLInputElement;
|
||||||
const clearbtn = document.getElementById("quicksearch_clear") as HTMLAnchorElement;
|
const clearbtn = document.getElementById('quicksearch_clear') as HTMLAnchorElement;
|
||||||
if (isTruthy(clearbtn)) {
|
if (isTruthy(clearbtn)) {
|
||||||
if (quicksearch.value === "") {
|
if (quicksearch.value === '') {
|
||||||
clearbtn.classList.add("invisible");
|
clearbtn.classList.add('invisible');
|
||||||
} else {
|
} else {
|
||||||
clearbtn.classList.remove("invisible");
|
clearbtn.classList.remove('invisible');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear the existing search parameters in the link to export Current View.
|
||||||
|
*/
|
||||||
|
function clearLinkParams(): void {
|
||||||
|
const link = document.getElementById('export_current_view') as HTMLLinkElement;
|
||||||
|
const linkUpdated = link?.href.split('&')[0];
|
||||||
|
link.setAttribute('href', linkUpdated);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the Export View link to add the Quick Search parameters.
|
||||||
|
* @param event
|
||||||
|
*/
|
||||||
|
function handleQuickSearchParams(event: Event): void {
|
||||||
|
const quickSearchParameters = event.currentTarget as HTMLInputElement;
|
||||||
|
|
||||||
|
// Clear the existing search parameters
|
||||||
|
clearLinkParams();
|
||||||
|
|
||||||
|
if (quickSearchParameters != null) {
|
||||||
|
const link = document.getElementById('export_current_view') as HTMLLinkElement;
|
||||||
|
const search_parameter = `q=${quickSearchParameters.value}`;
|
||||||
|
const linkUpdated = link?.href + '&' + search_parameter;
|
||||||
|
link.setAttribute('href', linkUpdated);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize Quicksearch Event listener/handlers.
|
* Initialize Quicksearch Event listener/handlers.
|
||||||
*/
|
*/
|
||||||
export function initQuickSearch(): void {
|
export function initQuickSearch(): void {
|
||||||
const quicksearch = document.getElementById("quicksearch") as HTMLInputElement;
|
const quicksearch = document.getElementById('quicksearch') as HTMLInputElement;
|
||||||
const clearbtn = document.getElementById("quicksearch_clear") as HTMLAnchorElement;
|
const clearbtn = document.getElementById('quicksearch_clear') as HTMLAnchorElement;
|
||||||
if (isTruthy(quicksearch)) {
|
if (isTruthy(quicksearch)) {
|
||||||
quicksearch.addEventListener("keyup", quickSearchEventHandler, {
|
quicksearch.addEventListener('keyup', quickSearchEventHandler, {
|
||||||
passive: true
|
passive: true,
|
||||||
})
|
});
|
||||||
quicksearch.addEventListener("search", quickSearchEventHandler, {
|
quicksearch.addEventListener('search', quickSearchEventHandler, {
|
||||||
passive: true
|
passive: true,
|
||||||
})
|
});
|
||||||
|
quicksearch.addEventListener('change', handleQuickSearchParams, {
|
||||||
|
passive: true,
|
||||||
|
});
|
||||||
|
|
||||||
if (isTruthy(clearbtn)) {
|
if (isTruthy(clearbtn)) {
|
||||||
clearbtn.addEventListener("click", async () => {
|
clearbtn.addEventListener(
|
||||||
const search = new Event('search');
|
'click',
|
||||||
quicksearch.value = '';
|
async () => {
|
||||||
await new Promise(f => setTimeout(f, 100));
|
const search = new Event('search');
|
||||||
quicksearch.dispatchEvent(search);
|
quicksearch.value = '';
|
||||||
}, {
|
await new Promise(f => setTimeout(f, 100));
|
||||||
passive: true
|
quicksearch.dispatchEvent(search);
|
||||||
})
|
clearLinkParams();
|
||||||
|
},
|
||||||
|
{
|
||||||
|
passive: true,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
// Global variables
|
// Global variables
|
||||||
|
|
||||||
// Set base fonts
|
// Set base fonts
|
||||||
$font-family-base: 'Inter';
|
$font-family-sans-serif: 'Inter';
|
||||||
// See https://github.com/tabler/tabler/issues/1812
|
// See https://github.com/tabler/tabler/issues/1812
|
||||||
$font-family-monospace: 'Roboto Mono';
|
$font-family-monospace: 'Roboto Mono';
|
||||||
|
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
// Serialized data from change records
|
// Serialized data from change records
|
||||||
pre.change-data {
|
pre.change-data {
|
||||||
padding-right: 0;
|
border-radius: 0;
|
||||||
padding-left: 0;
|
padding: 0;
|
||||||
|
|
||||||
// Display each line individually for highlighting
|
// Display each line individually for highlighting
|
||||||
> span {
|
> span {
|
||||||
|
@ -1,3 +1,10 @@
|
|||||||
|
// Disable font-ligatures for Chromium based browsers
|
||||||
|
// Chromium requires `font-variant-ligatures: none` in addition to `font-feature-settings "liga" 0`
|
||||||
|
* {
|
||||||
|
font-feature-settings: "liga" 0;
|
||||||
|
font-variant-ligatures: none;
|
||||||
|
}
|
||||||
|
|
||||||
// Restore default foreground & background colors for <pre> blocks
|
// Restore default foreground & background colors for <pre> blocks
|
||||||
pre {
|
pre {
|
||||||
background-color: transparent;
|
background-color: transparent;
|
||||||
@ -32,3 +39,8 @@ table a {
|
|||||||
// Adjust table anchor link contrast as not enough contrast in dark mode
|
// Adjust table anchor link contrast as not enough contrast in dark mode
|
||||||
filter: brightness(110%);
|
filter: brightness(110%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Override background color alpha value
|
||||||
|
[data-bs-theme=dark] ::selection {
|
||||||
|
background-color: rgba(var(--tblr-primary-rgb),.48)
|
||||||
|
}
|
||||||
|
@ -1754,10 +1754,10 @@ graphql@16.8.1:
|
|||||||
resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.8.1.tgz#1930a965bef1170603702acdb68aedd3f3cf6f07"
|
resolved "https://registry.yarnpkg.com/graphql/-/graphql-16.8.1.tgz#1930a965bef1170603702acdb68aedd3f3cf6f07"
|
||||||
integrity sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw==
|
integrity sha512-59LZHPdGZVh695Ud9lRzPBVTtlX9ZCV150Er2W43ro37wVof0ctenSaskPPjN7lVTIN8mSZt8PHUNKZuNQUuxw==
|
||||||
|
|
||||||
gridstack@10.1.2:
|
gridstack@10.2.0:
|
||||||
version "10.1.2"
|
version "10.2.0"
|
||||||
resolved "https://registry.yarnpkg.com/gridstack/-/gridstack-10.1.2.tgz#58b5ae0057a8aa5e4f6563041c4ca2def3aa4268"
|
resolved "https://registry.yarnpkg.com/gridstack/-/gridstack-10.2.0.tgz#4ba9c7ee69a730851721a9f5cb33dc55026ded1f"
|
||||||
integrity sha512-Nn27XGQ68WtBC513cKQQ4t/dA2uuN/xnNUU50puXEJv6IFk5SzT0Dnsq68GpopO1n0tXUKZKm1Rw7uOUMDz1KQ==
|
integrity sha512-svKAOq/dfinpvhe/nnxdyZOOEd9qynXiOPHvL96PALE0yWChWp/6lechnqKwud0tL/rRyAfMJ6Hh/z2fS13pBA==
|
||||||
|
|
||||||
has-bigints@^1.0.1, has-bigints@^1.0.2:
|
has-bigints@^1.0.1, has-bigints@^1.0.2:
|
||||||
version "1.0.2"
|
version "1.0.2"
|
||||||
@ -2482,10 +2482,10 @@ safe-regex-test@^1.0.3:
|
|||||||
es-errors "^1.3.0"
|
es-errors "^1.3.0"
|
||||||
is-regex "^1.1.4"
|
is-regex "^1.1.4"
|
||||||
|
|
||||||
sass@1.77.1:
|
sass@1.77.4:
|
||||||
version "1.77.1"
|
version "1.77.4"
|
||||||
resolved "https://registry.yarnpkg.com/sass/-/sass-1.77.1.tgz#018cdfb206afd14724030c02e9fefd8f30a76cd0"
|
resolved "https://registry.yarnpkg.com/sass/-/sass-1.77.4.tgz#92059c7bfc56b827c56eb116778d157ec017a5cd"
|
||||||
integrity sha512-OMEyfirt9XEfyvocduUIOlUSkWOXS/LAt6oblR/ISXCTukyavjex+zQNm51pPCOiFKY1QpWvEH1EeCkgyV3I6w==
|
integrity sha512-vcF3Ckow6g939GMA4PeU7b2K/9FALXk2KF9J87txdHzXbUF9XRQRwSxcAs/fGaTnJeBFd7UoV22j3lzMLdM0Pw==
|
||||||
dependencies:
|
dependencies:
|
||||||
chokidar ">=3.0.0 <4.0.0"
|
chokidar ">=3.0.0 <4.0.0"
|
||||||
immutable "^4.0.0"
|
immutable "^4.0.0"
|
||||||
|
51
netbox/templates/circuits/circuittermination.html
Normal file
51
netbox/templates/circuits/circuittermination.html
Normal file
@ -0,0 +1,51 @@
|
|||||||
|
{% extends 'generic/object.html' %}
|
||||||
|
{% load helpers %}
|
||||||
|
{% load plugins %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block breadcrumbs %}
|
||||||
|
{{ block.super }}
|
||||||
|
<li class="breadcrumb-item"><a href="{% url 'circuits:circuit_list' %}?provider_id={{ object.circuit.provider.pk }}">{{ object.circuit.provider }}</a></li>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="row">
|
||||||
|
<div class="col col-md-6">
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
{% if object %}
|
||||||
|
<table class="table table-hover attr-table">
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Circuit" %}</th>
|
||||||
|
<td>
|
||||||
|
{{ object.circuit|linkify }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Provider" %}</th>
|
||||||
|
<td>
|
||||||
|
{{ object.circuit.provider|linkify }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% include 'circuits/inc/circuit_termination_fields.html' with termination=object %}
|
||||||
|
</table>
|
||||||
|
{% else %}
|
||||||
|
<div class="card-body">
|
||||||
|
<span class="text-muted">{% trans "None" %}</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
{% plugin_left_page object %}
|
||||||
|
</div>
|
||||||
|
<div class="col col-md-6">
|
||||||
|
{% include 'inc/panels/custom_fields.html' %}
|
||||||
|
{% include 'inc/panels/tags.html' %}
|
||||||
|
{% plugin_right_page object %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col col-md-12">
|
||||||
|
{% plugin_full_width_page object %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
@ -27,93 +27,7 @@
|
|||||||
</h5>
|
</h5>
|
||||||
{% if termination %}
|
{% if termination %}
|
||||||
<table class="table table-hover attr-table">
|
<table class="table table-hover attr-table">
|
||||||
{% if termination.site %}
|
{% include 'circuits/inc/circuit_termination_fields.html' with termination=termination %}
|
||||||
<tr>
|
|
||||||
<th scope="row">{% trans "Site" %}</th>
|
|
||||||
<td>
|
|
||||||
{% if termination.site.region %}
|
|
||||||
{{ termination.site.region|linkify }} /
|
|
||||||
{% endif %}
|
|
||||||
{{ termination.site|linkify }}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th scope="row">{% trans "Termination" %}</th>
|
|
||||||
<td>
|
|
||||||
{% if termination.mark_connected %}
|
|
||||||
<span class="text-success"><i class="mdi mdi-check-bold"></i></span>
|
|
||||||
<span class="text-muted">{% trans "Marked as connected" %}</span>
|
|
||||||
{% elif termination.cable %}
|
|
||||||
<a class="d-block d-md-inline" href="{{ termination.cable.get_absolute_url }}">{{ termination.cable }}</a> {% trans "to" %}
|
|
||||||
{% for peer in termination.link_peers %}
|
|
||||||
{% if peer.device %}
|
|
||||||
{{ peer.device|linkify }}<br/>
|
|
||||||
{% elif peer.circuit %}
|
|
||||||
{{ peer.circuit|linkify }}<br/>
|
|
||||||
{% endif %}
|
|
||||||
{{ peer|linkify }}{% if not forloop.last %},{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
<div class="mt-1">
|
|
||||||
<a href="{% url 'circuits:circuittermination_trace' pk=termination.pk %}" class="btn btn-primary lh-1" title="{% trans "Trace" %}">
|
|
||||||
<i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i> {% trans "Trace" %}
|
|
||||||
</a>
|
|
||||||
{% if perms.dcim.change_cable %}
|
|
||||||
<a href="{% url 'dcim:cable_edit' pk=termination.cable.pk %}?return_url={{ termination.circuit.get_absolute_url }}" title="{% trans "Edit cable" %}" class="btn btn-warning lh-1">
|
|
||||||
<i class="mdi mdi-ethernet-cable" aria-hidden="true"></i> {% trans "Edit" %}
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
{% if perms.dcim.delete_cable %}
|
|
||||||
<a href="{% url 'dcim:cable_delete' pk=termination.cable.pk %}?return_url={{ termination.circuit.get_absolute_url }}" title="{% trans "Remove cable" %}" class="btn btn-danger lh-1">
|
|
||||||
<i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i> {% trans "Disconnect" %}
|
|
||||||
</a>
|
|
||||||
{% endif %}
|
|
||||||
</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">
|
|
||||||
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span> {% trans "Connect" %}
|
|
||||||
</button>
|
|
||||||
<ul class="dropdown-menu">
|
|
||||||
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=circuits.circuittermination&a_terminations={{ termination.pk }}&b_terminations_type=dcim.interface&return_url={{ object.get_absolute_url }}">{% trans "Interface" %}</a></li>
|
|
||||||
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=circuits.circuittermination&a_terminations={{ termination.pk }}&b_terminations_type=dcim.frontport&return_url={{ object.get_absolute_url }}">{% trans "Front Port" %}</a></li>
|
|
||||||
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=circuits.circuittermination&a_terminations={{ termination.pk }}&b_terminations_type=dcim.rearport&return_url={{ object.get_absolute_url }}">{% trans "Rear Port" %}</a></li>
|
|
||||||
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=circuits.circuittermination&a_terminations={{ termination.pk }}&b_terminations_type=circuits.circuittermination&return_url={{ object.get_absolute_url }}">{% trans "Circuit Termination" %}</a></li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{% else %}
|
|
||||||
<tr>
|
|
||||||
<th scope="row">{% trans "Provider Network" %}</th>
|
|
||||||
<td>{{ termination.provider_network.provider|linkify }} / {{ termination.provider_network|linkify }}</td>
|
|
||||||
</tr>
|
|
||||||
{% endif %}
|
|
||||||
<tr>
|
|
||||||
<th scope="row">{% trans "Speed" %}</th>
|
|
||||||
<td>
|
|
||||||
{% if termination.port_speed and termination.upstream_speed %}
|
|
||||||
<i class="mdi mdi-arrow-down-bold" title="{% trans "Downstream" %}"></i> {{ termination.port_speed|humanize_speed }}
|
|
||||||
<i class="mdi mdi-arrow-up-bold" title="{% trans "Upstream" %}"></i> {{ termination.upstream_speed|humanize_speed }}
|
|
||||||
{% elif termination.port_speed %}
|
|
||||||
{{ termination.port_speed|humanize_speed }}
|
|
||||||
{% else %}
|
|
||||||
{{ ''|placeholder }}
|
|
||||||
{% endif %}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th scope="row">{% trans "Cross-Connect" %}</th>
|
|
||||||
<td>{{ termination.xconnect_id|placeholder }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th scope="row">{% trans "Patch Panel/Port" %}</th>
|
|
||||||
<td>{{ termination.pp_info|placeholder }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<th scope="row">{% trans "Description" %}</th>
|
|
||||||
<td>{{ termination.description|placeholder }}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="row">{% trans "Tags" %}</th>
|
<th scope="row">{% trans "Tags" %}</th>
|
||||||
<td>
|
<td>
|
||||||
|
@ -0,0 +1,90 @@
|
|||||||
|
{% load helpers %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% if termination.site %}
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Site" %}</th>
|
||||||
|
<td>
|
||||||
|
{% if termination.site.region %}
|
||||||
|
{{ termination.site.region|linkify }} /
|
||||||
|
{% endif %}
|
||||||
|
{{ termination.site|linkify }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Termination" %}</th>
|
||||||
|
<td>
|
||||||
|
{% if termination.mark_connected %}
|
||||||
|
<span class="text-success"><i class="mdi mdi-check-bold"></i></span>
|
||||||
|
<span class="text-muted">{% trans "Marked as connected" %}</span>
|
||||||
|
{% elif termination.cable %}
|
||||||
|
<a class="d-block d-md-inline" href="{{ termination.cable.get_absolute_url }}">{{ termination.cable }}</a> {% trans "to" %}
|
||||||
|
{% for peer in termination.link_peers %}
|
||||||
|
{% if peer.device %}
|
||||||
|
{{ peer.device|linkify }}<br/>
|
||||||
|
{% elif peer.circuit %}
|
||||||
|
{{ peer.circuit|linkify }}<br/>
|
||||||
|
{% endif %}
|
||||||
|
{{ peer|linkify }}{% if not forloop.last %},{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
<div class="mt-1">
|
||||||
|
<a href="{% url 'circuits:circuittermination_trace' pk=termination.pk %}" class="btn btn-primary lh-1" title="{% trans "Trace" %}">
|
||||||
|
<i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i> {% trans "Trace" %}
|
||||||
|
</a>
|
||||||
|
{% if perms.dcim.change_cable %}
|
||||||
|
<a href="{% url 'dcim:cable_edit' pk=termination.cable.pk %}?return_url={{ termination.circuit.get_absolute_url }}" title="{% trans "Edit cable" %}" class="btn btn-warning lh-1">
|
||||||
|
<i class="mdi mdi-ethernet-cable" aria-hidden="true"></i> {% trans "Edit" %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
{% if perms.dcim.delete_cable %}
|
||||||
|
<a href="{% url 'dcim:cable_delete' pk=termination.cable.pk %}?return_url={{ termination.circuit.get_absolute_url }}" title="{% trans "Remove cable" %}" class="btn btn-danger lh-1">
|
||||||
|
<i class="mdi mdi-ethernet-cable-off" aria-hidden="true"></i> {% trans "Disconnect" %}
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
</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">
|
||||||
|
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span> {% trans "Connect" %}
|
||||||
|
</button>
|
||||||
|
<ul class="dropdown-menu">
|
||||||
|
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=circuits.circuittermination&a_terminations={{ termination.pk }}&b_terminations_type=dcim.interface&return_url={{ object.get_absolute_url }}">{% trans "Interface" %}</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=circuits.circuittermination&a_terminations={{ termination.pk }}&b_terminations_type=dcim.frontport&return_url={{ object.get_absolute_url }}">{% trans "Front Port" %}</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=circuits.circuittermination&a_terminations={{ termination.pk }}&b_terminations_type=dcim.rearport&return_url={{ object.get_absolute_url }}">{% trans "Rear Port" %}</a></li>
|
||||||
|
<li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=circuits.circuittermination&a_terminations={{ termination.pk }}&b_terminations_type=circuits.circuittermination&return_url={{ object.get_absolute_url }}">{% trans "Circuit Termination" %}</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% else %}
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Provider Network" %}</th>
|
||||||
|
<td>{{ termination.provider_network.provider|linkify }} / {{ termination.provider_network|linkify }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Speed" %}</th>
|
||||||
|
<td>
|
||||||
|
{% if termination.port_speed and termination.upstream_speed %}
|
||||||
|
<i class="mdi mdi-arrow-down-bold" title="{% trans "Downstream" %}"></i> {{ termination.port_speed|humanize_speed }}
|
||||||
|
<i class="mdi mdi-arrow-up-bold" title="{% trans "Upstream" %}"></i> {{ termination.upstream_speed|humanize_speed }}
|
||||||
|
{% elif termination.port_speed %}
|
||||||
|
{{ termination.port_speed|humanize_speed }}
|
||||||
|
{% else %}
|
||||||
|
{{ ''|placeholder }}
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Cross-Connect" %}</th>
|
||||||
|
<td>{{ termination.xconnect_id|placeholder }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Patch Panel/Port" %}</th>
|
||||||
|
<td>{{ termination.pp_info|placeholder }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{% trans "Description" %}</th>
|
||||||
|
<td>{{ termination.description|placeholder }}</td>
|
||||||
|
</tr>
|
@ -5,7 +5,7 @@
|
|||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="col col-md-12">
|
<div class="col col-md-12">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<div class="card-body table-responsive">
|
<div class="table-responsive">
|
||||||
{% render_table table 'inc/table.html' %}
|
{% render_table table 'inc/table.html' %}
|
||||||
{% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
|
{% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
|
||||||
</div>
|
</div>
|
||||||
|
@ -5,6 +5,7 @@
|
|||||||
{% load helpers %}
|
{% load helpers %}
|
||||||
{% load plugins %}
|
{% load plugins %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
{% load l10n %}
|
||||||
{% load mptt %}
|
{% load mptt %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
@ -63,7 +64,7 @@
|
|||||||
{% if object.latitude and object.longitude %}
|
{% if object.latitude and object.longitude %}
|
||||||
{% if config.MAPS_URL %}
|
{% if config.MAPS_URL %}
|
||||||
<div class="position-absolute top-50 end-0 translate-middle-y d-print-none">
|
<div class="position-absolute top-50 end-0 translate-middle-y d-print-none">
|
||||||
<a href="{{ config.MAPS_URL }}{{ object.latitude }},{{ object.longitude }}" target="_blank" class="btn btn-primary">
|
<a href="{{ config.MAPS_URL }}{{ object.latitude|unlocalize }},{{ object.longitude|unlocalize }}" target="_blank" class="btn btn-primary">
|
||||||
<i class="mdi mdi-map-marker"></i> {% trans "Map It" %}
|
<i class="mdi mdi-map-marker"></i> {% trans "Map It" %}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
@ -3,6 +3,7 @@
|
|||||||
{% load plugins %}
|
{% load plugins %}
|
||||||
{% load tz %}
|
{% load tz %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
{% load l10n %}
|
||||||
{% load mptt %}
|
{% load mptt %}
|
||||||
|
|
||||||
{% block breadcrumbs %}
|
{% block breadcrumbs %}
|
||||||
@ -95,7 +96,7 @@
|
|||||||
{% if object.latitude and object.longitude %}
|
{% if object.latitude and object.longitude %}
|
||||||
{% if config.MAPS_URL %}
|
{% if config.MAPS_URL %}
|
||||||
<div class="position-absolute top-50 end-0 translate-middle-y d-print-none">
|
<div class="position-absolute top-50 end-0 translate-middle-y d-print-none">
|
||||||
<a href="{{ config.MAPS_URL }}{{ object.latitude }},{{ object.longitude }}" target="_blank" class="btn btn-primary">
|
<a href="{{ config.MAPS_URL }}{{ object.latitude|unlocalize }},{{ object.longitude|unlocalize }}" target="_blank" class="btn btn-primary">
|
||||||
<i class="mdi mdi-map-marker"></i> {% trans "Map It" %}
|
<i class="mdi mdi-map-marker"></i> {% trans "Map It" %}
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
{% extends 'generic/object.html' %}
|
{% extends 'generic/object.html' %}
|
||||||
{% load helpers %}
|
{% load helpers %}
|
||||||
|
{% load plugins %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
|
||||||
{% block title %}{{ object }}{% endblock %}
|
{% block title %}{{ object }}{% endblock %}
|
||||||
@ -22,7 +23,7 @@
|
|||||||
{% block subtitle %}{% endblock %}
|
{% block subtitle %}{% endblock %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row mb-3">
|
<div class="row">
|
||||||
<div class="col col-md-5">
|
<div class="col col-md-5">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h5 class="card-header">{% trans "Change" %}</h5>
|
<h5 class="card-header">{% trans "Change" %}</h5>
|
||||||
@ -104,7 +105,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row mb-3">
|
<div class="row">
|
||||||
<div class="col col-md-6">
|
<div class="col col-md-6">
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h5 class="card-header">{% trans "Pre-Change Data" %}</h5>
|
<h5 class="card-header">{% trans "Pre-Change Data" %}</h5>
|
||||||
@ -112,7 +113,7 @@
|
|||||||
{% if object.prechange_data %}
|
{% if object.prechange_data %}
|
||||||
{% spaceless %}
|
{% spaceless %}
|
||||||
<pre class="change-data">
|
<pre class="change-data">
|
||||||
{% for k, v in object.prechange_data.items %}
|
{% for k, v in object.prechange_data_clean.items %}
|
||||||
<span{% if k in diff_removed %} class="removed"{% endif %}>{{ k }}: {{ v|json }}</span>
|
<span{% if k in diff_removed %} class="removed"{% endif %}>{{ k }}: {{ v|json }}</span>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</pre>
|
</pre>
|
||||||
@ -132,7 +133,7 @@
|
|||||||
{% if object.postchange_data %}
|
{% if object.postchange_data %}
|
||||||
{% spaceless %}
|
{% spaceless %}
|
||||||
<pre class="change-data">
|
<pre class="change-data">
|
||||||
{% for k, v in object.postchange_data.items %}
|
{% for k, v in object.postchange_data_clean.items %}
|
||||||
<span{% if k in diff_added %} class="added"{% endif %}>{{ k }}: {{ v|json }}</span>
|
<span{% if k in diff_added %} class="added"{% endif %}>{{ k }}: {{ v|json }}</span>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</pre>
|
</pre>
|
||||||
@ -144,7 +145,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="row mb-3">
|
<div class="row">
|
||||||
|
<div class="col col-md-6">
|
||||||
|
{% plugin_left_page object %}
|
||||||
|
</div>
|
||||||
|
<div class="col col-md-6">
|
||||||
|
{% plugin_right_page object %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
<div class="col col-md-12">
|
<div class="col col-md-12">
|
||||||
{% include 'inc/panel_table.html' with table=related_changes_table heading='Related Changes' panel_class='default' %}
|
{% include 'inc/panel_table.html' with table=related_changes_table heading='Related Changes' panel_class='default' %}
|
||||||
{% if related_changes_count > related_changes_table.rows|length %}
|
{% if related_changes_count > related_changes_table.rows|length %}
|
||||||
@ -158,4 +167,9 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col col-md-12">
|
||||||
|
{% plugin_full_width_page object %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -14,38 +14,43 @@
|
|||||||
{% trans "You do not have permission to run scripts" %}.
|
{% trans "You do not have permission to run scripts" %}.
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<form action="" method="post" enctype="multipart/form-data" class="object-edit">
|
{% if form %}
|
||||||
{% csrf_token %}
|
<form action="" method="post" enctype="multipart/form-data" class="object-edit">
|
||||||
<div class="field-group my-4">
|
{% csrf_token %}
|
||||||
{# Render grouped fields according to declared fieldsets #}
|
<div class="field-group my-4">
|
||||||
{% for group, fields in script_class.get_fieldsets %}
|
{# Render grouped fields according to declared fieldsets #}
|
||||||
{% if fields %}
|
{% for group, fields in script_class.get_fieldsets %}
|
||||||
<div class="field-group mb-5">
|
{% if fields %}
|
||||||
<div class="row">
|
<div class="field-group mb-5">
|
||||||
<h5 class="col-9 offset-3">{{ group }}</h5>
|
<div class="row">
|
||||||
|
<h5 class="col-9 offset-3">{{ group }}</h5>
|
||||||
|
</div>
|
||||||
|
{% for name in fields %}
|
||||||
|
{% with field=form|getfield:name %}
|
||||||
|
{% render_field field %}
|
||||||
|
{% endwith %}
|
||||||
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% for name in fields %}
|
{% endif %}
|
||||||
{% with field=form|getfield:name %}
|
{% endfor %}
|
||||||
{% render_field field %}
|
</div>
|
||||||
{% endwith %}
|
<div class="text-end">
|
||||||
{% endfor %}
|
<a href="{% url 'extras:script_list' %}" class="btn btn-outline-secondary">{% trans "Cancel" %}</a>
|
||||||
</div>
|
{% if not request.user|can_run:script or not script.is_executable %}
|
||||||
|
<button class="btn btn-primary" disabled>
|
||||||
|
<i class="mdi mdi-play"></i> {% trans "Run Script" %}
|
||||||
|
</button>
|
||||||
|
{% else %}
|
||||||
|
<button type="submit" name="_run" class="btn btn-primary">
|
||||||
|
<i class="mdi mdi-play"></i> {% trans "Run Script" %}
|
||||||
|
</button>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{% endfor %}
|
</div>
|
||||||
</div>
|
</form>
|
||||||
<div class="text-end">
|
{% else %}
|
||||||
<a href="{% url 'extras:script_list' %}" class="btn btn-outline-secondary">{% trans "Cancel" %}</a>
|
<p>{% trans "Error loading script" %}.</p>
|
||||||
{% if not request.user|can_run:script or not script.is_executable %}
|
<pre class="block">{{ script.module.error }}</pre>
|
||||||
<button class="btn btn-primary" disabled>
|
{% endif %}
|
||||||
<i class="mdi mdi-play"></i> {% trans "Run Script" %}
|
|
||||||
</button>
|
|
||||||
{% else %}
|
|
||||||
<button type="submit" name="_run" class="btn btn-primary">
|
|
||||||
<i class="mdi mdi-play"></i> {% trans "Run Script" %}
|
|
||||||
</button>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock content %}
|
{% endblock content %}
|
||||||
|
@ -4,7 +4,7 @@
|
|||||||
{% load log_levels %}
|
{% load log_levels %}
|
||||||
{% load i18n %}
|
{% load i18n %}
|
||||||
|
|
||||||
{% block title %}{{ script }}{% endblock %}
|
{% block title %}{{ script.python_class.name }}{% endblock %}
|
||||||
|
|
||||||
{% block object_identifier %}
|
{% block object_identifier %}
|
||||||
{{ script.full_name }}
|
{{ script.full_name }}
|
||||||
@ -17,7 +17,7 @@
|
|||||||
|
|
||||||
{% block subtitle %}
|
{% block subtitle %}
|
||||||
<div class="text-secondary fs-5">
|
<div class="text-secondary fs-5">
|
||||||
{{ script.Meta.description|markdown }}
|
{{ script.python_class.Meta.description|markdown }}
|
||||||
</div>
|
</div>
|
||||||
{% endblock subtitle %}
|
{% endblock subtitle %}
|
||||||
|
|
||||||
|
@ -1,6 +1,14 @@
|
|||||||
{% extends 'extras/script/base.html' %}
|
{% extends 'extras/script/base.html' %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<code class="h6 my-3 d-block">{{ script_class.filename }}</code>
|
|
||||||
<pre class="block">{{ script_class.source }}</pre>
|
{% if script_class %}
|
||||||
|
<code class="h6 my-3 d-block">{{ script_class.filename }}</code>
|
||||||
|
<pre class="block">{{ script_class.source }}</pre>
|
||||||
|
{% else %}
|
||||||
|
<p>{% trans "Error loading script" %}.</p>
|
||||||
|
<pre class="block">{{ script.module.error }}</pre>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -56,15 +56,15 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<td>
|
<td>
|
||||||
{% if script.is_executable %}
|
{% if script.is_executable %}
|
||||||
<a href="{% url 'extras:script' script.pk %}" id="{{ script.module }}.{{ script.class_name }}">{{ script.name }}</a>
|
<a href="{% url 'extras:script' script.pk %}" id="{{ script.module }}.{{ script.class_name }}">{{ script.python_class.name }}</a>
|
||||||
{% else %}
|
{% else %}
|
||||||
<a href="{% url 'extras:script_jobs' script.pk %}" id="{{ script.module }}.{{ script.class_name }}">{{ script.name }}</a>
|
<a href="{% url 'extras:script_jobs' script.pk %}" id="{{ script.module }}.{{ script.class_name }}">{{ script.python_class.name }}</a>
|
||||||
<span class="text-danger">
|
<span class="text-danger">
|
||||||
<i class="mdi mdi-alert" title="{% trans "Script is no longer present in the source file" %}"></i>
|
<i class="mdi mdi-alert" title="{% trans "Script is no longer present in the source file" %}"></i>
|
||||||
</span>
|
</span>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td>{{ script.description|markdown|placeholder }}</td>
|
<td>{{ script.python_class.Meta.description|markdown|placeholder }}</td>
|
||||||
{% if last_job %}
|
{% if last_job %}
|
||||||
<td>
|
<td>
|
||||||
<a href="{% url 'extras:script_result' job_pk=last_job.pk %}">{{ last_job.created|isodatetime }}</a>
|
<a href="{% url 'extras:script_result' job_pk=last_job.pk %}">{{ last_job.created|isodatetime }}</a>
|
||||||
|
@ -59,7 +59,7 @@
|
|||||||
<th scope="row"><i class="mdi mdi-chip"></i> {% trans "Memory" %}</th>
|
<th scope="row"><i class="mdi mdi-chip"></i> {% trans "Memory" %}</th>
|
||||||
<td>
|
<td>
|
||||||
{% if memory_sum %}
|
{% if memory_sum %}
|
||||||
{{ memory_sum|humanize_megabytes }}
|
<span title={{ memory_sum }}>{{ memory_sum|humanize_megabytes }}</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ ''|placeholder }}
|
{{ ''|placeholder }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -125,7 +125,7 @@
|
|||||||
<th scope="row"><i class="mdi mdi-chip"></i> {% trans "Memory" %}</th>
|
<th scope="row"><i class="mdi mdi-chip"></i> {% trans "Memory" %}</th>
|
||||||
<td>
|
<td>
|
||||||
{% if object.memory %}
|
{% if object.memory %}
|
||||||
{{ object.memory|humanize_megabytes }}
|
<span title={{ object.memory }}>{{ object.memory|humanize_megabytes }}</span>
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ ''|placeholder }}
|
{{ ''|placeholder }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
@ -21,7 +21,7 @@ __all__ = (
|
|||||||
class ContactGroupSerializer(NestedGroupModelSerializer):
|
class ContactGroupSerializer(NestedGroupModelSerializer):
|
||||||
url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contactgroup-detail')
|
url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:contactgroup-detail')
|
||||||
parent = NestedContactGroupSerializer(required=False, allow_null=True, default=None)
|
parent = NestedContactGroupSerializer(required=False, allow_null=True, default=None)
|
||||||
contact_count = serializers.IntegerField(read_only=True)
|
contact_count = serializers.IntegerField(read_only=True, default=0)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ContactGroup
|
model = ContactGroup
|
||||||
|
@ -14,7 +14,7 @@ __all__ = (
|
|||||||
class TenantGroupSerializer(NestedGroupModelSerializer):
|
class TenantGroupSerializer(NestedGroupModelSerializer):
|
||||||
url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:tenantgroup-detail')
|
url = serializers.HyperlinkedIdentityField(view_name='tenancy-api:tenantgroup-detail')
|
||||||
parent = NestedTenantGroupSerializer(required=False, allow_null=True)
|
parent = NestedTenantGroupSerializer(required=False, allow_null=True)
|
||||||
tenant_count = serializers.IntegerField(read_only=True)
|
tenant_count = serializers.IntegerField(read_only=True, default=0)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = TenantGroup
|
model = TenantGroup
|
||||||
|
Binary file not shown.
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user